├── frontend ├── .prettierignore ├── .dockerignore ├── src │ ├── react-app-env.d.ts │ ├── models │ │ ├── auth │ │ │ ├── LoginRequest.ts │ │ │ └── AuthResponse.ts │ │ ├── content │ │ │ ├── ContentQuery.ts │ │ │ ├── CreateContentRequest.ts │ │ │ ├── UpdateContentRequest.ts │ │ │ └── Content.ts │ │ ├── course │ │ │ ├── CourseQuery.ts │ │ │ ├── CreateCourseRequest.ts │ │ │ ├── UpdateCourseRequest.ts │ │ │ └── Course.ts │ │ ├── stats │ │ │ └── Stats.ts │ │ └── user │ │ │ ├── UserQuery.ts │ │ │ ├── User.ts │ │ │ ├── CreateUserRequest.ts │ │ │ └── UpdateUserRequest.ts │ ├── hooks │ │ └── useAuth.tsx │ ├── setupTests.ts │ ├── services │ │ ├── StatsService.ts │ │ ├── ApiService.ts │ │ ├── CourseService.ts │ │ ├── AuthService.ts │ │ ├── ContentService.ts │ │ └── UserService.ts │ ├── components │ │ ├── shared │ │ │ ├── TableItem.tsx │ │ │ ├── Table.tsx │ │ │ └── Modal.tsx │ │ ├── layout │ │ │ ├── SidebarItem.tsx │ │ │ ├── index.tsx │ │ │ └── Sidebar.tsx │ │ ├── dashboard │ │ │ └── UpdateProfile.tsx │ │ ├── courses │ │ │ └── CoursesTable.tsx │ │ ├── content │ │ │ └── ContentsTable.tsx │ │ └── users │ │ │ └── UsersTable.tsx │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── context │ │ └── AuthenticationContext.tsx │ ├── styles │ │ └── index.css │ ├── Route.tsx │ ├── App.tsx │ └── pages │ │ ├── Dashboard.tsx │ │ ├── Login.tsx │ │ ├── Courses.tsx │ │ ├── Contents.tsx │ │ └── Users.tsx ├── .prettierrc ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── craco.config.js ├── Dockerfile ├── tailwind.config.js ├── .gitignore ├── tsconfig.json ├── nginx.conf ├── package.json └── README.md ├── backend ├── .dockerignore ├── .prettierrc ├── src │ ├── content │ │ ├── content.query.ts │ │ ├── content.module.ts │ │ ├── content.dto.ts │ │ ├── content.entity.ts │ │ ├── content.service.ts │ │ └── content.service.spec.ts │ ├── course │ │ ├── course.query.ts │ │ ├── course.module.ts │ │ ├── course.dto.ts │ │ ├── course.entity.ts │ │ ├── course.service.ts │ │ ├── course.controller.ts │ │ ├── course.service.spec.ts │ │ └── course.controller.spec.ts │ ├── enums │ │ └── role.enum.ts │ ├── user │ │ ├── user.query.ts │ │ ├── user.module.ts │ │ ├── user.entity.ts │ │ ├── user.dto.ts │ │ ├── user.controller.ts │ │ ├── user.service.ts │ │ ├── user.controller.spec.ts │ │ └── user.service.spec.ts │ ├── stats │ │ ├── stats.dto.ts │ │ ├── stats.controller.ts │ │ ├── stats.module.ts │ │ ├── stats.service.ts │ │ ├── stats.service.spec.ts │ │ └── stats.controller.spec.ts │ ├── auth │ │ ├── guards │ │ │ ├── jwt.guard.ts │ │ │ ├── jwt.guard.spec.ts │ │ │ ├── roles.guard.spec.ts │ │ │ ├── user.guard.ts │ │ │ ├── roles.guard.ts │ │ │ └── user.guard.spec.ts │ │ ├── auth.dto.ts │ │ ├── auth.module.ts │ │ ├── jwt.strategy.ts │ │ ├── auth.controller.ts │ │ ├── auth.service.spec.ts │ │ ├── auth.controller.spec.ts │ │ └── auth.service.ts │ ├── decorators │ │ └── roles.decorator.ts │ ├── app.module.ts │ └── main.ts ├── tsconfig.build.json ├── nest-cli.json ├── .env ├── ormconfig.js ├── tsconfig.json ├── dockerfile ├── .gitignore ├── .eslintrc.js ├── package.json ├── README.md └── e2e │ └── app.e2e.test.json ├── docker-compose.yml └── README.md /frontend/.prettierignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | build -------------------------------------------------------------------------------- /frontend/.dockerignore: -------------------------------------------------------------------------------- 1 | build 2 | node_modules 3 | .git -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | dist 2 | node_modules 3 | .env 4 | .git -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /backend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artuncolak/nest-react-admin/HEAD/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artuncolak/nest-react-admin/HEAD/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/artuncolak/nest-react-admin/HEAD/frontend/public/logo512.png -------------------------------------------------------------------------------- /backend/src/content/content.query.ts: -------------------------------------------------------------------------------- 1 | export class ContentQuery { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/course/course.query.ts: -------------------------------------------------------------------------------- 1 | export class CourseQuery { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/enums/role.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Role { 2 | User = 'user', 3 | Editor = 'editor', 4 | Admin = 'admin', 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/models/auth/LoginRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface LoginRequest { 2 | username: string; 3 | password: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/content/ContentQuery.ts: -------------------------------------------------------------------------------- 1 | export default interface ContentQuery { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/course/CourseQuery.ts: -------------------------------------------------------------------------------- 1 | export default interface CourseQuery { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/course/CreateCourseRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface CreateCourseRequest { 2 | name: string; 3 | description: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/content/CreateContentRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface CreateContentRequest { 2 | name: string; 3 | description: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/content/UpdateContentRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface UpdateContentRequest { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/models/course/UpdateCourseRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface UpdateCourseRequest { 2 | name?: string; 3 | description?: string; 4 | } 5 | -------------------------------------------------------------------------------- /backend/src/user/user.query.ts: -------------------------------------------------------------------------------- 1 | export class UserQuery { 2 | firstName?: string; 3 | lastName?: string; 4 | username?: string; 5 | role?: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/stats/stats.dto.ts: -------------------------------------------------------------------------------- 1 | export interface StatsResponseDto { 2 | numberOfUsers: number; 3 | numberOfCourses: number; 4 | numberOfContents: number; 5 | } 6 | -------------------------------------------------------------------------------- /frontend/src/models/auth/AuthResponse.ts: -------------------------------------------------------------------------------- 1 | import User from '../user/User'; 2 | 3 | export default interface AuthResponse { 4 | token: string; 5 | user: User; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/models/content/Content.ts: -------------------------------------------------------------------------------- 1 | export default interface Content { 2 | id: string; 3 | name: string; 4 | description: string; 5 | dateCreated: Date; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/models/course/Course.ts: -------------------------------------------------------------------------------- 1 | export default interface Course { 2 | id: string; 3 | name: string; 4 | description: string; 5 | dateCreated: Date; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/models/stats/Stats.ts: -------------------------------------------------------------------------------- 1 | export default interface Stats { 2 | numberOfUsers: number; 3 | numberOfCourses: number; 4 | numberOfContents: number; 5 | } 6 | -------------------------------------------------------------------------------- /backend/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src", 4 | "compilerOptions": { 5 | "plugins": ["@nestjs/swagger"] 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | style: { 3 | postcss: { 4 | plugins: [require('tailwindcss'), require('autoprefixer')], 5 | }, 6 | }, 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/models/user/UserQuery.ts: -------------------------------------------------------------------------------- 1 | export default interface UserQuery { 2 | firstName: string; 3 | lastName: string; 4 | username: string; 5 | role: string; 6 | } 7 | -------------------------------------------------------------------------------- /backend/src/auth/guards/jwt.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class JwtGuard extends AuthGuard('jwt') {} 6 | -------------------------------------------------------------------------------- /frontend/src/models/user/User.ts: -------------------------------------------------------------------------------- 1 | export default interface User { 2 | id: string; 3 | firstName: string; 4 | lastName: string; 5 | username: string; 6 | role: string; 7 | isActive: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/models/user/CreateUserRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface CreateUserRequest { 2 | firstName: string; 3 | lastName: string; 4 | username: string; 5 | password: string; 6 | role: string; 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/auth/guards/jwt.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { JwtGuard } from './jwt.guard'; 2 | 3 | describe('JwtGuard', () => { 4 | it('should be defined', () => { 5 | expect(new JwtGuard()).toBeDefined(); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /frontend/src/models/user/UpdateUserRequest.ts: -------------------------------------------------------------------------------- 1 | export default interface UpdateUserRequest { 2 | firstName?: string; 3 | lastName?: string; 4 | username?: string; 5 | password?: string; 6 | role?: string; 7 | isActive?: boolean; 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/hooks/useAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { AuthenticationContext } from '../context/AuthenticationContext'; 4 | 5 | export default function useAuth() { 6 | return useContext(AuthenticationContext); 7 | } 8 | -------------------------------------------------------------------------------- /backend/src/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | import { Role } from '../enums/role.enum'; 4 | 5 | export const ROLES_KEY = 'roles'; 6 | export const Roles = (...roles: Role[]) => SetMetadata(ROLES_KEY, roles); 7 | -------------------------------------------------------------------------------- /frontend/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as build 2 | 3 | WORKDIR /app 4 | 5 | COPY . . 6 | 7 | RUN yarn 8 | RUN yarn build 9 | 10 | #--- 11 | 12 | FROM nginx:alpine 13 | 14 | COPY --from=build /app/build/ /var/www 15 | COPY nginx.conf /etc/nginx/nginx.conf 16 | 17 | EXPOSE 80 -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /backend/src/auth/guards/roles.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { Reflector } from '@nestjs/core'; 2 | 3 | import { RolesGuard } from './roles.guard'; 4 | 5 | describe('RolesGuard', () => { 6 | it('should be defined', () => { 7 | expect(new RolesGuard(new Reflector())).toBeDefined(); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/services/StatsService.ts: -------------------------------------------------------------------------------- 1 | import Stats from '../models/stats/Stats'; 2 | import apiService from './ApiService'; 3 | 4 | class StatsService { 5 | async getStats(): Promise { 6 | return (await apiService.get('/api/stats')).data; 7 | } 8 | } 9 | 10 | export default new StatsService(); 11 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | purge: ['./src/**/*.{js,jsx,ts,tsx}', './public/index.html'], 3 | darkMode: false, 4 | theme: { 5 | extend: {}, 6 | }, 7 | variants: { 8 | extend: { 9 | opacity: ['disabled'], 10 | }, 11 | }, 12 | plugins: [require('@tailwindcss/forms')], 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { UserController } from './user.controller'; 4 | import { UserService } from './user.service'; 5 | 6 | @Module({ 7 | controllers: [UserController], 8 | providers: [UserService], 9 | exports: [UserService], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /backend/src/auth/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty } from 'class-validator'; 2 | import { User } from 'src/user/user.entity'; 3 | 4 | export class LoginDto { 5 | @IsNotEmpty() 6 | username: string; 7 | 8 | @IsNotEmpty() 9 | password: string; 10 | } 11 | 12 | export class LoginResponseDto { 13 | token: string; 14 | user: User; 15 | } 16 | -------------------------------------------------------------------------------- /backend/.env: -------------------------------------------------------------------------------- 1 | # Database 2 | DATABASE_HOST="localhost" 3 | DATABASE_PORT=5432 4 | DATABASE_USERNAME="postgres" 5 | DATABASE_PASSWORD="1234" 6 | DATABASE_NAME="carna-database" 7 | 8 | # JWT 9 | JWT_SECRET="4125442A472D4B6150645367556B58703273357638792F423F4528482B4D6251" 10 | JWT_REFRESH_SECRET="576E5A7134743777217A25432A462D4A614E645267556B58703273357538782F" -------------------------------------------------------------------------------- /backend/ormconfig.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | type: 'postgres', 3 | host: process.env.DATABASE_HOST, 4 | port: Number(process.env.DATABASE_PORT), 5 | username: process.env.DATABASE_USERNAME, 6 | password: process.env.DATABASE_PASSWORD, 7 | database: process.env.DATABASE_NAME, 8 | entities: ['dist/**/*.entity{.ts,.js}'], 9 | synchronize: true, 10 | }; 11 | -------------------------------------------------------------------------------- /backend/src/content/content.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | 3 | import { CourseModule } from '../course/course.module'; 4 | import { ContentService } from './content.service'; 5 | 6 | @Module({ 7 | imports: [forwardRef(() => CourseModule)], 8 | controllers: [], 9 | providers: [ContentService], 10 | exports: [ContentService], 11 | }) 12 | export class ContentModule {} 13 | -------------------------------------------------------------------------------- /frontend/src/components/shared/TableItem.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | interface TableItemProps { 4 | children: ReactNode; 5 | className?: string; 6 | } 7 | 8 | export default function TableItem({ children, className }: TableItemProps) { 9 | return ( 10 | 11 | {children} 12 | 13 | ); 14 | } 15 | -------------------------------------------------------------------------------- /backend/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 | } 15 | } 16 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /backend/src/course/course.module.ts: -------------------------------------------------------------------------------- 1 | import { forwardRef, Module } from '@nestjs/common'; 2 | 3 | import { ContentModule } from '../content/content.module'; 4 | import { CourseController } from './course.controller'; 5 | import { CourseService } from './course.service'; 6 | 7 | @Module({ 8 | imports: [forwardRef(() => ContentModule)], 9 | controllers: [CourseController], 10 | providers: [CourseService], 11 | exports: [CourseService], 12 | }) 13 | export class CourseModule {} 14 | -------------------------------------------------------------------------------- /backend/src/course/course.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class CreateCourseDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | description: string; 11 | } 12 | 13 | export class UpdateCourseDto { 14 | @IsOptional() 15 | @IsNotEmpty() 16 | @IsString() 17 | name?: string; 18 | 19 | @IsOptional() 20 | @IsNotEmpty() 21 | @IsString() 22 | description?: string; 23 | } 24 | -------------------------------------------------------------------------------- /backend/src/content/content.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsOptional, IsString } from 'class-validator'; 2 | 3 | export class CreateContentDto { 4 | @IsNotEmpty() 5 | @IsString() 6 | name: string; 7 | 8 | @IsNotEmpty() 9 | @IsString() 10 | description: string; 11 | } 12 | 13 | export class UpdateContentDto { 14 | @IsOptional() 15 | @IsNotEmpty() 16 | @IsString() 17 | name?: string; 18 | 19 | @IsOptional() 20 | @IsNotEmpty() 21 | @IsString() 22 | description?: string; 23 | } 24 | -------------------------------------------------------------------------------- /backend/dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:alpine as build 2 | 3 | WORKDIR /app 4 | 5 | COPY package.json . 6 | COPY yarn.lock . 7 | 8 | RUN yarn 9 | 10 | COPY . . 11 | 12 | RUN yarn build 13 | 14 | #--- 15 | 16 | FROM node:alpine 17 | 18 | WORKDIR /app 19 | 20 | ENV NODE_ENV=production 21 | 22 | COPY package.json . 23 | COPY yarn.lock . 24 | COPY ormconfig.js . 25 | 26 | RUN yarn install --production 27 | 28 | COPY --from=build /app/dist ./dist 29 | 30 | EXPOSE 5000 31 | 32 | ENTRYPOINT [ "yarn", "start:prod" ] -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /backend/src/stats/stats.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get } from '@nestjs/common'; 2 | import { ApiTags } from '@nestjs/swagger'; 3 | 4 | import { StatsResponseDto } from './stats.dto'; 5 | import { StatsService } from './stats.service'; 6 | 7 | @Controller('stats') 8 | @ApiTags('Stats') 9 | export class StatsController { 10 | constructor(private readonly statsService: StatsService) {} 11 | 12 | @Get() 13 | async getStats(): Promise { 14 | return await this.statsService.getStats(); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json -------------------------------------------------------------------------------- /backend/src/stats/stats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ContentModule } from '../content/content.module'; 4 | import { CourseModule } from '../course/course.module'; 5 | import { UserModule } from '../user/user.module'; 6 | import { StatsController } from './stats.controller'; 7 | import { StatsService } from './stats.service'; 8 | 9 | @Module({ 10 | imports: [UserModule, ContentModule, CourseModule], 11 | controllers: [StatsController], 12 | providers: [StatsService], 13 | }) 14 | export class StatsModule {} 15 | -------------------------------------------------------------------------------- /backend/src/course/course.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | OneToMany, 6 | PrimaryGeneratedColumn, 7 | } from 'typeorm'; 8 | 9 | import { Content } from '../content/content.entity'; 10 | 11 | @Entity() 12 | export class Course extends BaseEntity { 13 | @PrimaryGeneratedColumn('uuid') 14 | id: string; 15 | 16 | @Column() 17 | name: string; 18 | 19 | @Column() 20 | description: string; 21 | 22 | @Column() 23 | dateCreated: Date; 24 | 25 | @OneToMany(() => Content, (content) => content.course) 26 | contents: Content[]; 27 | } 28 | -------------------------------------------------------------------------------- /backend/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { PassportModule } from '@nestjs/passport'; 4 | 5 | import { UserModule } from '../user/user.module'; 6 | import { AuthController } from './auth.controller'; 7 | import { AuthService } from './auth.service'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | 10 | @Module({ 11 | imports: [UserModule, PassportModule, JwtModule.register({})], 12 | controllers: [AuthController], 13 | providers: [AuthService, JwtModule, JwtStrategy], 14 | }) 15 | export class AuthModule {} 16 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": false, 10 | "forceConsistentCasingInFileNames": true, 11 | "noFallthroughCasesInSwitch": true, 12 | "module": "esnext", 13 | "moduleResolution": "node", 14 | "resolveJsonModule": true, 15 | "isolatedModules": true, 16 | "noEmit": true, 17 | "jsx": "react-jsx" 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /backend/src/auth/guards/user.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class UserGuard implements CanActivate { 5 | async canActivate(context: ExecutionContext): Promise { 6 | const request = context.switchToHttp().getRequest(); 7 | const params = request.params; 8 | const user = request.user; 9 | 10 | /* It returns true if user's role is admin or user's id is match with the request parameter */ 11 | 12 | if (user.role === 'admin') { 13 | return true; 14 | } 15 | 16 | return user.userId === params.id; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /backend/src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | 5 | @Injectable() 6 | export class JwtStrategy extends PassportStrategy(Strategy) { 7 | constructor() { 8 | super({ 9 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 10 | ignoreExpiration: false, 11 | secretOrKey: process.env.JWT_SECRET, 12 | }); 13 | } 14 | 15 | async validate(payload: any) { 16 | return { 17 | userId: payload.sub, 18 | username: payload.username, 19 | role: payload.role, 20 | }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /frontend/nginx.conf: -------------------------------------------------------------------------------- 1 | events { 2 | worker_connections 8000; 3 | multi_accept on; 4 | } 5 | 6 | http { 7 | include mime.types; 8 | default_type application/octet-stream; 9 | 10 | sendfile on; 11 | 12 | keepalive_timeout 65; 13 | 14 | server { 15 | listen 80; 16 | 17 | location / { 18 | root /var/www; 19 | index index.html; 20 | try_files $uri $uri/ /index.html; 21 | } 22 | 23 | location /api { 24 | proxy_pass http://backend:5000/api; 25 | } 26 | 27 | error_page 500 502 503 504 /50x.html; 28 | location = /50x.html { 29 | root html; 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /frontend/src/components/layout/SidebarItem.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { ChevronRight } from 'react-feather'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | interface SidebarItemProps { 6 | children: ReactNode; 7 | to: string; 8 | active?: boolean; 9 | } 10 | 11 | export default function SidebarItem({ 12 | children, 13 | to, 14 | active = false, 15 | }: SidebarItemProps) { 16 | return ( 17 | 21 | 22 | {children} {active ? : null} 23 | 24 | 25 | ); 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import './styles/index.css'; 2 | 3 | import React from 'react'; 4 | import ReactDOM from 'react-dom'; 5 | import { QueryClient, QueryClientProvider } from 'react-query'; 6 | 7 | import App from './App'; 8 | import { AuthenticationProvider } from './context/AuthenticationContext'; 9 | import reportWebVitals from './reportWebVitals'; 10 | 11 | const queryClient = new QueryClient(); 12 | 13 | ReactDOM.render( 14 | 15 | 16 | 17 | 18 | 19 | 20 | , 21 | document.getElementById('root'), 22 | ); 23 | 24 | reportWebVitals(); 25 | -------------------------------------------------------------------------------- /backend/src/content/content.entity.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BaseEntity, 3 | Column, 4 | Entity, 5 | JoinColumn, 6 | ManyToOne, 7 | PrimaryGeneratedColumn, 8 | } from 'typeorm'; 9 | 10 | import { Course } from '../course/course.entity'; 11 | 12 | @Entity() 13 | export class Content extends BaseEntity { 14 | @PrimaryGeneratedColumn('uuid') 15 | id: string; 16 | 17 | @Column() 18 | name: string; 19 | 20 | @Column() 21 | description: string; 22 | 23 | @Column() 24 | dateCreated: Date; 25 | 26 | @Column({ select: false, nullable: false }) 27 | courseId: string; 28 | 29 | @ManyToOne(() => Course, (course) => course.contents, { onDelete: 'CASCADE' }) 30 | @JoinColumn({ name: 'courseId' }) 31 | course: Course; 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/context/AuthenticationContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, Dispatch, SetStateAction, useState } from 'react'; 2 | 3 | import User from '../models/user/User'; 4 | 5 | interface AuthContextValue { 6 | authenticatedUser: User; 7 | setAuthenticatedUser: Dispatch>; 8 | } 9 | 10 | export const AuthenticationContext = createContext(null); 11 | 12 | export function AuthenticationProvider({ children }) { 13 | const [authenticatedUser, setAuthenticatedUser] = useState(); 14 | 15 | return ( 16 | 19 | {children} 20 | 21 | ); 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { TypeOrmModule } from '@nestjs/typeorm'; 4 | 5 | import { AuthModule } from './auth/auth.module'; 6 | import { ContentModule } from './content/content.module'; 7 | import { CourseModule } from './course/course.module'; 8 | import { StatsModule } from './stats/stats.module'; 9 | import { UserModule } from './user/user.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | ConfigModule.forRoot({ isGlobal: true }), 14 | TypeOrmModule.forRoot(), 15 | UserModule, 16 | AuthModule, 17 | CourseModule, 18 | ContentModule, 19 | StatsModule, 20 | ], 21 | controllers: [], 22 | providers: [], 23 | }) 24 | export class AppModule {} 25 | -------------------------------------------------------------------------------- /backend/src/user/user.entity.ts: -------------------------------------------------------------------------------- 1 | import { Exclude } from 'class-transformer'; 2 | import { BaseEntity, Column, Entity, PrimaryGeneratedColumn } from 'typeorm'; 3 | 4 | import { Role } from '../enums/role.enum'; 5 | 6 | @Entity() 7 | export class User extends BaseEntity { 8 | @PrimaryGeneratedColumn('uuid') 9 | id: string; 10 | 11 | @Column() 12 | firstName: string; 13 | 14 | @Column() 15 | lastName: string; 16 | 17 | @Column() 18 | username: string; 19 | 20 | @Column() 21 | @Exclude() 22 | password: string; 23 | 24 | @Column({ type: 'enum', enum: Role, default: Role.User }) 25 | role: Role; 26 | 27 | @Column({ nullable: true }) 28 | @Exclude() 29 | refreshToken: string; 30 | 31 | @Column({ default: true }) 32 | isActive: boolean; 33 | } 34 | -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 14 | Carna Project 15 | 16 | 17 | 18 |
19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /frontend/src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Menu, X } from 'react-feather'; 3 | 4 | import Sidebar from './Sidebar'; 5 | 6 | export default function Layout({ children }) { 7 | const [showSidebar, setShowSidebar] = useState(false); 8 | 9 | return ( 10 | <> 11 | 12 |
13 | {children} 14 |
15 | 23 | 24 | ); 25 | } 26 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | sourceType: 'module', 6 | }, 7 | plugins: ['@typescript-eslint/eslint-plugin', 'simple-import-sort'], 8 | extends: [ 9 | 'plugin:@typescript-eslint/recommended', 10 | 'prettier/@typescript-eslint', 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 | 'simple-import-sort/imports': 'error', 25 | 'simple-import-sort/exports': 'error', 26 | }, 27 | }; 28 | -------------------------------------------------------------------------------- /backend/src/stats/stats.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import { ContentService } from '../content/content.service'; 4 | import { CourseService } from '../course/course.service'; 5 | import { UserService } from '../user/user.service'; 6 | import { StatsResponseDto } from './stats.dto'; 7 | 8 | @Injectable() 9 | export class StatsService { 10 | constructor( 11 | private readonly userService: UserService, 12 | private readonly courseService: CourseService, 13 | private readonly contentService: ContentService, 14 | ) {} 15 | async getStats(): Promise { 16 | const numberOfUsers = await this.userService.count(); 17 | const numberOfCourses = await this.courseService.count(); 18 | const numberOfContents = await this.contentService.count(); 19 | 20 | return { numberOfUsers, numberOfContents, numberOfCourses }; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/src/auth/guards/roles.guard.ts: -------------------------------------------------------------------------------- 1 | import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | 4 | import { ROLES_KEY } from '../../decorators/roles.decorator'; 5 | import { Role } from '../../enums/role.enum'; 6 | 7 | @Injectable() 8 | export class RolesGuard implements CanActivate { 9 | constructor(private reflector: Reflector) {} 10 | 11 | /* Check if roles array from the roles decorator includes the user's role */ 12 | 13 | canActivate(context: ExecutionContext): boolean { 14 | const requiredRoles = this.reflector.getAllAndOverride(ROLES_KEY, [ 15 | context.getHandler(), 16 | context.getClass(), 17 | ]); 18 | if (!requiredRoles) { 19 | return true; 20 | } 21 | const { user } = context.switchToHttp().getRequest(); 22 | return requiredRoles.some((role) => { 23 | return user.role === role; 24 | }); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Table.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | interface TableProps { 4 | columns: string[]; 5 | children: ReactNode; 6 | } 7 | 8 | export default function Table({ columns, children }: TableProps) { 9 | return ( 10 | 11 | 12 | 13 | {columns.map((column, index) => ( 14 | 21 | ))} 22 | 25 | 26 | 27 | {children} 28 |
19 | {column} 20 | 23 | Edit 24 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /frontend/src/services/ApiService.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import authService from './AuthService'; 4 | 5 | const axiosInstance = axios.create({ 6 | withCredentials: true, 7 | }); 8 | 9 | /* Auto refreshes the token if expired */ 10 | axiosInstance.interceptors.response.use( 11 | (response) => response, 12 | async function (error) { 13 | const originalRequest = error.config; 14 | if (error.response.status === 401 && !originalRequest._retry) { 15 | originalRequest._retry = true; 16 | 17 | try { 18 | const { token } = await authService.refresh(); 19 | console.log(token); 20 | error.response.config.headers.Authorization = `Bearer ${token}`; 21 | return axiosInstance(error.response.config); 22 | } catch (error) { 23 | //window.location.href = '/login'; 24 | return Promise.reject(error); 25 | } 26 | } 27 | return Promise.reject(error); 28 | }, 29 | ); 30 | 31 | export default axiosInstance; 32 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | services: 2 | database: 3 | image: postgres:alpine 4 | container_name: database 5 | environment: 6 | POSTGRES_DB: carna-database 7 | POSTGRES_USER: carna 8 | POSTGRES_PASSWORD: IjSJmKN1fc#DZBL*NHbVoxIh65JvsFFDO@so 9 | 10 | backend: 11 | container_name: backend 12 | build: 13 | context: backend 14 | dockerfile: Dockerfile 15 | depends_on: 16 | - database 17 | environment: 18 | DATABASE_HOST: database 19 | DATABASE_PORT: 5432 20 | DATABASE_USERNAME: carna 21 | DATABASE_PASSWORD: IjSJmKN1fc#DZBL*NHbVoxIh65JvsFFDO@so 22 | DATABASE_NAME: "carna-database" 23 | JWT_SECRET: 4125442A472D4B6150645367556B58703273357638792F423F4528482B4D6251 24 | JWT_REFRESH_SECRET: 576E5A7134743777217A25432A462D4A614E645267556B58703273357538782F 25 | 26 | frontend: 27 | container_name: frontend 28 | build: 29 | context: frontend 30 | dockerfile: Dockerfile 31 | ports: 32 | - "3000:80" 33 | depends_on: 34 | - backend 35 | -------------------------------------------------------------------------------- /backend/src/stats/stats.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { StatsService } from './stats.service'; 4 | 5 | const MockService = { 6 | getStats: jest.fn().mockImplementation(() => { 7 | return { 8 | numberOfUsers: 10, 9 | numberOfCourses: 5, 10 | numberOfContents: 6, 11 | }; 12 | }), 13 | }; 14 | 15 | describe('StatsService', () => { 16 | let service: StatsService; 17 | 18 | beforeEach(async () => { 19 | const module: TestingModule = await Test.createTestingModule({ 20 | providers: [ 21 | { 22 | provide: StatsService, 23 | useValue: MockService, 24 | }, 25 | ], 26 | }).compile(); 27 | 28 | service = module.get(StatsService); 29 | }); 30 | 31 | it('should be defined', () => { 32 | expect(service).toBeDefined(); 33 | }); 34 | 35 | describe('getStats', () => { 36 | it('should get stats', async () => { 37 | const stats = await service.getStats(); 38 | expect(stats.numberOfContents).toBe(6); 39 | expect(stats.numberOfCourses).toBe(5); 40 | expect(stats.numberOfUsers).toBe(10); 41 | }); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/services/CourseService.ts: -------------------------------------------------------------------------------- 1 | import Course from '../models/course/Course'; 2 | import CourseQuery from '../models/course/CourseQuery'; 3 | import CreateCourseRequest from '../models/course/CreateCourseRequest'; 4 | import UpdateCourseRequest from '../models/course/UpdateCourseRequest'; 5 | import apiService from './ApiService'; 6 | 7 | class UserService { 8 | async save(createCourseRequest: CreateCourseRequest): Promise { 9 | await apiService.post('/api/courses', createCourseRequest); 10 | } 11 | 12 | async findAll(courseQuery: CourseQuery): Promise { 13 | return ( 14 | await apiService.get('/api/courses', { params: courseQuery }) 15 | ).data; 16 | } 17 | 18 | async findOne(id: string): Promise { 19 | return (await apiService.get(`/api/courses/${id}`)).data; 20 | } 21 | 22 | async update( 23 | id: string, 24 | updateCourseRequest: UpdateCourseRequest, 25 | ): Promise { 26 | await apiService.put(`/api/courses/${id}`, updateCourseRequest); 27 | } 28 | 29 | async delete(id: string): Promise { 30 | await apiService.delete(`/api/courses/${id}`); 31 | } 32 | } 33 | 34 | export default new UserService(); 35 | -------------------------------------------------------------------------------- /frontend/src/services/AuthService.ts: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | import AuthResponse from '../models/auth/AuthResponse'; 4 | import LoginRequest from '../models/auth/LoginRequest'; 5 | import apiService from './ApiService'; 6 | 7 | class AuthService { 8 | async login(loginRequest: LoginRequest): Promise { 9 | const authResponse = ( 10 | await axios.post('/api/auth/login', loginRequest, { 11 | withCredentials: true, 12 | }) 13 | ).data; 14 | apiService.defaults.headers.Authorization = `Bearer ${authResponse.token}`; 15 | return authResponse; 16 | } 17 | 18 | async logout(): Promise { 19 | await apiService.post('/api/auth/logout', {}, { withCredentials: true }); 20 | apiService.defaults.headers.Authorization = null; 21 | } 22 | 23 | async refresh(): Promise { 24 | const authResponse = ( 25 | await axios.post( 26 | '/api/auth/refresh', 27 | {}, 28 | { withCredentials: true }, 29 | ) 30 | ).data; 31 | apiService.defaults.headers.Authorization = `Bearer ${authResponse.token}`; 32 | return authResponse; 33 | } 34 | } 35 | 36 | export default new AuthService(); 37 | -------------------------------------------------------------------------------- /backend/src/user/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsAlphanumeric, 3 | IsBoolean, 4 | IsEnum, 5 | IsNotEmpty, 6 | IsOptional, 7 | IsString, 8 | MinLength, 9 | } from 'class-validator'; 10 | 11 | import { Role } from '../enums/role.enum'; 12 | 13 | export class CreateUserDto { 14 | @IsNotEmpty() 15 | @IsString() 16 | firstName: string; 17 | 18 | @IsNotEmpty() 19 | @IsString() 20 | lastName: string; 21 | 22 | @IsNotEmpty() 23 | @IsAlphanumeric() 24 | username: string; 25 | 26 | @IsNotEmpty() 27 | @MinLength(6) 28 | @IsAlphanumeric() 29 | password: string; 30 | 31 | @IsEnum(Role) 32 | role: Role; 33 | } 34 | 35 | export class UpdateUserDto { 36 | @IsOptional() 37 | @IsNotEmpty() 38 | @IsString() 39 | firstName?: string; 40 | 41 | @IsOptional() 42 | @IsNotEmpty() 43 | @IsString() 44 | lastName?: string; 45 | 46 | @IsOptional() 47 | @IsNotEmpty() 48 | @IsAlphanumeric() 49 | username?: string; 50 | 51 | @IsOptional() 52 | @IsNotEmpty() 53 | @MinLength(6) 54 | @IsAlphanumeric() 55 | password?: string; 56 | 57 | @IsOptional() 58 | @IsEnum(Role) 59 | role?: Role; 60 | 61 | @IsOptional() 62 | @IsNotEmpty() 63 | @IsBoolean() 64 | isActive?: boolean; 65 | } 66 | -------------------------------------------------------------------------------- /backend/src/auth/guards/user.guard.spec.ts: -------------------------------------------------------------------------------- 1 | import { createMock } from '@golevelup/nestjs-testing'; 2 | import { ExecutionContext } from '@nestjs/common'; 3 | 4 | import { UserGuard } from './user.guard'; 5 | 6 | describe('UserGuard', () => { 7 | let guard: UserGuard; 8 | 9 | beforeEach(() => { 10 | guard = new UserGuard(); 11 | }); 12 | 13 | it('should be defined', () => { 14 | expect(guard).toBeDefined(); 15 | }); 16 | 17 | it('should return true when user is admin', async () => { 18 | const context = createMock(); 19 | 20 | context.switchToHttp().getRequest.mockReturnValue({ 21 | user: { 22 | role: 'admin', 23 | }, 24 | }); 25 | const result = await guard.canActivate(context); 26 | expect(result).toBe(true); 27 | }); 28 | 29 | it('should return true when user is equals to param id', async () => { 30 | const context = createMock(); 31 | 32 | context.switchToHttp().getRequest.mockReturnValue({ 33 | params: { 34 | id: 'testid', 35 | }, 36 | user: { 37 | userId: 'testid', 38 | }, 39 | }); 40 | const result = await guard.canActivate(context); 41 | expect(result).toBe(true); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Modal.tsx: -------------------------------------------------------------------------------- 1 | import { HTMLProps, ReactNode, useEffect, useState } from 'react'; 2 | import { createPortal } from 'react-dom'; 3 | 4 | interface ModalProps extends HTMLProps { 5 | className?: string; 6 | children?: ReactNode; 7 | show: boolean; 8 | } 9 | 10 | export default function Modal({ children, className, show }: ModalProps) { 11 | const [isVisible, setIsVisible] = useState(false); 12 | 13 | useEffect(() => { 14 | let timeout: NodeJS.Timeout; 15 | 16 | if (show) { 17 | setIsVisible(true); 18 | } else { 19 | timeout = setTimeout(() => { 20 | setIsVisible(false); 21 | }, 150); 22 | } 23 | 24 | return () => clearTimeout(timeout); 25 | }, [show]); 26 | 27 | return createPortal( 28 |
34 |
35 | {children} 36 |
37 |
, 38 | document.getElementById('modal'), 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | @layer base { 6 | html, 7 | body, 8 | #root { 9 | @apply h-full; 10 | } 11 | h1 { 12 | @apply text-2xl; 13 | } 14 | h2 { 15 | @apply text-xl; 16 | } 17 | h3 { 18 | @apply text-lg; 19 | } 20 | a { 21 | @apply text-blue-600 underline; 22 | } 23 | } 24 | 25 | @layer components { 26 | .btn { 27 | @apply p-3 bg-blue-500 rounded-md focus:outline-none text-white; 28 | @apply hover:bg-blue-700 transition-all; 29 | @apply focus:ring disabled:opacity-50; 30 | } 31 | .btn.danger { 32 | @apply bg-red-500 hover:bg-red-700; 33 | } 34 | .input { 35 | @apply rounded-md border-gray-300 transition-colors disabled:opacity-50; 36 | } 37 | .card { 38 | @apply p-5 rounded-md bg-white border; 39 | } 40 | .table-container { 41 | @apply border rounded-lg mt-5 shadow overflow-x-auto; 42 | } 43 | .table-filter { 44 | @apply flex flex-col lg:flex-row gap-5 rounded-lg p-3 shadow overflow-x-auto border; 45 | } 46 | .sidebar { 47 | @apply fixed h-full w-72 p-5 shadow flex flex-col transform -translate-x-72 bg-white lg:translate-x-0 transition-transform; 48 | } 49 | .sidebar.show { 50 | @apply translate-x-0 !important; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /backend/src/stats/stats.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { StatsController } from './stats.controller'; 4 | import { StatsService } from './stats.service'; 5 | 6 | const MockService = { 7 | getStats: jest.fn().mockImplementation(() => { 8 | return { 9 | numberOfUsers: 10, 10 | numberOfCourses: 5, 11 | numberOfContents: 6, 12 | }; 13 | }), 14 | }; 15 | 16 | describe('StatsController', () => { 17 | let controller: StatsController; 18 | 19 | beforeEach(async () => { 20 | const module: TestingModule = await Test.createTestingModule({ 21 | controllers: [StatsController], 22 | providers: [ 23 | { 24 | provide: StatsService, 25 | useValue: MockService, 26 | }, 27 | ], 28 | }).compile(); 29 | 30 | controller = module.get(StatsController); 31 | }); 32 | 33 | it('should be defined', () => { 34 | expect(controller).toBeDefined(); 35 | }); 36 | 37 | describe('getStats', () => { 38 | it('should get stats', async () => { 39 | const stats = await controller.getStats(); 40 | expect(stats.numberOfContents).toBe(6); 41 | expect(stats.numberOfCourses).toBe(5); 42 | expect(stats.numberOfUsers).toBe(10); 43 | }); 44 | }); 45 | }); 46 | -------------------------------------------------------------------------------- /frontend/src/services/ContentService.ts: -------------------------------------------------------------------------------- 1 | import Content from '../models/content/Content'; 2 | import ContentQuery from '../models/content/ContentQuery'; 3 | import CreateContentRequest from '../models/content/CreateContentRequest'; 4 | import UpdateContentRequest from '../models/content/UpdateContentRequest'; 5 | import apiService from './ApiService'; 6 | 7 | class ContentService { 8 | async findAll( 9 | courseId: string, 10 | contentQuery: ContentQuery, 11 | ): Promise { 12 | return ( 13 | await apiService.get(`/api/courses/${courseId}/contents`, { 14 | params: contentQuery, 15 | }) 16 | ).data; 17 | } 18 | 19 | async save( 20 | courseId: string, 21 | createContentRequest: CreateContentRequest, 22 | ): Promise { 23 | await apiService.post( 24 | `/api/courses/${courseId}/contents`, 25 | createContentRequest, 26 | ); 27 | } 28 | 29 | async update( 30 | courseId: string, 31 | id: string, 32 | updateContentRequest: UpdateContentRequest, 33 | ): Promise { 34 | await apiService.put( 35 | `/api/courses/${courseId}/contents/${id}`, 36 | updateContentRequest, 37 | ); 38 | } 39 | 40 | async delete(courseId: string, id: string): Promise { 41 | await apiService.delete(`/api/courses/${courseId}/contents/${id}`); 42 | } 43 | } 44 | 45 | export default new ContentService(); 46 | -------------------------------------------------------------------------------- /backend/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; 4 | import * as bcrypt from 'bcrypt'; 5 | import * as cookieParser from 'cookie-parser'; 6 | 7 | import { AppModule } from './app.module'; 8 | import { Role } from './enums/role.enum'; 9 | import { User } from './user/user.entity'; 10 | 11 | async function createAdminOnFirstUse() { 12 | const admin = await User.findOne({ where: { username: 'admin' } }); 13 | 14 | if (!admin) { 15 | await User.create({ 16 | firstName: 'admin', 17 | lastName: 'admin', 18 | isActive: true, 19 | username: 'admin', 20 | role: Role.Admin, 21 | password: await bcrypt.hash('admin123', 10), 22 | }).save(); 23 | } 24 | } 25 | 26 | async function bootstrap() { 27 | const app = await NestFactory.create(AppModule); 28 | app.setGlobalPrefix('api'); 29 | app.use(cookieParser()); 30 | app.useGlobalPipes(new ValidationPipe()); 31 | 32 | const config = new DocumentBuilder() 33 | .setTitle('Carna Project API') 34 | .setDescription('Carna Project API Documentation') 35 | .setVersion('1.0') 36 | .addBearerAuth() 37 | .build(); 38 | const document = SwaggerModule.createDocument(app, config); 39 | SwaggerModule.setup('/api/docs', app, document); 40 | 41 | await createAdminOnFirstUse(); 42 | 43 | await app.listen(5000); 44 | } 45 | bootstrap(); 46 | -------------------------------------------------------------------------------- /frontend/src/Route.tsx: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | import { Redirect, Route, RouteProps } from 'react-router'; 3 | 4 | import { AuthenticationContext } from './context/AuthenticationContext'; 5 | 6 | export { Route } from 'react-router'; 7 | 8 | interface PrivateRouteProps extends RouteProps { 9 | roles?: string[]; 10 | } 11 | 12 | export function PrivateRoute({ 13 | component: Component, 14 | roles, 15 | ...rest 16 | }: PrivateRouteProps) { 17 | const { authenticatedUser } = useContext(AuthenticationContext); 18 | 19 | return ( 20 | { 23 | if (authenticatedUser) { 24 | if (roles) { 25 | if (roles.includes(authenticatedUser.role)) { 26 | return ; 27 | } else { 28 | return ; 29 | } 30 | } else { 31 | return ; 32 | } 33 | } 34 | return ; 35 | }} 36 | /> 37 | ); 38 | } 39 | 40 | export function AuthRoute({ component: Component, ...rest }) { 41 | const { authenticatedUser } = useContext(AuthenticationContext); 42 | 43 | return ( 44 | { 47 | return authenticatedUser ? ( 48 | 49 | ) : ( 50 | 51 | ); 52 | }} 53 | /> 54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /frontend/src/services/UserService.ts: -------------------------------------------------------------------------------- 1 | import CreateUserRequest from '../models/user/CreateUserRequest'; 2 | import UpdateUserRequest from '../models/user/UpdateUserRequest'; 3 | import User from '../models/user/User'; 4 | import UserQuery from '../models/user/UserQuery'; 5 | import apiService from './ApiService'; 6 | 7 | class UserService { 8 | async save(createUserRequest: CreateUserRequest): Promise { 9 | await apiService.post('/api/users', createUserRequest); 10 | } 11 | 12 | async findAll(userQuery: UserQuery): Promise { 13 | return ( 14 | await apiService.get('/api/users', { 15 | params: userQuery, 16 | }) 17 | ).data; 18 | } 19 | 20 | async findOne(id: string): Promise { 21 | return (await apiService.get(`/api/users/${id}`)).data; 22 | } 23 | 24 | async update( 25 | id: string, 26 | updateUserRequest: UpdateUserRequest, 27 | ): Promise { 28 | const { 29 | firstName, 30 | isActive, 31 | lastName, 32 | password, 33 | role, 34 | username, 35 | } = updateUserRequest; 36 | await apiService.put(`/api/users/${id}`, { 37 | firstName: firstName || undefined, 38 | lastName: lastName || undefined, 39 | username: username || undefined, 40 | role: role || undefined, 41 | isActive, 42 | password: password || undefined, 43 | }); 44 | } 45 | 46 | async delete(id: string): Promise { 47 | await apiService.delete(`/api/users/${id}`); 48 | } 49 | } 50 | 51 | export default new UserService(); 52 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | HttpCode, 5 | HttpStatus, 6 | Post, 7 | Req, 8 | Res, 9 | UseGuards, 10 | } from '@nestjs/common'; 11 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 12 | import { Request, Response } from 'express'; 13 | 14 | import { LoginDto, LoginResponseDto } from './auth.dto'; 15 | import { AuthService } from './auth.service'; 16 | import { JwtGuard } from './guards/jwt.guard'; 17 | 18 | @Controller('auth') 19 | @ApiTags('Authentication') 20 | export class AuthController { 21 | constructor(private readonly authService: AuthService) {} 22 | 23 | @Post('/login') 24 | @HttpCode(HttpStatus.OK) 25 | async login( 26 | @Body() loginDto: LoginDto, 27 | @Res({ passthrough: true }) response: Response, 28 | ): Promise { 29 | return await this.authService.login(loginDto, response); 30 | } 31 | 32 | @UseGuards(JwtGuard) 33 | @Post('/logout') 34 | @ApiBearerAuth() 35 | @HttpCode(HttpStatus.OK) 36 | async logout( 37 | @Req() request: Request, 38 | @Res({ passthrough: true }) response: Response, 39 | ): Promise { 40 | return await this.authService.logout(request, response); 41 | } 42 | 43 | @Post('refresh') 44 | @HttpCode(HttpStatus.OK) 45 | async refresh( 46 | @Req() request: Request, 47 | @Res({ passthrough: true }) response: Response, 48 | ): Promise { 49 | const refresh = request.cookies['refresh-token']; 50 | 51 | return await this.authService.refresh(refresh, response); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { BrowserRouter as Router, Switch } from 'react-router-dom'; 3 | 4 | import useAuth from './hooks/useAuth'; 5 | import Contents from './pages/Contents'; 6 | import Courses from './pages/Courses'; 7 | import Dashboard from './pages/Dashboard'; 8 | import Login from './pages/Login'; 9 | import Users from './pages/Users'; 10 | import { AuthRoute, PrivateRoute } from './Route'; 11 | import authService from './services/AuthService'; 12 | 13 | export default function App() { 14 | const { authenticatedUser, setAuthenticatedUser } = useAuth(); 15 | const [isLoaded, setIsLoaded] = useState(false); 16 | 17 | const authenticate = async () => { 18 | try { 19 | const authResponse = await authService.refresh(); 20 | setAuthenticatedUser(authResponse.user); 21 | } catch (error) { 22 | console.log(error); 23 | } finally { 24 | setIsLoaded(true); 25 | } 26 | }; 27 | 28 | useEffect(() => { 29 | if (!authenticatedUser) { 30 | authenticate(); 31 | } else { 32 | setIsLoaded(true); 33 | } 34 | }, []); 35 | 36 | return isLoaded ? ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | ) : null; 48 | } 49 | -------------------------------------------------------------------------------- /frontend/src/components/layout/Sidebar.tsx: -------------------------------------------------------------------------------- 1 | import { BookOpen, Home, LogOut, Users } from 'react-feather'; 2 | import { useHistory } from 'react-router'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | import useAuth from '../../hooks/useAuth'; 6 | import authService from '../../services/AuthService'; 7 | import SidebarItem from './SidebarItem'; 8 | 9 | interface SidebarProps { 10 | className: string; 11 | } 12 | 13 | export default function Sidebar({ className }: SidebarProps) { 14 | const history = useHistory(); 15 | 16 | const { authenticatedUser, setAuthenticatedUser } = useAuth(); 17 | 18 | const handleLogout = async () => { 19 | await authService.logout(); 20 | setAuthenticatedUser(null); 21 | history.push('/login'); 22 | }; 23 | 24 | return ( 25 |
26 | 27 |

Carna Project

28 | 29 | 42 | 48 |
49 | ); 50 | } 51 | -------------------------------------------------------------------------------- /frontend/src/pages/Dashboard.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from 'react-query'; 2 | 3 | import UpdateProfile from '../components/dashboard/UpdateProfile'; 4 | import Layout from '../components/layout'; 5 | import statsService from '../services/StatsService'; 6 | 7 | export default function Dashboard() { 8 | const { data, isLoading } = useQuery('stats', statsService.getStats); 9 | 10 | return ( 11 | 12 |

Dashboard

13 |
14 |
15 | {!isLoading ? ( 16 |
17 |
18 |

19 | {data.numberOfUsers} 20 |

21 |

Users

22 |
23 |
24 |

25 | {data.numberOfCourses} 26 |

27 |

Courses

28 |
29 |
30 |

31 | {data.numberOfContents} 32 |

33 |

Contents

34 |
35 |
36 | ) : null} 37 | 38 | 39 |
40 |
41 | ); 42 | } 43 | -------------------------------------------------------------------------------- /backend/src/course/course.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { ILike } from 'typeorm'; 3 | 4 | import { CreateCourseDto, UpdateCourseDto } from './course.dto'; 5 | import { Course } from './course.entity'; 6 | import { CourseQuery } from './course.query'; 7 | 8 | @Injectable() 9 | export class CourseService { 10 | async save(createCourseDto: CreateCourseDto): Promise { 11 | return await Course.create({ 12 | ...createCourseDto, 13 | dateCreated: new Date(), 14 | }).save(); 15 | } 16 | 17 | async findAll(courseQuery: CourseQuery): Promise { 18 | Object.keys(courseQuery).forEach((key) => { 19 | courseQuery[key] = ILike(`%${courseQuery[key]}%`); 20 | }); 21 | return await Course.find({ 22 | where: courseQuery, 23 | order: { 24 | name: 'ASC', 25 | description: 'ASC', 26 | }, 27 | }); 28 | } 29 | 30 | async findById(id: string): Promise { 31 | const course = await Course.findOne(id); 32 | if (!course) { 33 | throw new HttpException( 34 | `Could not find course with matching id ${id}`, 35 | HttpStatus.NOT_FOUND, 36 | ); 37 | } 38 | return course; 39 | } 40 | 41 | async update(id: string, updateCourseDto: UpdateCourseDto): Promise { 42 | const course = await this.findById(id); 43 | return await Course.create({ id: course.id, ...updateCourseDto }).save(); 44 | } 45 | 46 | async delete(id: string): Promise { 47 | const course = await this.findById(id); 48 | await Course.delete(course); 49 | return id; 50 | } 51 | 52 | async count(): Promise { 53 | return await Course.count(); 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@craco/craco": "^6.1.2", 7 | "@tailwindcss/forms": "^0.3.2", 8 | "@testing-library/jest-dom": "^5.11.4", 9 | "@testing-library/react": "^11.1.0", 10 | "@testing-library/user-event": "^12.1.10", 11 | "@types/jest": "^26.0.15", 12 | "@types/node": "^12.0.0", 13 | "@types/react": "^17.0.0", 14 | "@types/react-dom": "^17.0.0", 15 | "axios": "^0.21.1", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "react-feather": "^2.0.9", 19 | "react-hook-form": "^7.3.5", 20 | "react-query": "^3.15.1", 21 | "react-router-dom": "^5.2.0", 22 | "react-scripts": "4.0.3", 23 | "typescript": "^4.1.2", 24 | "web-vitals": "^1.0.1" 25 | }, 26 | "scripts": { 27 | "start": "craco start", 28 | "build": "craco build", 29 | "test": "craco test", 30 | "eject": "react-scripts eject" 31 | }, 32 | "eslintConfig": { 33 | "extends": [ 34 | "react-app", 35 | "react-app/jest" 36 | ], 37 | "plugins": [ 38 | "simple-import-sort", 39 | "prettier" 40 | ], 41 | "rules": { 42 | "simple-import-sort/imports": "error", 43 | "simple-import-sort/exports": "error", 44 | "prettier/prettier": "error", 45 | "react-hooks/exhaustive-deps": "off" 46 | } 47 | }, 48 | "browserslist": { 49 | "production": [ 50 | ">0.2%", 51 | "not dead", 52 | "not op_mini all" 53 | ], 54 | "development": [ 55 | "last 1 chrome version", 56 | "last 1 firefox version", 57 | "last 1 safari version" 58 | ] 59 | }, 60 | "proxy": "http://localhost:5000", 61 | "devDependencies": { 62 | "@types/react-router-dom": "^5.1.7", 63 | "autoprefixer": "^9", 64 | "eslint-plugin-prettier": "^3.4.0", 65 | "eslint-plugin-simple-import-sort": "^7.0.0", 66 | "postcss": "^7", 67 | "prettier": "^2.2.1", 68 | "tailwindcss": "npm:@tailwindcss/postcss7-compat" 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | ClassSerializerInterceptor, 4 | Controller, 5 | Delete, 6 | Get, 7 | HttpCode, 8 | HttpStatus, 9 | Param, 10 | Post, 11 | Put, 12 | Query, 13 | UseGuards, 14 | UseInterceptors, 15 | } from '@nestjs/common'; 16 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 17 | 18 | import { JwtGuard } from '../auth/guards/jwt.guard'; 19 | import { RolesGuard } from '../auth/guards/roles.guard'; 20 | import { UserGuard } from '../auth/guards/user.guard'; 21 | import { Roles } from '../decorators/roles.decorator'; 22 | import { Role } from '../enums/role.enum'; 23 | import { CreateUserDto, UpdateUserDto } from './user.dto'; 24 | import { User } from './user.entity'; 25 | import { UserQuery } from './user.query'; 26 | import { UserService } from './user.service'; 27 | 28 | @Controller('users') 29 | @ApiTags('Users') 30 | @ApiBearerAuth() 31 | @UseGuards(JwtGuard, RolesGuard) 32 | @UseInterceptors(ClassSerializerInterceptor) 33 | export class UserController { 34 | constructor(private readonly userService: UserService) {} 35 | 36 | @Post() 37 | @HttpCode(HttpStatus.CREATED) 38 | @Roles(Role.Admin) 39 | async save(@Body() createUserDto: CreateUserDto): Promise { 40 | return await this.userService.save(createUserDto); 41 | } 42 | 43 | @Get() 44 | @Roles(Role.Admin) 45 | async findAll(@Query() userQuery: UserQuery): Promise { 46 | return await this.userService.findAll(userQuery); 47 | } 48 | 49 | @Get('/:id') 50 | @UseGuards(UserGuard) 51 | async findOne(@Param('id') id: string): Promise { 52 | return await this.userService.findById(id); 53 | } 54 | 55 | @Put('/:id') 56 | @UseGuards(UserGuard) 57 | async update( 58 | @Param('id') id: string, 59 | @Body() updateUserDto: UpdateUserDto, 60 | ): Promise { 61 | return await this.userService.update(id, updateUserDto); 62 | } 63 | 64 | @Delete('/:id') 65 | @Roles(Role.Admin) 66 | async delete(@Param('id') id: string): Promise { 67 | return await this.userService.delete(id); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as mocks from 'node-mocks-http'; 3 | 4 | import { LoginDto } from './auth.dto'; 5 | import { AuthService } from './auth.service'; 6 | 7 | const MockService = { 8 | login: jest.fn().mockImplementation((loginDto: LoginDto) => { 9 | return { 10 | token: 'token', 11 | user: { 12 | username: loginDto.username, 13 | }, 14 | }; 15 | }), 16 | logout: jest.fn().mockReturnValue(true), 17 | refresh: jest.fn().mockImplementation(() => { 18 | return { 19 | token: 'token', 20 | user: { 21 | username: 'test', 22 | }, 23 | }; 24 | }), 25 | }; 26 | 27 | describe('AuthService', () => { 28 | let service: AuthService; 29 | 30 | beforeEach(async () => { 31 | const module: TestingModule = await Test.createTestingModule({ 32 | providers: [ 33 | { 34 | provide: AuthService, 35 | useValue: MockService, 36 | }, 37 | ], 38 | }).compile(); 39 | 40 | service = module.get(AuthService); 41 | }); 42 | 43 | it('should be defined', () => { 44 | expect(service).toBeDefined(); 45 | }); 46 | 47 | describe('login', () => { 48 | it('should get login response', async () => { 49 | const req = mocks.createRequest(); 50 | req.res = mocks.createResponse(); 51 | 52 | const loginResponse = await service.login( 53 | { username: 'test', password: 'test' }, 54 | req.res, 55 | ); 56 | expect(loginResponse).toEqual({ 57 | token: 'token', 58 | user: { 59 | username: 'test', 60 | }, 61 | }); 62 | }); 63 | }); 64 | 65 | describe('logout', () => { 66 | it('should get true', async () => { 67 | const req = mocks.createRequest(); 68 | req.res = mocks.createResponse(); 69 | const result = await service.logout(req, req.res); 70 | 71 | expect(result).toBe(true); 72 | }); 73 | }); 74 | 75 | describe('refresh', () => { 76 | it('should get login response', async () => { 77 | const req = mocks.createRequest(); 78 | 79 | const loginResponse = await service.refresh('token', req.res); 80 | expect(loginResponse).toEqual({ 81 | token: 'token', 82 | user: { 83 | username: 'test', 84 | }, 85 | }); 86 | }); 87 | }); 88 | }); 89 | -------------------------------------------------------------------------------- /frontend/src/pages/Login.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Loader } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useHistory } from 'react-router-dom'; 5 | 6 | import useAuth from '../hooks/useAuth'; 7 | import LoginRequest from '../models/auth/LoginRequest'; 8 | import authService from '../services/AuthService'; 9 | 10 | export default function Login() { 11 | const { setAuthenticatedUser } = useAuth(); 12 | const history = useHistory(); 13 | 14 | const [error, setError] = useState(); 15 | 16 | const { 17 | register, 18 | handleSubmit, 19 | formState: { isSubmitting }, 20 | } = useForm(); 21 | 22 | const onSubmit = async (loginRequest: LoginRequest) => { 23 | try { 24 | const data = await authService.login(loginRequest); 25 | setAuthenticatedUser(data.user); 26 | history.push('/'); 27 | } catch (error) { 28 | setError(error.response.data.message); 29 | } 30 | }; 31 | 32 | return ( 33 |
34 |
35 |

Login

36 |
37 |
41 | 49 | 57 | 68 | {error ? ( 69 |
70 | {error} 71 |
72 | ) : null} 73 |
74 |
75 |
76 | ); 77 | } 78 | -------------------------------------------------------------------------------- /backend/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as mocks from 'node-mocks-http'; 3 | 4 | import { AuthController } from './auth.controller'; 5 | import { LoginDto } from './auth.dto'; 6 | import { AuthService } from './auth.service'; 7 | 8 | const MockService = { 9 | login: jest.fn().mockImplementation((loginDto: LoginDto) => { 10 | return { 11 | token: 'token', 12 | user: { 13 | username: loginDto.username, 14 | }, 15 | }; 16 | }), 17 | logout: jest.fn().mockReturnValue(true), 18 | refresh: jest.fn().mockImplementation(() => { 19 | return { 20 | token: 'token', 21 | user: { 22 | username: 'test', 23 | }, 24 | }; 25 | }), 26 | }; 27 | 28 | describe('AuthController', () => { 29 | let controller: AuthController; 30 | 31 | beforeEach(async () => { 32 | const module: TestingModule = await Test.createTestingModule({ 33 | controllers: [AuthController], 34 | providers: [ 35 | { 36 | provide: AuthService, 37 | useValue: MockService, 38 | }, 39 | ], 40 | }).compile(); 41 | 42 | controller = module.get(AuthController); 43 | }); 44 | 45 | it('should be defined', () => { 46 | expect(controller).toBeDefined(); 47 | }); 48 | 49 | describe('login', () => { 50 | it('should get login response', async () => { 51 | const req = mocks.createRequest(); 52 | req.res = mocks.createResponse(); 53 | 54 | const loginResponse = await controller.login( 55 | { username: 'test', password: 'test' }, 56 | req.res, 57 | ); 58 | expect(loginResponse).toEqual({ 59 | token: 'token', 60 | user: { 61 | username: 'test', 62 | }, 63 | }); 64 | }); 65 | }); 66 | 67 | describe('logout', () => { 68 | it('should get true', async () => { 69 | const req = mocks.createRequest(); 70 | req.res = mocks.createResponse(); 71 | const result = await controller.logout(req, req.res); 72 | 73 | expect(result).toBe(true); 74 | }); 75 | }); 76 | 77 | describe('refresh', () => { 78 | it('should get login response', async () => { 79 | const req = mocks.createRequest(); 80 | 81 | const loginResponse = await controller.refresh(req, req.res); 82 | expect(loginResponse).toEqual({ 83 | token: 'token', 84 | user: { 85 | username: 'test', 86 | }, 87 | }); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 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": "newman run e2e/app.e2e.test.json" 22 | }, 23 | "dependencies": { 24 | "@nestjs/common": "^7.5.1", 25 | "@nestjs/config": "^0.6.3", 26 | "@nestjs/core": "^7.5.1", 27 | "@nestjs/jwt": "^7.2.0", 28 | "@nestjs/mapped-types": "*", 29 | "@nestjs/passport": "^7.1.5", 30 | "@nestjs/platform-express": "^7.5.1", 31 | "@nestjs/swagger": "^4.8.0", 32 | "@nestjs/typeorm": "^7.1.5", 33 | "bcrypt": "^5.0.1", 34 | "class-transformer": "^0.4.0", 35 | "class-validator": "^0.13.1", 36 | "cookie-parser": "^1.4.5", 37 | "passport": "^0.4.1", 38 | "passport-jwt": "^4.0.0", 39 | "pg": "^8.6.0", 40 | "reflect-metadata": "^0.1.13", 41 | "rimraf": "^3.0.2", 42 | "rxjs": "^6.6.3", 43 | "swagger-ui-express": "^4.1.6", 44 | "typeorm": "^0.2.32" 45 | }, 46 | "devDependencies": { 47 | "@golevelup/nestjs-testing": "^0.1.2", 48 | "@nestjs/cli": "^7.5.1", 49 | "@nestjs/schematics": "^7.1.3", 50 | "@nestjs/testing": "^7.5.1", 51 | "@types/bcrypt": "^3.0.1", 52 | "@types/cookie-parser": "^1.4.2", 53 | "@types/express": "^4.17.8", 54 | "@types/jest": "^26.0.15", 55 | "@types/node": "^14.14.6", 56 | "@types/passport-jwt": "^3.0.5", 57 | "@types/passport-local": "^1.0.33", 58 | "@types/supertest": "^2.0.10", 59 | "@typescript-eslint/eslint-plugin": "^4.6.1", 60 | "@typescript-eslint/parser": "^4.6.1", 61 | "eslint": "^7.12.1", 62 | "eslint-config-prettier": "7.2.0", 63 | "eslint-plugin-prettier": "^3.1.4", 64 | "eslint-plugin-simple-import-sort": "^7.0.0", 65 | "jest": "^26.6.3", 66 | "newman": "^5.2.3", 67 | "node-mocks-http": "^1.10.1", 68 | "prettier": "^2.1.2", 69 | "supertest": "^6.0.0", 70 | "ts-jest": "^26.4.3", 71 | "ts-loader": "^8.0.8", 72 | "ts-node": "^9.0.0", 73 | "tsconfig-paths": "^3.9.0", 74 | "typescript": "^4.0.5" 75 | }, 76 | "jest": { 77 | "moduleFileExtensions": [ 78 | "js", 79 | "json", 80 | "ts" 81 | ], 82 | "rootDir": "src", 83 | "testRegex": ".*\\.spec\\.ts$", 84 | "transform": { 85 | "^.+\\.(t|j)s$": "ts-jest" 86 | }, 87 | "collectCoverageFrom": [ 88 | "**/*.(t|j)s" 89 | ], 90 | "coverageDirectory": "../coverage", 91 | "testEnvironment": "node" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /backend/src/content/content.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import { ILike } from 'typeorm'; 3 | 4 | import { CourseService } from '../course/course.service'; 5 | import { CreateContentDto, UpdateContentDto } from './content.dto'; 6 | import { Content } from './content.entity'; 7 | import { ContentQuery } from './content.query'; 8 | 9 | @Injectable() 10 | export class ContentService { 11 | constructor(private readonly courseService: CourseService) {} 12 | 13 | async save( 14 | courseId: string, 15 | createContentDto: CreateContentDto, 16 | ): Promise { 17 | const { name, description } = createContentDto; 18 | const course = await this.courseService.findById(courseId); 19 | return await Content.create({ 20 | name, 21 | description, 22 | course, 23 | dateCreated: new Date(), 24 | }).save(); 25 | } 26 | 27 | async findAll(contentQuery: ContentQuery): Promise { 28 | Object.keys(contentQuery).forEach((key) => { 29 | contentQuery[key] = ILike(`%${contentQuery[key]}%`); 30 | }); 31 | 32 | return await Content.find({ 33 | where: contentQuery, 34 | order: { 35 | name: 'ASC', 36 | description: 'ASC', 37 | }, 38 | }); 39 | } 40 | 41 | async findById(id: string): Promise { 42 | const content = await Content.findOne(id); 43 | 44 | if (!content) { 45 | throw new HttpException( 46 | `Could not find content with matching id ${id}`, 47 | HttpStatus.NOT_FOUND, 48 | ); 49 | } 50 | 51 | return content; 52 | } 53 | 54 | async findByCourseIdAndId(courseId: string, id: string): Promise { 55 | const content = await Content.findOne({ where: { courseId, id } }); 56 | if (!content) { 57 | throw new HttpException( 58 | `Could not find content with matching id ${id}`, 59 | HttpStatus.NOT_FOUND, 60 | ); 61 | } 62 | return content; 63 | } 64 | 65 | async findAllByCourseId( 66 | courseId: string, 67 | contentQuery: ContentQuery, 68 | ): Promise { 69 | Object.keys(contentQuery).forEach((key) => { 70 | contentQuery[key] = ILike(`%${contentQuery[key]}%`); 71 | }); 72 | return await Content.find({ 73 | where: { courseId, ...contentQuery }, 74 | order: { 75 | name: 'ASC', 76 | description: 'ASC', 77 | }, 78 | }); 79 | } 80 | 81 | async update( 82 | courseId: string, 83 | id: string, 84 | updateContentDto: UpdateContentDto, 85 | ): Promise { 86 | const content = await this.findByCourseIdAndId(courseId, id); 87 | return await Content.create({ id: content.id, ...updateContentDto }).save(); 88 | } 89 | 90 | async delete(courseId: string, id: string): Promise { 91 | const content = await this.findByCourseIdAndId(courseId, id); 92 | await Content.delete(content); 93 | return id; 94 | } 95 | 96 | async count(): Promise { 97 | return await Content.count(); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/src/user/user.service.ts: -------------------------------------------------------------------------------- 1 | import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | import * as bcrypt from 'bcrypt'; 3 | import { ILike } from 'typeorm'; 4 | 5 | import { CreateUserDto, UpdateUserDto } from './user.dto'; 6 | import { User } from './user.entity'; 7 | import { UserQuery } from './user.query'; 8 | 9 | @Injectable() 10 | export class UserService { 11 | async save(createUserDto: CreateUserDto): Promise { 12 | const user = await this.findByUsername(createUserDto.username); 13 | 14 | if (user) { 15 | throw new HttpException( 16 | `User with username ${createUserDto.username} is already exists`, 17 | HttpStatus.BAD_REQUEST, 18 | ); 19 | } 20 | 21 | const { password } = createUserDto; 22 | createUserDto.password = await bcrypt.hash(password, 10); 23 | return User.create(createUserDto).save(); 24 | } 25 | 26 | async findAll(userQuery: UserQuery): Promise { 27 | Object.keys(userQuery).forEach((key) => { 28 | if (key !== 'role') { 29 | userQuery[key] = ILike(`%${userQuery[key]}%`); 30 | } 31 | }); 32 | 33 | return User.find({ 34 | where: userQuery, 35 | order: { 36 | firstName: 'ASC', 37 | lastName: 'ASC', 38 | }, 39 | }); 40 | } 41 | 42 | async findById(id: string): Promise { 43 | const user = await User.findOne(id); 44 | 45 | if (!user) { 46 | throw new HttpException( 47 | `Could not find user with matching id ${id}`, 48 | HttpStatus.NOT_FOUND, 49 | ); 50 | } 51 | 52 | return user; 53 | } 54 | 55 | async findByUsername(username: string): Promise { 56 | return User.findOne({ where: { username } }); 57 | } 58 | 59 | async update(id: string, updateUserDto: UpdateUserDto): Promise { 60 | const currentUser = await this.findById(id); 61 | 62 | /* If username is same as before, delete it from the dto */ 63 | if (currentUser.username === updateUserDto.username) { 64 | delete updateUserDto.username; 65 | } 66 | 67 | if (updateUserDto.password) { 68 | updateUserDto.password = await bcrypt.hash(updateUserDto.password, 10); 69 | } 70 | 71 | if (updateUserDto.username) { 72 | if (await this.findByUsername(updateUserDto.username)) { 73 | throw new HttpException( 74 | `User with username ${updateUserDto.username} is already exists`, 75 | HttpStatus.BAD_REQUEST, 76 | ); 77 | } 78 | } 79 | 80 | return User.create({ id, ...updateUserDto }).save(); 81 | } 82 | 83 | async delete(id: string): Promise { 84 | await User.delete(await this.findById(id)); 85 | return id; 86 | } 87 | 88 | async count(): Promise { 89 | return await User.count(); 90 | } 91 | 92 | /* Hash the refresh token and save it to the database */ 93 | async setRefreshToken(id: string, refreshToken: string): Promise { 94 | const user = await this.findById(id); 95 | await User.update(user, { 96 | refreshToken: refreshToken ? await bcrypt.hash(refreshToken, 10) : null, 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /backend/src/course/course.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Body, 3 | Controller, 4 | Delete, 5 | Get, 6 | Param, 7 | Post, 8 | Put, 9 | Query, 10 | UseGuards, 11 | } from '@nestjs/common'; 12 | import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; 13 | 14 | import { JwtGuard } from '../auth/guards/jwt.guard'; 15 | import { RolesGuard } from '../auth/guards/roles.guard'; 16 | import { CreateContentDto, UpdateContentDto } from '../content/content.dto'; 17 | import { Content } from '../content/content.entity'; 18 | import { ContentQuery } from '../content/content.query'; 19 | import { ContentService } from '../content/content.service'; 20 | import { Roles } from '../decorators/roles.decorator'; 21 | import { Role } from '../enums/role.enum'; 22 | import { CreateCourseDto, UpdateCourseDto } from './course.dto'; 23 | import { Course } from './course.entity'; 24 | import { CourseQuery } from './course.query'; 25 | import { CourseService } from './course.service'; 26 | 27 | @Controller('courses') 28 | @ApiBearerAuth() 29 | @UseGuards(JwtGuard, RolesGuard) 30 | @ApiTags('Courses') 31 | export class CourseController { 32 | constructor( 33 | private readonly courseService: CourseService, 34 | private readonly contentService: ContentService, 35 | ) {} 36 | 37 | @Post() 38 | @Roles(Role.Admin, Role.Editor) 39 | async save(@Body() createCourseDto: CreateCourseDto): Promise { 40 | return await this.courseService.save(createCourseDto); 41 | } 42 | 43 | @Get() 44 | async findAll(@Query() courseQuery: CourseQuery): Promise { 45 | return await this.courseService.findAll(courseQuery); 46 | } 47 | 48 | @Get('/:id') 49 | async findOne(@Param('id') id: string): Promise { 50 | return await this.courseService.findById(id); 51 | } 52 | 53 | @Put('/:id') 54 | @Roles(Role.Admin, Role.Editor) 55 | async update( 56 | @Param('id') id: string, 57 | @Body() updateCourseDto: UpdateCourseDto, 58 | ): Promise { 59 | return await this.courseService.update(id, updateCourseDto); 60 | } 61 | 62 | @Delete('/:id') 63 | @Roles(Role.Admin) 64 | async delete(@Param('id') id: string): Promise { 65 | return await this.courseService.delete(id); 66 | } 67 | 68 | @Post('/:id/contents') 69 | @Roles(Role.Admin, Role.Editor) 70 | async saveContent( 71 | @Param('id') id: string, 72 | @Body() createContentDto: CreateContentDto, 73 | ): Promise { 74 | return await this.contentService.save(id, createContentDto); 75 | } 76 | 77 | @Get('/:id/contents') 78 | async findAllContentsByCourseId( 79 | @Param('id') id: string, 80 | @Query() contentQuery: ContentQuery, 81 | ): Promise { 82 | return await this.contentService.findAllByCourseId(id, contentQuery); 83 | } 84 | 85 | @Put('/:id/contents/:contentId') 86 | @Roles(Role.Admin, Role.Editor) 87 | async updateContent( 88 | @Param('id') id: string, 89 | @Param('contentId') contentId: string, 90 | @Body() updateContentDto: UpdateContentDto, 91 | ): Promise { 92 | return await this.contentService.update(id, contentId, updateContentDto); 93 | } 94 | 95 | @Delete('/:id/contents/:contentId') 96 | @Roles(Role.Admin) 97 | async deleteContent( 98 | @Param('id') id: string, 99 | @Param('contentId') contentId: string, 100 | ): Promise { 101 | return await this.contentService.delete(id, contentId); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /backend/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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Admin Panel Project 2 | 3 | # Assumptions 4 | 5 | - User can have only 1 role. 6 | - 3 Roles: Admin, Editor, User (Authorizations of roles are described down below) 7 | - There are 3 data types. Users, Courses and Contents. 8 | - Courses can have multiple contents. 9 | 10 | **Admin** 11 | 12 | | Table | Read | Write | Update | Delete | 13 | | -------- | ---- | ----- | ------ | ------ | 14 | | Users | ✓ | ✓ | ✓ | ✓ | 15 | | Courses | ✓ | ✓ | ✓ | ✓ | 16 | | Contents | ✓ | ✓ | ✓ | ✓ | 17 | 18 | **Editor** 19 | 20 | | Table | Read | Write | Update | Delete | 21 | | -------- | ------ | ----- | ------ | ------ | 22 | | Users | itself | | itself | | 23 | | Courses | ✓ | ✓ | ✓ | | 24 | | Contents | ✓ | ✓ | ✓ | | 25 | 26 | **User** 27 | 28 | | Table | Read | Write | Update | Delete | 29 | | -------- | ------ | ----- | ------ | ------ | 30 | | Users | itself | | itself | | 31 | | Courses | ✓ | | | | 32 | | Contents | ✓ | | | | 33 | 34 | # Tech Stack 35 | 36 | 1. **Backend**: NestJS 37 | 2. **Frontend**: React 38 | 3. **Database**: PostgreSQL 39 | 4. **Testing**: Jest for unit testing. Postman for e2e testing. 40 | 41 | # Features 42 | 43 | - Swagger Documentation 44 | - JWT authentication with refresh & access token 45 | - Role based authorization 46 | - Data filtering 47 | - Fully responsive design 48 | 49 | # Authentication 50 | 51 | Application generates 2 tokens on login. Access token and refresh token. Access token has a lifetime of 15 minutes and the refresh token has a lifetime of 1 year. 52 | 53 | # First Login 54 | 55 | On the first run, application inserts a new admin to the database. 56 | 57 | - **username**: admin 58 | - **password**: admin123 59 | 60 | # How to setup 61 | 62 | ## **Deploy with Docker** 63 | 64 | You can run the entire app using docker compose. 65 | 66 | On root directory 67 | 68 | ```bash 69 | docker-compose up -d 70 | ``` 71 | 72 | Application will be deployed on http://localhost:3000 73 | 74 | Swagger Docs on http://localhost:3000/api/docs 75 | 76 | ## **Running locally** 77 | 78 | ## Backend 79 | 80 | First you have to postgresql installed on your computer. 81 | 82 | Edit the database properties on the backend/.env file. 83 | 84 | On backend directory 85 | 86 | ### Installing the dependencies 87 | 88 | ```bash 89 | yarn 90 | ``` 91 | 92 | ### Running the app 93 | 94 | ```bash 95 | $ yarn start 96 | ``` 97 | 98 | Backend will be started on http://localhost:5000 99 | 100 | Swagger Docs on http://localhost:5000/api/docs 101 | 102 | ## Frontend 103 | 104 | On frontend directory 105 | 106 | ### Installing the dependencies 107 | 108 | ```bash 109 | yarn 110 | ``` 111 | 112 | ### Running the app 113 | 114 | ```bash 115 | $ yarn start 116 | ``` 117 | 118 | Frontend will be started on http://localhost:3000 119 | 120 | # Testing 121 | 122 | **Unit testing** 123 | 124 | On backend directory 125 | 126 | ```bash 127 | yarn test 128 | ``` 129 | 130 | **e2e api testing** 131 | 132 | First start the backend locally. 133 | 134 | On backend directory 135 | 136 | Install the dependencies 137 | 138 | ```bash 139 | yarn 140 | ``` 141 | 142 | Start the backend locally. 143 | 144 | ```bash 145 | yarn start 146 | ``` 147 | 148 | Start the test 149 | 150 | Test will login as **username:** admin, **password:** admin123 and create users with usernames test and test2. If you change username and password of admin or if you add users with username test and test2. Tests will fail. 151 | 152 | ```bash 153 | yarn test:e2e 154 | ``` 155 | -------------------------------------------------------------------------------- /backend/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ForbiddenException, 3 | HttpException, 4 | HttpStatus, 5 | Injectable, 6 | UnauthorizedException, 7 | } from '@nestjs/common'; 8 | import { JwtService } from '@nestjs/jwt'; 9 | import * as bcrypt from 'bcrypt'; 10 | import { Request, Response } from 'express'; 11 | 12 | import { UserService } from '../user/user.service'; 13 | import { LoginDto, LoginResponseDto } from './auth.dto'; 14 | 15 | @Injectable() 16 | export class AuthService { 17 | private readonly SECRET = process.env.JWT_SECRET; 18 | private readonly REFRESH_SECRET = process.env.JWT_REFRESH_SECRET; 19 | 20 | constructor( 21 | private readonly userService: UserService, 22 | private readonly jwtService: JwtService, 23 | ) {} 24 | 25 | async login( 26 | loginDto: LoginDto, 27 | response: Response, 28 | ): Promise { 29 | const { username, password } = loginDto; 30 | const user = await this.userService.findByUsername(username); 31 | 32 | if (!user || !(await bcrypt.compare(password, user.password))) { 33 | throw new HttpException( 34 | 'Invalid username or password', 35 | HttpStatus.UNAUTHORIZED, 36 | ); 37 | } 38 | 39 | if (!user.isActive) { 40 | throw new HttpException('Account is disabled', HttpStatus.UNAUTHORIZED); 41 | } 42 | 43 | const { id, firstName, lastName, role } = user; 44 | 45 | const accessToken = await this.jwtService.signAsync( 46 | { username, firstName, lastName, role }, 47 | { subject: id, expiresIn: '15m', secret: this.SECRET }, 48 | ); 49 | 50 | /* Generates a refresh token and stores it in a httponly cookie */ 51 | const refreshToken = await this.jwtService.signAsync( 52 | { username, firstName, lastName, role }, 53 | { subject: id, expiresIn: '1y', secret: this.REFRESH_SECRET }, 54 | ); 55 | 56 | await this.userService.setRefreshToken(id, refreshToken); 57 | 58 | response.cookie('refresh-token', refreshToken, { httpOnly: true }); 59 | 60 | return { token: accessToken, user }; 61 | } 62 | 63 | /* Because JWT is a stateless authentication, this function removes the refresh token from the cookies and the database */ 64 | async logout(request: Request, response: Response): Promise { 65 | const userId = request.user['userId']; 66 | await this.userService.setRefreshToken(userId, null); 67 | response.clearCookie('refresh-token'); 68 | return true; 69 | } 70 | 71 | async refresh( 72 | refreshToken: string, 73 | response: Response, 74 | ): Promise { 75 | if (!refreshToken) { 76 | throw new HttpException('Refresh token required', HttpStatus.BAD_REQUEST); 77 | } 78 | 79 | const decoded = this.jwtService.decode(refreshToken); 80 | const user = await this.userService.findById(decoded['sub']); 81 | const { firstName, lastName, username, id, role } = user; 82 | 83 | if (!(await bcrypt.compare(refreshToken, user.refreshToken))) { 84 | response.clearCookie('refresh-token'); 85 | throw new HttpException( 86 | 'Refresh token is not valid', 87 | HttpStatus.FORBIDDEN, 88 | ); 89 | } 90 | 91 | try { 92 | await this.jwtService.verifyAsync(refreshToken, { 93 | secret: this.REFRESH_SECRET, 94 | }); 95 | const accessToken = await this.jwtService.signAsync( 96 | { username, firstName, lastName, role }, 97 | { subject: id, expiresIn: '15m', secret: this.SECRET }, 98 | ); 99 | 100 | return { token: accessToken, user }; 101 | } catch (error) { 102 | response.clearCookie('refresh-token'); 103 | await this.userService.setRefreshToken(id, null); 104 | throw new HttpException( 105 | 'Refresh token is not valid', 106 | HttpStatus.FORBIDDEN, 107 | ); 108 | } 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /frontend/src/components/dashboard/UpdateProfile.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Loader } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useQuery } from 'react-query'; 5 | 6 | import useAuth from '../../hooks/useAuth'; 7 | import UpdateUserRequest from '../../models/user/UpdateUserRequest'; 8 | import userService from '../../services/UserService'; 9 | 10 | export default function UpdateProfile() { 11 | const { authenticatedUser } = useAuth(); 12 | const [error, setError] = useState(); 13 | 14 | const { data, isLoading, refetch } = useQuery( 15 | `user-${authenticatedUser.id}`, 16 | () => userService.findOne(authenticatedUser.id), 17 | ); 18 | 19 | const { 20 | register, 21 | handleSubmit, 22 | formState: { isSubmitting }, 23 | setValue, 24 | } = useForm(); 25 | 26 | const handleUpdateUser = async (updateUserRequest: UpdateUserRequest) => { 27 | try { 28 | if (updateUserRequest.username === data.username) { 29 | delete updateUserRequest.username; 30 | } 31 | await userService.update(authenticatedUser.id, updateUserRequest); 32 | setError(null); 33 | setValue('password', ''); 34 | refetch(); 35 | } catch (error) { 36 | setError(error.response.data.message); 37 | } 38 | }; 39 | 40 | if (!isLoading) { 41 | return ( 42 |
43 |
47 |

{`Welcome ${data.firstName}`}

48 |
49 |
50 |
51 | 52 | 60 |
61 |
62 | 63 | 71 |
72 |
73 |
74 | 75 | 83 |
84 |
85 | 86 | 93 |
94 | 101 | {error ? ( 102 |
103 | {error} 104 |
105 | ) : null} 106 |
107 |
108 | ); 109 | } 110 | 111 | return null; 112 | } 113 | -------------------------------------------------------------------------------- /backend/src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { Role } from '../enums/role.enum'; 4 | import { UserController } from './user.controller'; 5 | import { CreateUserDto, UpdateUserDto } from './user.dto'; 6 | import { UserService } from './user.service'; 7 | 8 | const MockService = { 9 | findAll: jest.fn().mockResolvedValue([ 10 | { 11 | id: 'test1', 12 | firstName: 'test1', 13 | lastName: 'test1', 14 | username: 'test1', 15 | isActive: true, 16 | role: Role.Admin, 17 | }, 18 | { 19 | id: 'test2', 20 | firstName: 'test2', 21 | lastName: 'test2', 22 | username: 'test2', 23 | isActive: true, 24 | role: Role.Admin, 25 | }, 26 | { 27 | id: 'test3', 28 | firstName: 'test3', 29 | lastName: 'test3', 30 | username: 'test3', 31 | isActive: true, 32 | role: Role.Admin, 33 | }, 34 | ]), 35 | save: jest.fn().mockImplementation((createUserDto: CreateUserDto) => { 36 | return { 37 | id: 'testid', 38 | ...createUserDto, 39 | }; 40 | }), 41 | findById: jest.fn().mockImplementation((id) => { 42 | return { 43 | id, 44 | firstName: 'test', 45 | lastName: 'test', 46 | password: 'test', 47 | role: Role.User, 48 | isActive: true, 49 | username: 'test', 50 | }; 51 | }), 52 | update: jest 53 | .fn() 54 | .mockImplementation((id: string, updateUserDto: UpdateUserDto) => { 55 | return { 56 | id, 57 | ...updateUserDto, 58 | }; 59 | }), 60 | delete: jest.fn().mockImplementation((id: string) => id), 61 | }; 62 | 63 | describe('UserController', () => { 64 | let controller: UserController; 65 | 66 | beforeEach(async () => { 67 | const module: TestingModule = await Test.createTestingModule({ 68 | controllers: [UserController], 69 | providers: [ 70 | { 71 | provide: UserService, 72 | useValue: MockService, 73 | }, 74 | ], 75 | }).compile(); 76 | 77 | controller = module.get(UserController); 78 | }); 79 | 80 | it('should be defined', () => { 81 | expect(controller).toBeDefined(); 82 | }); 83 | 84 | describe('saveUser', () => { 85 | it('should get the same user that is created', async () => { 86 | const returnValue = await controller.save({ 87 | firstName: 'test', 88 | lastName: 'test', 89 | password: 'test', 90 | role: Role.User, 91 | username: 'test', 92 | }); 93 | expect(returnValue.id).toBe('testid'); 94 | expect(returnValue.firstName).toBe('test'); 95 | expect(returnValue.role).toBe('user'); 96 | }); 97 | }); 98 | 99 | describe('findAllUsers', () => { 100 | it('should get the list of users', async () => { 101 | const users = await controller.findAll({}); 102 | expect(typeof users).toBe('object'); 103 | expect(users[0].firstName).toBe('test1'); 104 | expect(users[1].lastName).toBe('test2'); 105 | expect(users[2].username).toBe('test3'); 106 | expect(users.length).toBe(3); 107 | }); 108 | }); 109 | 110 | describe('findOneUser', () => { 111 | it('should get a user matching id', async () => { 112 | const user = await controller.findOne('id'); 113 | expect(user.id).toBe('id'); 114 | expect(user.firstName).toBe('test'); 115 | }); 116 | }); 117 | 118 | describe('updateUser', () => { 119 | it('should update a user and return changed values', async () => { 120 | const updatedUser = await controller.update('testid', { 121 | firstName: 'test', 122 | role: Role.Editor, 123 | }); 124 | expect(updatedUser.id).toBe('testid'); 125 | expect(updatedUser.role).toBe('editor'); 126 | expect(updatedUser.lastName).toBe(undefined); 127 | }); 128 | }); 129 | 130 | describe('deleteUser', () => { 131 | it('should delete a user and return the id', async () => { 132 | const id = await controller.delete('testid'); 133 | expect(id).toBe('testid'); 134 | }); 135 | }); 136 | }); 137 | -------------------------------------------------------------------------------- /backend/src/course/course.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { CreateCourseDto, UpdateCourseDto } from './course.dto'; 4 | import { CourseService } from './course.service'; 5 | 6 | const MockService = { 7 | save: jest.fn().mockImplementation((createCourseDto: CreateCourseDto) => { 8 | return { 9 | id: 'testid', 10 | dateCreated: new Date(), 11 | ...createCourseDto, 12 | }; 13 | }), 14 | findAll: jest.fn().mockImplementation(() => { 15 | return [ 16 | { 17 | id: 'testid1', 18 | name: 'test1', 19 | description: 'test1', 20 | dateCreated: new Date(), 21 | }, 22 | { 23 | id: 'testid2', 24 | name: 'test2', 25 | description: 'test2', 26 | dateCreated: new Date(), 27 | }, 28 | { 29 | id: 'testid3', 30 | name: 'test3', 31 | description: 'test3', 32 | dateCreated: new Date(), 33 | }, 34 | ]; 35 | }), 36 | findById: jest.fn().mockImplementation((id: string) => { 37 | return { 38 | id, 39 | name: 'test', 40 | description: 'test', 41 | dateCreated: new Date(), 42 | }; 43 | }), 44 | update: jest 45 | .fn() 46 | .mockImplementation((id: string, updateCourseDto: UpdateCourseDto) => { 47 | return { 48 | id, 49 | ...updateCourseDto, 50 | }; 51 | }), 52 | delete: jest.fn().mockImplementation((id) => id), 53 | count: jest.fn().mockReturnValue(10), 54 | }; 55 | 56 | describe('CourseService', () => { 57 | let service: CourseService; 58 | 59 | beforeEach(async () => { 60 | const module: TestingModule = await Test.createTestingModule({ 61 | providers: [ 62 | { 63 | provide: CourseService, 64 | useValue: MockService, 65 | }, 66 | ], 67 | }).compile(); 68 | 69 | service = module.get(CourseService); 70 | }); 71 | 72 | it('should be defined', () => { 73 | expect(service).toBeDefined(); 74 | }); 75 | 76 | describe('saveCourse', () => { 77 | it('should get the created course ', async () => { 78 | const created = await service.save({ 79 | name: 'test', 80 | description: 'test', 81 | }); 82 | expect(created.id).toBe('testid'); 83 | expect(created.name).toBe('test'); 84 | expect(created.description).toBe('test'); 85 | }); 86 | }); 87 | 88 | describe('findAllCourses', () => { 89 | it('should get the array of courses ', async () => { 90 | const courses = await service.findAll({}); 91 | expect(courses[0].id).toBe('testid1'); 92 | expect(courses[1].name).toBe('test2'); 93 | expect(courses[2].description).toBe('test3'); 94 | }); 95 | }); 96 | 97 | describe('findCourseById', () => { 98 | it('should get the course with matching id ', async () => { 99 | const spy = jest.spyOn(global, 'Date'); 100 | const course = await service.findById('testid'); 101 | const date = spy.mock.instances[0]; 102 | 103 | expect(course).toEqual({ 104 | id: 'testid', 105 | name: 'test', 106 | description: 'test', 107 | dateCreated: date, 108 | }); 109 | }); 110 | }); 111 | 112 | describe('updateCourse', () => { 113 | it('should update a course and return changed values', async () => { 114 | const updatedCourse = await service.update('testid', { 115 | name: 'test', 116 | description: 'test', 117 | }); 118 | 119 | expect(updatedCourse).toEqual({ 120 | id: 'testid', 121 | name: 'test', 122 | description: 'test', 123 | }); 124 | 125 | const updatedCourse2 = await service.update('testid2', { 126 | name: 'test2', 127 | }); 128 | 129 | expect(updatedCourse2).toEqual({ 130 | id: 'testid2', 131 | name: 'test2', 132 | }); 133 | }); 134 | }); 135 | 136 | describe('deleteCourse', () => { 137 | it('should delete a course and return the id', async () => { 138 | const id = await service.delete('testid'); 139 | expect(id).toBe('testid'); 140 | }); 141 | }); 142 | 143 | describe('count', () => { 144 | it('should get number of courses', async () => { 145 | const count = await service.count(); 146 | expect(count).toBe(10); 147 | }); 148 | }); 149 | }); 150 | -------------------------------------------------------------------------------- /frontend/src/pages/Courses.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Loader, Plus, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useQuery } from 'react-query'; 5 | 6 | import CoursesTable from '../components/courses/CoursesTable'; 7 | import Layout from '../components/layout'; 8 | import Modal from '../components/shared/Modal'; 9 | import useAuth from '../hooks/useAuth'; 10 | import CreateCourseRequest from '../models/course/CreateCourseRequest'; 11 | import courseService from '../services/CourseService'; 12 | 13 | export default function Courses() { 14 | const [name, setName] = useState(''); 15 | const [description, setDescription] = useState(''); 16 | 17 | const [addCourseShow, setAddCourseShow] = useState(false); 18 | const [error, setError] = useState(); 19 | 20 | const { authenticatedUser } = useAuth(); 21 | const { data, isLoading } = useQuery( 22 | ['courses', name, description], 23 | () => 24 | courseService.findAll({ 25 | name: name || undefined, 26 | description: description || undefined, 27 | }), 28 | { 29 | refetchInterval: 1000, 30 | }, 31 | ); 32 | 33 | const { 34 | register, 35 | handleSubmit, 36 | formState: { isSubmitting }, 37 | reset, 38 | } = useForm(); 39 | 40 | const saveCourse = async (createCourseRequest: CreateCourseRequest) => { 41 | try { 42 | await courseService.save(createCourseRequest); 43 | setAddCourseShow(false); 44 | reset(); 45 | setError(null); 46 | } catch (error) { 47 | setError(error.response.data.message); 48 | } 49 | }; 50 | 51 | return ( 52 | 53 |

Manage Courses

54 |
55 | {authenticatedUser.role !== 'user' ? ( 56 | 62 | ) : null} 63 | 64 |
65 |
66 | setName(e.target.value)} 72 | /> 73 | setDescription(e.target.value)} 79 | /> 80 |
81 |
82 | 83 | 84 | 85 | {/* Add User Modal */} 86 | 87 |
88 |

Add Course

89 | 98 |
99 |
100 | 101 |
105 | 113 | 121 | 128 | {error ? ( 129 |
130 | {error} 131 |
132 | ) : null} 133 |
134 |
135 |
136 | ); 137 | } 138 | -------------------------------------------------------------------------------- /frontend/src/pages/Contents.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Loader, Plus, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useQuery } from 'react-query'; 5 | import { useParams } from 'react-router'; 6 | 7 | import ContentsTable from '../components/content/ContentsTable'; 8 | import Layout from '../components/layout'; 9 | import Modal from '../components/shared/Modal'; 10 | import useAuth from '../hooks/useAuth'; 11 | import CreateContentRequest from '../models/content/CreateContentRequest'; 12 | import contentService from '../services/ContentService'; 13 | import courseService from '../services/CourseService'; 14 | 15 | export default function Course() { 16 | const { id } = useParams<{ id: string }>(); 17 | const { authenticatedUser } = useAuth(); 18 | 19 | const [name, setName] = useState(''); 20 | const [description, setDescription] = useState(''); 21 | const [addContentShow, setAddContentShow] = useState(false); 22 | const [error, setError] = useState(); 23 | 24 | const userQuery = useQuery('user', async () => courseService.findOne(id)); 25 | 26 | const { 27 | register, 28 | handleSubmit, 29 | formState: { isSubmitting }, 30 | reset, 31 | } = useForm(); 32 | 33 | const { data, isLoading } = useQuery( 34 | [`contents-${id}`, name, description], 35 | async () => 36 | contentService.findAll(id, { 37 | name: name || undefined, 38 | description: description || undefined, 39 | }), 40 | { 41 | refetchInterval: 1000, 42 | }, 43 | ); 44 | 45 | const saveCourse = async (createContentRequest: CreateContentRequest) => { 46 | try { 47 | await contentService.save(id, createContentRequest); 48 | setAddContentShow(false); 49 | reset(); 50 | setError(null); 51 | } catch (error) { 52 | setError(error.response.data.message); 53 | } 54 | }; 55 | 56 | return ( 57 | 58 |

59 | {!userQuery.isLoading ? `${userQuery.data.name} Contents` : ''} 60 |

61 |
62 | {authenticatedUser.role !== 'user' ? ( 63 | 69 | ) : null} 70 | 71 |
72 |
73 | setName(e.target.value)} 79 | /> 80 | setDescription(e.target.value)} 86 | /> 87 |
88 |
89 | 90 | 91 | 92 | {/* Add User Modal */} 93 | 94 |
95 |

Add Content

96 | 105 |
106 |
107 | 108 |
112 | 120 | 128 | 135 | {error ? ( 136 |
137 | {error} 138 |
139 | ) : null} 140 |
141 |
142 |
143 | ); 144 | } 145 | -------------------------------------------------------------------------------- /backend/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { Role } from '../enums/role.enum'; 4 | import { CreateUserDto, UpdateUserDto } from './user.dto'; 5 | import { UserService } from './user.service'; 6 | 7 | describe('UserService', () => { 8 | let service: UserService; 9 | 10 | beforeEach(async () => { 11 | const module: TestingModule = await Test.createTestingModule({ 12 | providers: [ 13 | { 14 | provide: UserService, 15 | useValue: { 16 | findAll: jest.fn().mockResolvedValue([ 17 | { 18 | id: 'test1', 19 | firstName: 'test1', 20 | lastName: 'test1', 21 | username: 'test1', 22 | isActive: true, 23 | role: Role.Admin, 24 | }, 25 | { 26 | id: 'test2', 27 | firstName: 'test2', 28 | lastName: 'test2', 29 | username: 'test2', 30 | isActive: true, 31 | role: Role.Admin, 32 | }, 33 | { 34 | id: 'test3', 35 | firstName: 'test3', 36 | lastName: 'test3', 37 | username: 'test3', 38 | isActive: true, 39 | role: Role.Admin, 40 | }, 41 | ]), 42 | save: jest 43 | .fn() 44 | .mockImplementation((createUserDto: CreateUserDto) => { 45 | return { 46 | id: 'testid', 47 | ...createUserDto, 48 | }; 49 | }), 50 | findById: jest.fn().mockImplementation((id: string) => { 51 | return { 52 | id, 53 | firstName: 'test', 54 | lastName: 'test', 55 | password: 'test', 56 | role: Role.User, 57 | isActive: true, 58 | username: 'test', 59 | }; 60 | }), 61 | findByUsername: jest.fn().mockImplementation((username: string) => { 62 | return { 63 | id: 'testid', 64 | firstName: 'test', 65 | lastName: 'test', 66 | password: 'test', 67 | role: Role.User, 68 | isActive: true, 69 | username, 70 | }; 71 | }), 72 | update: jest 73 | .fn() 74 | .mockImplementation( 75 | (id: string, updateUserDto: UpdateUserDto) => { 76 | return { 77 | id, 78 | ...updateUserDto, 79 | }; 80 | }, 81 | ), 82 | delete: jest.fn().mockImplementation((id: string) => id), 83 | count: jest.fn().mockReturnValue(10), 84 | }, 85 | }, 86 | ], 87 | }).compile(); 88 | 89 | service = module.get(UserService); 90 | }); 91 | 92 | it('should be defined', () => { 93 | expect(service).toBeDefined(); 94 | }); 95 | 96 | describe('saveUser', () => { 97 | it('should get the same user that is created', async () => { 98 | const returnValue = await service.save({ 99 | firstName: 'test', 100 | lastName: 'test', 101 | password: 'test', 102 | role: Role.User, 103 | username: 'test', 104 | }); 105 | expect(returnValue.id).toBe('testid'); 106 | expect(returnValue.firstName).toBe('test'); 107 | expect(returnValue.role).toBe('user'); 108 | }); 109 | }); 110 | 111 | describe('findAllUsers', () => { 112 | it('should get the list of users', async () => { 113 | const users = await service.findAll({}); 114 | expect(typeof users).toBe('object'); 115 | expect(users[0].firstName).toBe('test1'); 116 | expect(users[1].lastName).toBe('test2'); 117 | expect(users[2].username).toBe('test3'); 118 | expect(users.length).toBe(3); 119 | }); 120 | }); 121 | 122 | describe('findOneUser', () => { 123 | it('should get a user matching id', async () => { 124 | const user = await service.findById('id'); 125 | expect(user.id).toBe('id'); 126 | expect(user.firstName).toBe('test'); 127 | }); 128 | }); 129 | 130 | describe('findOneUserByUsername', () => { 131 | it('should get a user matching username', async () => { 132 | const user = await service.findByUsername('testusername'); 133 | expect(user.id).toBe('testid'); 134 | expect(user.firstName).toBe('test'); 135 | expect(user.username).toBe('testusername'); 136 | }); 137 | }); 138 | 139 | describe('updateUser', () => { 140 | it('should update a user and return changed values', async () => { 141 | const updatedUser = await service.update('testid', { 142 | firstName: 'test', 143 | role: Role.Editor, 144 | }); 145 | expect(updatedUser.id).toBe('testid'); 146 | expect(updatedUser.role).toBe('editor'); 147 | expect(updatedUser.lastName).toBe(undefined); 148 | }); 149 | }); 150 | 151 | describe('deleteUser', () => { 152 | it('should delete a user and return the id', async () => { 153 | const id = await service.delete('testid'); 154 | expect(id).toBe('testid'); 155 | }); 156 | }); 157 | 158 | describe('countUsers', () => { 159 | it('should return the number of users', async () => { 160 | const count = await service.count(); 161 | expect(count).toBe(10); 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /frontend/src/pages/Users.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { Loader, Plus, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useQuery } from 'react-query'; 5 | 6 | import Layout from '../components/layout'; 7 | import Modal from '../components/shared/Modal'; 8 | import UsersTable from '../components/users/UsersTable'; 9 | import useAuth from '../hooks/useAuth'; 10 | import CreateUserRequest from '../models/user/CreateUserRequest'; 11 | import userService from '../services/UserService'; 12 | 13 | export default function Users() { 14 | const { authenticatedUser } = useAuth(); 15 | 16 | const [firstName, setFirstName] = useState(''); 17 | const [lastName, setLastName] = useState(''); 18 | const [username, setUsername] = useState(''); 19 | const [role, setRole] = useState(''); 20 | 21 | const [addUserShow, setAddUserShow] = useState(false); 22 | const [error, setError] = useState(); 23 | 24 | const { data, isLoading } = useQuery( 25 | ['users', firstName, lastName, username, role], 26 | async () => { 27 | return ( 28 | await userService.findAll({ 29 | firstName: firstName || undefined, 30 | lastName: lastName || undefined, 31 | username: username || undefined, 32 | role: role || undefined, 33 | }) 34 | ).filter((user) => user.id !== authenticatedUser.id); 35 | }, 36 | { 37 | refetchInterval: 1000, 38 | }, 39 | ); 40 | 41 | const { 42 | register, 43 | handleSubmit, 44 | formState: { isSubmitting }, 45 | reset, 46 | } = useForm(); 47 | 48 | const saveUser = async (createUserRequest: CreateUserRequest) => { 49 | try { 50 | await userService.save(createUserRequest); 51 | setAddUserShow(false); 52 | setError(null); 53 | reset(); 54 | } catch (error) { 55 | setError(error.response.data.message); 56 | } 57 | }; 58 | 59 | return ( 60 | 61 |

Manage Users

62 |
63 | 69 | 70 |
71 |
72 | setFirstName(e.target.value)} 78 | /> 79 | setLastName(e.target.value)} 85 | /> 86 |
87 |
88 | setUsername(e.target.value)} 94 | /> 95 | 107 |
108 |
109 | 110 | 111 | 112 | {/* Add User Modal */} 113 | 114 |
115 |

Add User

116 | 126 |
127 |
128 | 129 |
133 |
134 | 142 | 150 |
151 | 159 | 167 | 177 | 184 | {error ? ( 185 |
186 | {error} 187 |
188 | ) : null} 189 |
190 |
191 |
192 | ); 193 | } 194 | -------------------------------------------------------------------------------- /backend/src/content/content.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | 3 | import { CreateContentDto, UpdateContentDto } from './content.dto'; 4 | import { ContentService } from './content.service'; 5 | 6 | const MockService = { 7 | save: jest 8 | .fn() 9 | .mockImplementation((id: string, createContentDto: CreateContentDto) => { 10 | return { 11 | id: 'testid', 12 | dateCreated: new Date(), 13 | ...createContentDto, 14 | }; 15 | }), 16 | findAll: jest.fn().mockImplementation(() => { 17 | return [ 18 | { 19 | id: 'testid1', 20 | name: 'test1', 21 | description: 'test1', 22 | dateCreated: new Date(), 23 | }, 24 | { 25 | id: 'testid2', 26 | name: 'test2', 27 | description: 'test2', 28 | dateCreated: new Date(), 29 | }, 30 | { 31 | id: 'testid3', 32 | name: 'test3', 33 | description: 'test3', 34 | dateCreated: new Date(), 35 | }, 36 | ]; 37 | }), 38 | findById: jest.fn().mockImplementation((id: string) => { 39 | return { 40 | id, 41 | name: 'test', 42 | description: 'test', 43 | dateCreated: new Date(), 44 | }; 45 | }), 46 | findByCourseIdAndId: jest 47 | .fn() 48 | .mockImplementation((courseId: string, id: string) => { 49 | return { 50 | id, 51 | name: 'test', 52 | description: 'test', 53 | dateCreated: new Date(), 54 | }; 55 | }), 56 | findAllByCourseId: jest.fn().mockImplementation((id: string) => { 57 | return [ 58 | { 59 | id: 'testid1', 60 | name: 'test1', 61 | description: 'test1', 62 | dateCreated: new Date(), 63 | }, 64 | { 65 | id: 'testid2', 66 | name: 'test2', 67 | description: 'test2', 68 | dateCreated: new Date(), 69 | }, 70 | { 71 | id: 'testid3', 72 | name: 'test3', 73 | description: 'test3', 74 | dateCreated: new Date(), 75 | }, 76 | ]; 77 | }), 78 | update: jest 79 | .fn() 80 | .mockImplementation( 81 | (id: string, contentId: string, updateContentDto: UpdateContentDto) => { 82 | return { 83 | id: contentId, 84 | ...updateContentDto, 85 | }; 86 | }, 87 | ), 88 | delete: jest 89 | .fn() 90 | .mockImplementation((id: string, contentId: string) => contentId), 91 | count: jest.fn().mockReturnValue(10), 92 | }; 93 | 94 | describe('ContentService', () => { 95 | let service: ContentService; 96 | 97 | beforeEach(async () => { 98 | const module: TestingModule = await Test.createTestingModule({ 99 | providers: [ 100 | { 101 | provide: ContentService, 102 | useValue: MockService, 103 | }, 104 | ], 105 | }).compile(); 106 | 107 | service = module.get(ContentService); 108 | }); 109 | 110 | it('should be defined', () => { 111 | expect(service).toBeDefined(); 112 | }); 113 | 114 | describe('saveContent', () => { 115 | it('should get the saved content', async () => { 116 | const spy = jest.spyOn(global, 'Date'); 117 | const content = await service.save('testcourseid', { 118 | name: 'test', 119 | description: 'test', 120 | }); 121 | const date = spy.mock.instances[0]; 122 | 123 | expect(content).toEqual({ 124 | id: 'testid', 125 | name: 'test', 126 | description: 'test', 127 | dateCreated: date, 128 | }); 129 | }); 130 | }); 131 | 132 | describe('findAllContent', () => { 133 | it('should get the list of all contents', async () => { 134 | const contents = await service.findAll({}); 135 | expect(contents[0].id).toBe('testid1'); 136 | expect(contents[1].name).toBe('test2'); 137 | expect(contents[2].description).toBe('test3'); 138 | }); 139 | }); 140 | 141 | describe('findContentById', () => { 142 | it('should get a content by id', async () => { 143 | const spy = jest.spyOn(global, 'Date'); 144 | const content = await service.findById('testid'); 145 | const date = spy.mock.instances[0]; 146 | 147 | expect(content).toEqual({ 148 | id: 'testid', 149 | name: 'test', 150 | description: 'test', 151 | dateCreated: date, 152 | }); 153 | }); 154 | }); 155 | 156 | describe('findAllContentsByCourseIdAndId', () => { 157 | it('should get a contets', async () => { 158 | const spy = jest.spyOn(global, 'Date'); 159 | const content = await service.findByCourseIdAndId( 160 | 'testcourseid', 161 | 'testid', 162 | ); 163 | const date = spy.mock.instances[0]; 164 | 165 | expect(content).toEqual({ 166 | id: 'testid', 167 | name: 'test', 168 | description: 'test', 169 | dateCreated: date, 170 | }); 171 | }); 172 | }); 173 | 174 | describe('findAllContentsByCourseId', () => { 175 | it('should get the array of contents', async () => { 176 | const contents = await service.findAllByCourseId('testcourseid', {}); 177 | 178 | expect(contents[0].id).toBe('testid1'); 179 | expect(contents[1].name).toBe('test2'); 180 | expect(contents[2].description).toBe('test3'); 181 | }); 182 | }); 183 | 184 | describe('updateContent', () => { 185 | it('should update a content and return changed values', async () => { 186 | const updatedContent = await service.update('testid', 'testcontentid', { 187 | name: 'test', 188 | description: 'test', 189 | }); 190 | 191 | expect(updatedContent).toEqual({ 192 | id: 'testcontentid', 193 | name: 'test', 194 | description: 'test', 195 | }); 196 | 197 | const updatedContent2 = await service.update('testid', 'testcontentid2', { 198 | description: 'test', 199 | }); 200 | 201 | expect(updatedContent2).toEqual({ 202 | id: 'testcontentid2', 203 | description: 'test', 204 | }); 205 | }); 206 | }); 207 | 208 | describe('deleteContent', () => { 209 | it('should delete a content and return the id', async () => { 210 | const id = await service.delete('testid', 'testcontentid'); 211 | expect(id).toBe('testcontentid'); 212 | }); 213 | }); 214 | 215 | describe('countContents', () => { 216 | it('should get number of contents', async () => { 217 | const count = await service.count(); 218 | expect(count).toBe(10); 219 | }); 220 | }); 221 | }); 222 | -------------------------------------------------------------------------------- /frontend/src/components/courses/CoursesTable.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { AlertTriangle, Loader, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | import { Link } from 'react-router-dom'; 5 | 6 | import useAuth from '../../hooks/useAuth'; 7 | import Course from '../../models/course/Course'; 8 | import UpdateCourseRequest from '../../models/course/UpdateCourseRequest'; 9 | import courseService from '../../services/CourseService'; 10 | import Modal from '../shared/Modal'; 11 | import Table from '../shared/Table'; 12 | import TableItem from '../shared/TableItem'; 13 | 14 | interface UsersTableProps { 15 | data: Course[]; 16 | isLoading: boolean; 17 | } 18 | 19 | export default function CoursesTable({ data, isLoading }: UsersTableProps) { 20 | const { authenticatedUser } = useAuth(); 21 | const [deleteShow, setDeleteShow] = useState(false); 22 | const [isDeleting, setIsDeleting] = useState(false); 23 | const [selectedCourseId, setSelectedCourseId] = useState(); 24 | const [error, setError] = useState(); 25 | const [updateShow, setUpdateShow] = useState(false); 26 | 27 | const { 28 | register, 29 | handleSubmit, 30 | formState: { isSubmitting }, 31 | reset, 32 | setValue, 33 | } = useForm(); 34 | 35 | const handleDelete = async () => { 36 | try { 37 | setIsDeleting(true); 38 | await courseService.delete(selectedCourseId); 39 | setDeleteShow(false); 40 | } catch (error) { 41 | setError(error.response.data.message); 42 | } finally { 43 | setIsDeleting(false); 44 | } 45 | }; 46 | 47 | const handleUpdate = async (updateCourseRequest: UpdateCourseRequest) => { 48 | try { 49 | await courseService.update(selectedCourseId, updateCourseRequest); 50 | setUpdateShow(false); 51 | reset(); 52 | setError(null); 53 | } catch (error) { 54 | setError(error.response.data.message); 55 | } 56 | }; 57 | 58 | return ( 59 | <> 60 |
61 | 62 | {isLoading 63 | ? null 64 | : data.map(({ id, name, description, dateCreated }) => ( 65 | 66 | 67 | {name} 68 | 69 | {description} 70 | 71 | {new Date(dateCreated).toLocaleDateString()} 72 | 73 | 74 | {['admin', 'editor'].includes(authenticatedUser.role) ? ( 75 | 88 | ) : null} 89 | {authenticatedUser.role === 'admin' ? ( 90 | 99 | ) : null} 100 | 101 | 102 | ))} 103 |
104 | {!isLoading && data.length < 1 ? ( 105 |
106 |

Empty

107 |
108 | ) : null} 109 |
110 | {/* Delete Course Modal */} 111 | 112 | 113 |
114 |

Delete Course

115 |
116 |

117 | Are you sure you want to delete the course? All of course's data 118 | will be permanently removed. 119 |
120 | This action cannot be undone. 121 |

122 |
123 |
124 | 134 | 145 |
146 | {error ? ( 147 |
148 | {error} 149 |
150 | ) : null} 151 |
152 | {/* Update Course Modal */} 153 | 154 |
155 |

Update Course

156 | 166 |
167 |
168 | 169 |
173 | 180 | 188 | 195 | {error ? ( 196 |
197 | {error} 198 |
199 | ) : null} 200 |
201 |
202 | 203 | ); 204 | } 205 | -------------------------------------------------------------------------------- /frontend/src/components/content/ContentsTable.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { AlertTriangle, Loader, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | 5 | import useAuth from '../../hooks/useAuth'; 6 | import Content from '../../models/content/Content'; 7 | import UpdateContentRequest from '../../models/content/UpdateContentRequest'; 8 | import contentService from '../../services/ContentService'; 9 | import Modal from '../shared/Modal'; 10 | import Table from '../shared/Table'; 11 | import TableItem from '../shared/TableItem'; 12 | 13 | interface ContentsTableProps { 14 | data: Content[]; 15 | courseId: string; 16 | isLoading: boolean; 17 | } 18 | 19 | export default function ContentsTable({ 20 | data, 21 | isLoading, 22 | courseId, 23 | }: ContentsTableProps) { 24 | const { authenticatedUser } = useAuth(); 25 | const [deleteShow, setDeleteShow] = useState(false); 26 | const [isDeleting, setIsDeleting] = useState(false); 27 | const [selectedContentId, setSelectedContentId] = useState(); 28 | const [error, setError] = useState(); 29 | const [updateShow, setUpdateShow] = useState(false); 30 | 31 | const { 32 | register, 33 | handleSubmit, 34 | formState: { isSubmitting }, 35 | reset, 36 | setValue, 37 | } = useForm(); 38 | 39 | const handleDelete = async () => { 40 | try { 41 | setIsDeleting(true); 42 | await contentService.delete(courseId, selectedContentId); 43 | setDeleteShow(false); 44 | } catch (error) { 45 | setError(error.response.data.message); 46 | } finally { 47 | setIsDeleting(false); 48 | } 49 | }; 50 | 51 | const handleUpdate = async (updateContentRequest: UpdateContentRequest) => { 52 | try { 53 | await contentService.update( 54 | courseId, 55 | selectedContentId, 56 | updateContentRequest, 57 | ); 58 | setUpdateShow(false); 59 | reset(); 60 | setError(null); 61 | } catch (error) { 62 | setError(error.response.data.message); 63 | } 64 | }; 65 | 66 | return ( 67 | <> 68 |
69 | 70 | {isLoading 71 | ? null 72 | : data.map(({ id, name, description, dateCreated }) => ( 73 | 74 | {name} 75 | {description} 76 | 77 | {new Date(dateCreated).toLocaleDateString()} 78 | 79 | 80 | {['admin', 'editor'].includes(authenticatedUser.role) ? ( 81 | 94 | ) : null} 95 | {authenticatedUser.role === 'admin' ? ( 96 | 105 | ) : null} 106 | 107 | 108 | ))} 109 |
110 | {!isLoading && data.length < 1 ? ( 111 |
112 |

Empty

113 |
114 | ) : null} 115 |
116 | 117 | {/* Delete Content Modal */} 118 | 119 | 120 |
121 |

Delete Content

122 |
123 |

124 | Are you sure you want to delete the content? All of content's data 125 | will be permanently removed. 126 |
127 | This action cannot be undone. 128 |

129 |
130 |
131 | 141 | 152 |
153 | {error ? ( 154 |
155 | {error} 156 |
157 | ) : null} 158 |
159 | 160 | {/* Update Content Modal */} 161 | {selectedContentId ? ( 162 | 163 |
164 |

Update Content

165 | 175 |
176 |
177 | 178 |
182 | 189 | 197 | 204 | {error ? ( 205 |
206 | {error} 207 |
208 | ) : null} 209 |
210 |
211 | ) : null} 212 | 213 | ); 214 | } 215 | -------------------------------------------------------------------------------- /backend/src/course/course.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { CreateContentDto, UpdateContentDto } from 'src/content/content.dto'; 3 | 4 | import { ContentService } from '../content/content.service'; 5 | import { CourseController } from './course.controller'; 6 | import { CreateCourseDto, UpdateCourseDto } from './course.dto'; 7 | import { CourseService } from './course.service'; 8 | 9 | const CourseMockService = { 10 | save: jest.fn().mockImplementation((createCourseDto: CreateCourseDto) => { 11 | return { 12 | id: 'testid', 13 | dateCreated: new Date(), 14 | ...createCourseDto, 15 | }; 16 | }), 17 | findAll: jest.fn().mockImplementation(() => { 18 | return [ 19 | { 20 | id: 'testid1', 21 | name: 'test1', 22 | description: 'test1', 23 | dateCreated: new Date(), 24 | }, 25 | { 26 | id: 'testid2', 27 | name: 'test2', 28 | description: 'test2', 29 | dateCreated: new Date(), 30 | }, 31 | { 32 | id: 'testid3', 33 | name: 'test3', 34 | description: 'test3', 35 | dateCreated: new Date(), 36 | }, 37 | ]; 38 | }), 39 | findById: jest.fn().mockImplementation((id: string) => { 40 | return { 41 | id, 42 | name: 'test', 43 | description: 'test', 44 | dateCreated: new Date(), 45 | }; 46 | }), 47 | update: jest 48 | .fn() 49 | .mockImplementation((id: string, updateCourseDto: UpdateCourseDto) => { 50 | return { 51 | id, 52 | ...updateCourseDto, 53 | }; 54 | }), 55 | delete: jest.fn().mockImplementation((id) => id), 56 | }; 57 | 58 | const ContentMockService = { 59 | save: jest 60 | .fn() 61 | .mockImplementation((id: string, createContentDto: CreateContentDto) => { 62 | return { 63 | id: 'testid', 64 | dateCreated: new Date(), 65 | ...createContentDto, 66 | }; 67 | }), 68 | findAllByCourseId: jest.fn().mockImplementation((id: string) => { 69 | return [ 70 | { 71 | id: 'testid1', 72 | name: 'test1', 73 | description: 'test1', 74 | dateCreated: new Date(), 75 | }, 76 | { 77 | id: 'testid2', 78 | name: 'test2', 79 | description: 'test2', 80 | dateCreated: new Date(), 81 | }, 82 | { 83 | id: 'testid3', 84 | name: 'test3', 85 | description: 'test3', 86 | dateCreated: new Date(), 87 | }, 88 | ]; 89 | }), 90 | update: jest 91 | .fn() 92 | .mockImplementation( 93 | (id: string, contentId: string, updateContentDto: UpdateContentDto) => { 94 | return { 95 | id: contentId, 96 | ...updateContentDto, 97 | }; 98 | }, 99 | ), 100 | delete: jest 101 | .fn() 102 | .mockImplementation((id: string, contentId: string) => contentId), 103 | }; 104 | 105 | describe('CourseController', () => { 106 | let controller: CourseController; 107 | 108 | beforeEach(async () => { 109 | const module: TestingModule = await Test.createTestingModule({ 110 | controllers: [CourseController], 111 | providers: [ 112 | { 113 | provide: CourseService, 114 | useValue: CourseMockService, 115 | }, 116 | { 117 | provide: ContentService, 118 | useValue: ContentMockService, 119 | }, 120 | ], 121 | }).compile(); 122 | 123 | controller = module.get(CourseController); 124 | }); 125 | 126 | it('should be defined', () => { 127 | expect(controller).toBeDefined(); 128 | }); 129 | 130 | describe('saveCourse', () => { 131 | it('should get the created course ', async () => { 132 | const created = await controller.save({ 133 | name: 'test', 134 | description: 'test', 135 | }); 136 | expect(created.id).toBe('testid'); 137 | expect(created.name).toBe('test'); 138 | expect(created.description).toBe('test'); 139 | }); 140 | }); 141 | 142 | describe('findAllCourses', () => { 143 | it('should get the array of courses ', async () => { 144 | const courses = await controller.findAll({}); 145 | expect(courses[0].id).toBe('testid1'); 146 | expect(courses[1].name).toBe('test2'); 147 | expect(courses[2].description).toBe('test3'); 148 | }); 149 | }); 150 | 151 | describe('findCourseById', () => { 152 | it('should get the course with matching id ', async () => { 153 | const spy = jest.spyOn(global, 'Date'); 154 | const course = await controller.findOne('testid'); 155 | const date = spy.mock.instances[0]; 156 | 157 | expect(course).toEqual({ 158 | id: 'testid', 159 | name: 'test', 160 | description: 'test', 161 | dateCreated: date, 162 | }); 163 | }); 164 | }); 165 | 166 | describe('updateCourse', () => { 167 | it('should update a course and return changed values', async () => { 168 | const updatedCourse = await controller.update('testid', { 169 | name: 'test', 170 | description: 'test', 171 | }); 172 | 173 | expect(updatedCourse).toEqual({ 174 | id: 'testid', 175 | name: 'test', 176 | description: 'test', 177 | }); 178 | 179 | const updatedCourse2 = await controller.update('testid2', { 180 | name: 'test2', 181 | }); 182 | 183 | expect(updatedCourse2).toEqual({ 184 | id: 'testid2', 185 | name: 'test2', 186 | }); 187 | }); 188 | }); 189 | 190 | describe('deleteCourse', () => { 191 | it('should delete a course and return the id', async () => { 192 | const id = await controller.delete('testid'); 193 | expect(id).toBe('testid'); 194 | }); 195 | }); 196 | 197 | describe('saveContent', () => { 198 | it('should get the saved content', async () => { 199 | const spy = jest.spyOn(global, 'Date'); 200 | const content = await controller.saveContent('testcourseid', { 201 | name: 'test', 202 | description: 'test', 203 | }); 204 | const date = spy.mock.instances[0]; 205 | 206 | expect(content).toEqual({ 207 | id: 'testid', 208 | name: 'test', 209 | description: 'test', 210 | dateCreated: date, 211 | }); 212 | }); 213 | }); 214 | 215 | describe('findAllContentsByCourseId', () => { 216 | it('should get the array of contents', async () => { 217 | const contents = await controller.findAllContentsByCourseId( 218 | 'testcourseid', 219 | {}, 220 | ); 221 | 222 | expect(contents[0].id).toBe('testid1'); 223 | expect(contents[1].name).toBe('test2'); 224 | expect(contents[2].description).toBe('test3'); 225 | }); 226 | }); 227 | 228 | describe('updateContent', () => { 229 | it('should update a content and return changed values', async () => { 230 | const updatedContent = await controller.updateContent( 231 | 'testid', 232 | 'testcontentid', 233 | { 234 | name: 'test', 235 | description: 'test', 236 | }, 237 | ); 238 | 239 | expect(updatedContent).toEqual({ 240 | id: 'testcontentid', 241 | name: 'test', 242 | description: 'test', 243 | }); 244 | 245 | const updatedContent2 = await controller.updateContent( 246 | 'testid', 247 | 'testcontentid2', 248 | { 249 | description: 'test', 250 | }, 251 | ); 252 | 253 | expect(updatedContent2).toEqual({ 254 | id: 'testcontentid2', 255 | description: 'test', 256 | }); 257 | }); 258 | }); 259 | 260 | describe('deleteContent', () => { 261 | it('should delete a content and return the id', async () => { 262 | const id = await controller.deleteContent('testid', 'testcontentid'); 263 | expect(id).toBe('testcontentid'); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /frontend/src/components/users/UsersTable.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { AlertTriangle, Loader, X } from 'react-feather'; 3 | import { useForm } from 'react-hook-form'; 4 | 5 | import UpdateUserRequest from '../../models/user/UpdateUserRequest'; 6 | import User from '../../models/user/User'; 7 | import userService from '../../services/UserService'; 8 | import Modal from '../shared/Modal'; 9 | import Table from '../shared/Table'; 10 | import TableItem from '../shared/TableItem'; 11 | 12 | interface UsersTableProps { 13 | data: User[]; 14 | isLoading: boolean; 15 | } 16 | 17 | export default function UsersTable({ data, isLoading }: UsersTableProps) { 18 | const [deleteShow, setDeleteShow] = useState(false); 19 | const [updateShow, setUpdateShow] = useState(false); 20 | const [isDeleting, setIsDeleting] = useState(false); 21 | const [selectedUserId, setSelectedUserId] = useState(); 22 | const [error, setError] = useState(); 23 | 24 | const { 25 | register, 26 | handleSubmit, 27 | formState: { isSubmitting }, 28 | reset, 29 | setValue, 30 | } = useForm(); 31 | 32 | const handleDelete = async () => { 33 | try { 34 | setIsDeleting(true); 35 | await userService.delete(selectedUserId); 36 | setDeleteShow(false); 37 | } catch (error) { 38 | setError(error.response.data.message); 39 | } finally { 40 | setIsDeleting(false); 41 | } 42 | }; 43 | 44 | const handleUpdate = async (updateUserRequest: UpdateUserRequest) => { 45 | try { 46 | await userService.update(selectedUserId, updateUserRequest); 47 | setUpdateShow(false); 48 | reset(); 49 | setError(null); 50 | } catch (error) { 51 | setError(error.response.data.message); 52 | } 53 | }; 54 | 55 | return ( 56 | <> 57 |
58 | 59 | {isLoading 60 | ? null 61 | : data.map( 62 | ({ id, firstName, lastName, role, isActive, username }) => ( 63 | 64 | {`${firstName} ${lastName}`} 65 | {username} 66 | 67 | {isActive ? ( 68 | 69 | Active 70 | 71 | ) : ( 72 | 73 | Inactive 74 | 75 | )} 76 | 77 | {role} 78 | 79 | 95 | 104 | 105 | 106 | ), 107 | )} 108 |
109 | 110 | {!isLoading && data.length < 1 ? ( 111 |
112 |

Empty

113 |
114 | ) : null} 115 |
116 | {/* Delete User Modal */} 117 | 118 | 119 |
120 |

Delete User

121 |
122 |

123 | Are you sure you want to delete the user? All of user's data will be 124 | permanently removed. 125 |
126 | This action cannot be undone. 127 |

128 |
129 |
130 | 140 | 151 |
152 | {error ? ( 153 |
154 | {error} 155 |
156 | ) : null} 157 |
158 | {/* Update User Modal */} 159 | 160 |
161 |

Update User

162 | 172 |
173 |
174 | 175 |
179 |
180 | 186 | 193 |
194 | 201 | 208 | 217 |
218 | 219 | 224 |
225 | 232 | {error ? ( 233 |
234 | {error} 235 |
236 | ) : null} 237 |
238 |
239 | 240 | ); 241 | } 242 | -------------------------------------------------------------------------------- /backend/e2e/app.e2e.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "info": { 3 | "_postman_id": "5f9b2a20-6c73-4f34-a899-3ce2d284acd4", 4 | "name": "Carna Project Api Test", 5 | "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json" 6 | }, 7 | "item": [ 8 | { 9 | "name": "Auth Login", 10 | "event": [ 11 | { 12 | "listen": "test", 13 | "script": { 14 | "exec": [ 15 | "pm.test(\"Status code is 200\", function () {\r", 16 | " pm.response.to.have.status(200);\r", 17 | " pm.environment.set(\"access_token\", pm.response.json().token);\r", 18 | "})\r", 19 | "\r", 20 | "pm.test(\"Should return token and user matching username\", function () {\r", 21 | " const jsonData = pm.response.json();\r", 22 | " pm.expect(jsonData.token).to.match(/^[A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*$/);\r", 23 | "});\r", 24 | "\r", 25 | "pm.test(\"Should return refresh token cookie\", function () {\r", 26 | " pm.expect(pm.cookies.has('refresh-token')).to.be.true;\r", 27 | "});" 28 | ], 29 | "type": "text/javascript" 30 | } 31 | } 32 | ], 33 | "request": { 34 | "method": "POST", 35 | "header": [], 36 | "body": { 37 | "mode": "raw", 38 | "raw": "{\r\n \"username\":\"admin\",\r\n \"password\":\"admin123\"\r\n}", 39 | "options": { 40 | "raw": { 41 | "language": "json" 42 | } 43 | } 44 | }, 45 | "url": { 46 | "raw": "http://localhost:5000/api/auth/login", 47 | "protocol": "http", 48 | "host": [ 49 | "localhost" 50 | ], 51 | "port": "5000", 52 | "path": [ 53 | "api", 54 | "auth", 55 | "login" 56 | ] 57 | } 58 | }, 59 | "response": [] 60 | }, 61 | { 62 | "name": "Auth Refresh", 63 | "event": [ 64 | { 65 | "listen": "test", 66 | "script": { 67 | "exec": [ 68 | "pm.test(\"Status code is 200\", function () {\r", 69 | " pm.response.to.have.status(200);\r", 70 | " pm.environment.set(\"access_token\", pm.response.json().token);\r", 71 | "});\r", 72 | "\r", 73 | "pm.test(\"Your test name\", function () {\r", 74 | " const jsonData = pm.response.json();\r", 75 | " pm.expect(jsonData.token).to.match(/^[A-Za-z0-9-_=]+\\.[A-Za-z0-9-_=]+\\.?[A-Za-z0-9-_.+/=]*$/);\r", 76 | "});" 77 | ], 78 | "type": "text/javascript" 79 | } 80 | } 81 | ], 82 | "request": { 83 | "method": "POST", 84 | "header": [], 85 | "url": { 86 | "raw": "http://localhost:5000/api/auth/refresh", 87 | "protocol": "http", 88 | "host": [ 89 | "localhost" 90 | ], 91 | "port": "5000", 92 | "path": [ 93 | "api", 94 | "auth", 95 | "refresh" 96 | ] 97 | } 98 | }, 99 | "response": [] 100 | }, 101 | { 102 | "name": "Users", 103 | "event": [ 104 | { 105 | "listen": "test", 106 | "script": { 107 | "exec": [ 108 | "pm.test(\"Status code is 201\", function () {\r", 109 | " pm.response.to.have.status(201);\r", 110 | " pm.environment.set(\"user_id\", pm.response.json().id);\r", 111 | "});\r", 112 | "\r", 113 | "pm.test(\"Should get created user\", function () {\r", 114 | " const responseJson = pm.response.json();\r", 115 | "\r", 116 | " pm.expect(responseJson.id).to.match(/[0-9a-fA-F]{8}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{4}\\-[0-9a-fA-F]{12}/)\r", 117 | " pm.expect(responseJson.firstName).to.eql(\"test\");\r", 118 | " pm.expect(responseJson.lastName).to.eql(\"test\");\r", 119 | " pm.expect(responseJson.username).to.eql(\"test\");\r", 120 | " pm.expect(responseJson.isActive).to.be.true;\r", 121 | " pm.expect(responseJson.role).to.eql(\"user\");\r", 122 | "});" 123 | ], 124 | "type": "text/javascript" 125 | } 126 | } 127 | ], 128 | "request": { 129 | "auth": { 130 | "type": "bearer", 131 | "bearer": [ 132 | { 133 | "key": "token", 134 | "value": "{{access_token}}", 135 | "type": "string" 136 | } 137 | ] 138 | }, 139 | "method": "POST", 140 | "header": [], 141 | "body": { 142 | "mode": "raw", 143 | "raw": "{\r\n \"firstName\": \"test\",\r\n \"lastName\": \"test\",\r\n \"username\": \"test\",\r\n \"password\": \"test123456\",\r\n \"role\": \"user\"\r\n}", 144 | "options": { 145 | "raw": { 146 | "language": "json" 147 | } 148 | } 149 | }, 150 | "url": { 151 | "raw": "http://localhost:5000/api/users", 152 | "protocol": "http", 153 | "host": [ 154 | "localhost" 155 | ], 156 | "port": "5000", 157 | "path": [ 158 | "api", 159 | "users" 160 | ] 161 | } 162 | }, 163 | "response": [] 164 | }, 165 | { 166 | "name": "Users", 167 | "event": [ 168 | { 169 | "listen": "test", 170 | "script": { 171 | "exec": [ 172 | "pm.test(\"Status code is 200\", function () {\r", 173 | " pm.response.to.have.status(200);\r", 174 | "});\r", 175 | "\r", 176 | "pm.test(\"Response should be an array\", function () {\r", 177 | " const jsonData = pm.response.json();\r", 178 | " pm.expect(jsonData).to.be.an(\"array\");\r", 179 | "});" 180 | ], 181 | "type": "text/javascript" 182 | } 183 | } 184 | ], 185 | "request": { 186 | "auth": { 187 | "type": "bearer", 188 | "bearer": [ 189 | { 190 | "key": "token", 191 | "value": "{{access_token}}", 192 | "type": "string" 193 | } 194 | ] 195 | }, 196 | "method": "GET", 197 | "header": [], 198 | "url": { 199 | "raw": "http://localhost:5000/api/users", 200 | "protocol": "http", 201 | "host": [ 202 | "localhost" 203 | ], 204 | "port": "5000", 205 | "path": [ 206 | "api", 207 | "users" 208 | ] 209 | } 210 | }, 211 | "response": [] 212 | }, 213 | { 214 | "name": "users/{{user_id}}", 215 | "event": [ 216 | { 217 | "listen": "test", 218 | "script": { 219 | "exec": [ 220 | "pm.test(\"Status code is 200\", function () {\r", 221 | " pm.response.to.have.status(200);\r", 222 | "});\r", 223 | "\r", 224 | "pm.test(\"Should get user with matching id\", function () {\r", 225 | " const responseJson = pm.response.json();\r", 226 | " const id = pm.environment.get(\"user_id\");\r", 227 | "\r", 228 | " pm.expect(responseJson.id).to.eql(id);\r", 229 | " pm.expect(responseJson.firstName).to.eql(\"test\");\r", 230 | " pm.expect(responseJson.lastName).to.eql(\"test\");\r", 231 | " pm.expect(responseJson.username).to.eql(\"test\");\r", 232 | " pm.expect(responseJson.role).to.eql(\"user\");\r", 233 | " pm.expect(responseJson.isActive).to.be.true;\r", 234 | "});" 235 | ], 236 | "type": "text/javascript" 237 | } 238 | } 239 | ], 240 | "request": { 241 | "auth": { 242 | "type": "bearer", 243 | "bearer": [ 244 | { 245 | "key": "token", 246 | "value": "{{access_token}}", 247 | "type": "string" 248 | } 249 | ] 250 | }, 251 | "method": "GET", 252 | "header": [], 253 | "url": { 254 | "raw": "http://localhost:5000/api/users/{{user_id}}", 255 | "protocol": "http", 256 | "host": [ 257 | "localhost" 258 | ], 259 | "port": "5000", 260 | "path": [ 261 | "api", 262 | "users", 263 | "{{user_id}}" 264 | ] 265 | } 266 | }, 267 | "response": [] 268 | }, 269 | { 270 | "name": "users/{{user_id}}", 271 | "event": [ 272 | { 273 | "listen": "test", 274 | "script": { 275 | "exec": [ 276 | "pm.test(\"Status code is 200\", function () {\r", 277 | " pm.response.to.have.status(200);\r", 278 | "});\r", 279 | "\r", 280 | "pm.test(\"Should get id and updated fields\", function () {\r", 281 | " const responseJson = pm.response.json();\r", 282 | " const id = pm.environment.get(\"user_id\");\r", 283 | "\r", 284 | " pm.expect(responseJson.id).to.eql(id);\r", 285 | " pm.expect(responseJson.firstName).to.eql(\"test2\");\r", 286 | " pm.expect(responseJson.lastName).to.eql(\"test2\");\r", 287 | " pm.expect(responseJson.username).to.eql(\"test2\");\r", 288 | " pm.expect(responseJson.role).to.eql(\"editor\");\r", 289 | " pm.expect(responseJson.isActive).to.be.false;\r", 290 | "});" 291 | ], 292 | "type": "text/javascript" 293 | } 294 | } 295 | ], 296 | "request": { 297 | "auth": { 298 | "type": "bearer", 299 | "bearer": [ 300 | { 301 | "key": "token", 302 | "value": "{{access_token}}", 303 | "type": "string" 304 | } 305 | ] 306 | }, 307 | "method": "PUT", 308 | "header": [], 309 | "body": { 310 | "mode": "raw", 311 | "raw": "{\r\n \"firstName\": \"test2\",\r\n \"lastName\": \"test2\",\r\n \"username\": \"test2\",\r\n \"password\": \"test2123456\",\r\n \"isActive\": false,\r\n \"role\": \"editor\"\r\n}", 312 | "options": { 313 | "raw": { 314 | "language": "json" 315 | } 316 | } 317 | }, 318 | "url": { 319 | "raw": "http://localhost:5000/api/users/{{user_id}}", 320 | "protocol": "http", 321 | "host": [ 322 | "localhost" 323 | ], 324 | "port": "5000", 325 | "path": [ 326 | "api", 327 | "users", 328 | "{{user_id}}" 329 | ] 330 | } 331 | }, 332 | "response": [] 333 | }, 334 | { 335 | "name": "users/{{user_id}}", 336 | "event": [ 337 | { 338 | "listen": "test", 339 | "script": { 340 | "exec": [ 341 | "pm.test(\"Status code is 200\", function () {\r", 342 | " pm.response.to.have.status(200);\r", 343 | "});\r", 344 | "\r", 345 | "pm.test(\"Should get deleted user's id\", function () {\r", 346 | " const id = pm.environment.get(\"user_id\");\r", 347 | "\r", 348 | " pm.response.to.have.body(id);\r", 349 | "});" 350 | ], 351 | "type": "text/javascript" 352 | } 353 | } 354 | ], 355 | "request": { 356 | "auth": { 357 | "type": "bearer", 358 | "bearer": [ 359 | { 360 | "key": "token", 361 | "value": "{{access_token}}", 362 | "type": "string" 363 | } 364 | ] 365 | }, 366 | "method": "DELETE", 367 | "header": [], 368 | "url": { 369 | "raw": "http://localhost:5000/api/users/{{user_id}}", 370 | "protocol": "http", 371 | "host": [ 372 | "localhost" 373 | ], 374 | "port": "5000", 375 | "path": [ 376 | "api", 377 | "users", 378 | "{{user_id}}" 379 | ] 380 | } 381 | }, 382 | "response": [] 383 | }, 384 | { 385 | "name": "courses", 386 | "event": [ 387 | { 388 | "listen": "test", 389 | "script": { 390 | "exec": [ 391 | "pm.test(\"Status code is 201\", function () {\r", 392 | " pm.response.to.have.status(201);\r", 393 | " pm.environment.set(\"course_id\", pm.response.json().id);\r", 394 | "});\r", 395 | "\r", 396 | "pm.test(\"Should return created course\", function () {\r", 397 | " const jsonData = pm.response.json();\r", 398 | " const id = pm.environment.get(\"course_id\");\r", 399 | "\r", 400 | " pm.expect(jsonData.name).to.eql(\"test\");\r", 401 | " pm.expect(jsonData.description).to.eql(\"test\");\r", 402 | " pm.expect(jsonData.dateCreated).to.match(/\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d(?:\\.\\d+)?Z?/gm)\r", 403 | " pm.expect(jsonData.id).to.eql(id);\r", 404 | "});" 405 | ], 406 | "type": "text/javascript" 407 | } 408 | } 409 | ], 410 | "request": { 411 | "auth": { 412 | "type": "bearer", 413 | "bearer": [ 414 | { 415 | "key": "token", 416 | "value": "{{access_token}}", 417 | "type": "string" 418 | } 419 | ] 420 | }, 421 | "method": "POST", 422 | "header": [], 423 | "body": { 424 | "mode": "raw", 425 | "raw": "{\r\n \"name\": \"test\",\r\n \"description\": \"test\"\r\n}", 426 | "options": { 427 | "raw": { 428 | "language": "json" 429 | } 430 | } 431 | }, 432 | "url": { 433 | "raw": "http://localhost:5000/api/courses", 434 | "protocol": "http", 435 | "host": [ 436 | "localhost" 437 | ], 438 | "port": "5000", 439 | "path": [ 440 | "api", 441 | "courses" 442 | ] 443 | } 444 | }, 445 | "response": [] 446 | }, 447 | { 448 | "name": "courses", 449 | "event": [ 450 | { 451 | "listen": "test", 452 | "script": { 453 | "exec": [ 454 | "pm.test(\"Status code is 200\", function () {\r", 455 | " pm.response.to.have.status(200);\r", 456 | "});\r", 457 | "\r", 458 | "pm.test(\"Should get an array\", function () {\r", 459 | " var jsonData = pm.response.json();\r", 460 | " pm.expect(jsonData).to.be.an(\"array\");\r", 461 | "});" 462 | ], 463 | "type": "text/javascript" 464 | } 465 | } 466 | ], 467 | "request": { 468 | "auth": { 469 | "type": "bearer", 470 | "bearer": [ 471 | { 472 | "key": "token", 473 | "value": "{{access_token}}", 474 | "type": "string" 475 | } 476 | ] 477 | }, 478 | "method": "GET", 479 | "header": [], 480 | "url": { 481 | "raw": "http://localhost:5000/api/courses", 482 | "protocol": "http", 483 | "host": [ 484 | "localhost" 485 | ], 486 | "port": "5000", 487 | "path": [ 488 | "api", 489 | "courses" 490 | ] 491 | } 492 | }, 493 | "response": [] 494 | }, 495 | { 496 | "name": "courses/{{course_id}}", 497 | "event": [ 498 | { 499 | "listen": "test", 500 | "script": { 501 | "exec": [ 502 | "pm.test(\"Status code is 200\", function () {\r", 503 | " pm.response.to.have.status(200);\r", 504 | "});\r", 505 | "\r", 506 | "pm.test(\"Should get user with matching id\", function () {\r", 507 | " const jsonData = pm.response.json();\r", 508 | " const id = pm.environment.get(\"course_id\");\r", 509 | " \r", 510 | " pm.expect(jsonData.id).to.eql(id);\r", 511 | " pm.expect(jsonData.name).to.eql(\"test\");\r", 512 | " pm.expect(jsonData.description).to.eql(\"test\");\r", 513 | " pm.expect(jsonData.dateCreated).to.match(/\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d(?:\\.\\d+)?Z?/gm);\r", 514 | "});" 515 | ], 516 | "type": "text/javascript" 517 | } 518 | } 519 | ], 520 | "request": { 521 | "auth": { 522 | "type": "bearer", 523 | "bearer": [ 524 | { 525 | "key": "token", 526 | "value": "{{access_token}}", 527 | "type": "string" 528 | } 529 | ] 530 | }, 531 | "method": "GET", 532 | "header": [], 533 | "url": { 534 | "raw": "http://localhost:5000/api/courses/{{course_id}}", 535 | "protocol": "http", 536 | "host": [ 537 | "localhost" 538 | ], 539 | "port": "5000", 540 | "path": [ 541 | "api", 542 | "courses", 543 | "{{course_id}}" 544 | ] 545 | } 546 | }, 547 | "response": [] 548 | }, 549 | { 550 | "name": "courses/{{course_id}}", 551 | "event": [ 552 | { 553 | "listen": "test", 554 | "script": { 555 | "exec": [ 556 | "pm.test(\"Status code is 200\", function () {\r", 557 | " pm.response.to.have.status(200);\r", 558 | "});\r", 559 | "\r", 560 | "pm.test(\"Should return updated fields\", function () {\r", 561 | " const jsonData = pm.response.json();\r", 562 | " const id = pm.environment.get(\"course_id\");\r", 563 | "\r", 564 | " pm.expect(jsonData.id).to.eql(id);\r", 565 | " pm.expect(jsonData.name).to.eql(\"test2\");\r", 566 | " pm.expect(jsonData.description).to.eql(\"test2\");\r", 567 | "});" 568 | ], 569 | "type": "text/javascript" 570 | } 571 | } 572 | ], 573 | "request": { 574 | "auth": { 575 | "type": "bearer", 576 | "bearer": [ 577 | { 578 | "key": "token", 579 | "value": "{{access_token}}", 580 | "type": "string" 581 | } 582 | ] 583 | }, 584 | "method": "PUT", 585 | "header": [], 586 | "body": { 587 | "mode": "raw", 588 | "raw": "{\r\n \"name\": \"test2\",\r\n \"description\": \"test2\"\r\n}", 589 | "options": { 590 | "raw": { 591 | "language": "json" 592 | } 593 | } 594 | }, 595 | "url": { 596 | "raw": "http://localhost:5000/api/courses/{{course_id}}", 597 | "protocol": "http", 598 | "host": [ 599 | "localhost" 600 | ], 601 | "port": "5000", 602 | "path": [ 603 | "api", 604 | "courses", 605 | "{{course_id}}" 606 | ] 607 | } 608 | }, 609 | "response": [] 610 | }, 611 | { 612 | "name": "courses/{{course_id}}/contents", 613 | "event": [ 614 | { 615 | "listen": "test", 616 | "script": { 617 | "exec": [ 618 | "pm.test(\"Status code is 201\", function () {\r", 619 | " pm.response.to.have.status(201);\r", 620 | " pm.environment.set(\"content_id\", pm.response.json().id);\r", 621 | "});\r", 622 | "\r", 623 | "pm.test(\"Should return the created content\", function () {\r", 624 | " const jsonData = pm.response.json();\r", 625 | " const course_id = pm.environment.get(\"course_id\");\r", 626 | " const content_id = pm.environment.get(\"content_id\");\r", 627 | "\r", 628 | " pm.expect(jsonData.courseId).to.eql(course_id);\r", 629 | " pm.expect(jsonData.name).to.eql(\"test\");\r", 630 | " pm.expect(jsonData.description).to.eql(\"test\");\r", 631 | " pm.expect(jsonData.dateCreated).to.match(/\\d{4}-[01]\\d-[0-3]\\dT[0-2]\\d:[0-5]\\d:[0-5]\\d(?:\\.\\d+)?Z?/gm)\r", 632 | " pm.expect(jsonData.id).to.eql(content_id);\r", 633 | "});" 634 | ], 635 | "type": "text/javascript" 636 | } 637 | } 638 | ], 639 | "request": { 640 | "auth": { 641 | "type": "bearer", 642 | "bearer": [ 643 | { 644 | "key": "token", 645 | "value": "{{access_token}}", 646 | "type": "string" 647 | } 648 | ] 649 | }, 650 | "method": "POST", 651 | "header": [], 652 | "body": { 653 | "mode": "raw", 654 | "raw": "{\r\n \"name\": \"test\",\r\n \"description\": \"test\"\r\n}", 655 | "options": { 656 | "raw": { 657 | "language": "json" 658 | } 659 | } 660 | }, 661 | "url": { 662 | "raw": "http://localhost:5000/api/courses/{{course_id}}/contents", 663 | "protocol": "http", 664 | "host": [ 665 | "localhost" 666 | ], 667 | "port": "5000", 668 | "path": [ 669 | "api", 670 | "courses", 671 | "{{course_id}}", 672 | "contents" 673 | ] 674 | } 675 | }, 676 | "response": [] 677 | }, 678 | { 679 | "name": "courses/{{course_id}}/contents", 680 | "event": [ 681 | { 682 | "listen": "test", 683 | "script": { 684 | "exec": [ 685 | "pm.test(\"Status code is 200\", function () {\r", 686 | " pm.response.to.have.status(200);\r", 687 | "});\r", 688 | "\r", 689 | "pm.test(\"Should return an array\", function () {\r", 690 | " const jsonData = pm.response.json();\r", 691 | " pm.expect(jsonData).to.be.an(\"array\");\r", 692 | "});" 693 | ], 694 | "type": "text/javascript" 695 | } 696 | } 697 | ], 698 | "request": { 699 | "auth": { 700 | "type": "bearer", 701 | "bearer": [ 702 | { 703 | "key": "token", 704 | "value": "{{access_token}}", 705 | "type": "string" 706 | } 707 | ] 708 | }, 709 | "method": "GET", 710 | "header": [], 711 | "url": { 712 | "raw": "http://localhost:5000/api/courses/{{course_id}}/contents", 713 | "protocol": "http", 714 | "host": [ 715 | "localhost" 716 | ], 717 | "port": "5000", 718 | "path": [ 719 | "api", 720 | "courses", 721 | "{{course_id}}", 722 | "contents" 723 | ] 724 | } 725 | }, 726 | "response": [] 727 | }, 728 | { 729 | "name": "courses/{{course_id}}/contents/{{content_id}}", 730 | "event": [ 731 | { 732 | "listen": "test", 733 | "script": { 734 | "exec": [ 735 | "pm.test(\"Status code is 200\", function () {\r", 736 | " pm.response.to.have.status(200);\r", 737 | "});\r", 738 | "\r", 739 | "pm.test(\"Should return the updated values\", function () {\r", 740 | " const jsonData = pm.response.json();\r", 741 | " const id = pm.environment.get(\"content_id\");\r", 742 | " \r", 743 | " pm.expect(jsonData.id).to.eql(id);\r", 744 | " pm.expect(jsonData.name).to.eql(\"test2\");\r", 745 | " pm.expect(jsonData.description).to.eql(\"test2\");\r", 746 | "});" 747 | ], 748 | "type": "text/javascript" 749 | } 750 | } 751 | ], 752 | "request": { 753 | "auth": { 754 | "type": "bearer", 755 | "bearer": [ 756 | { 757 | "key": "token", 758 | "value": "{{access_token}}", 759 | "type": "string" 760 | } 761 | ] 762 | }, 763 | "method": "PUT", 764 | "header": [], 765 | "body": { 766 | "mode": "raw", 767 | "raw": "{\r\n \"name\": \"test2\",\r\n \"description\": \"test2\"\r\n}", 768 | "options": { 769 | "raw": { 770 | "language": "json" 771 | } 772 | } 773 | }, 774 | "url": { 775 | "raw": "http://localhost:5000/api/courses/{{course_id}}/contents/{{content_id}}", 776 | "protocol": "http", 777 | "host": [ 778 | "localhost" 779 | ], 780 | "port": "5000", 781 | "path": [ 782 | "api", 783 | "courses", 784 | "{{course_id}}", 785 | "contents", 786 | "{{content_id}}" 787 | ] 788 | } 789 | }, 790 | "response": [] 791 | }, 792 | { 793 | "name": "courses/{{course_id}}/contents/{{content_id}}", 794 | "event": [ 795 | { 796 | "listen": "test", 797 | "script": { 798 | "exec": [ 799 | "pm.test(\"Status code is 200\", function () {\r", 800 | " pm.response.to.have.status(200);\r", 801 | "});\r", 802 | "\r", 803 | "pm.test(\"Should return deleted content's id\", function () {\r", 804 | " const id = pm.environment.get(\"content_id\");\r", 805 | " pm.response.to.have.body(id);\r", 806 | "});" 807 | ], 808 | "type": "text/javascript" 809 | } 810 | } 811 | ], 812 | "request": { 813 | "auth": { 814 | "type": "bearer", 815 | "bearer": [ 816 | { 817 | "key": "token", 818 | "value": "{{access_token}}", 819 | "type": "string" 820 | } 821 | ] 822 | }, 823 | "method": "DELETE", 824 | "header": [], 825 | "url": { 826 | "raw": "http://localhost:5000/api/courses/{{course_id}}/contents/{{content_id}}", 827 | "protocol": "http", 828 | "host": [ 829 | "localhost" 830 | ], 831 | "port": "5000", 832 | "path": [ 833 | "api", 834 | "courses", 835 | "{{course_id}}", 836 | "contents", 837 | "{{content_id}}" 838 | ] 839 | } 840 | }, 841 | "response": [] 842 | }, 843 | { 844 | "name": "courses/{{course_id}}", 845 | "event": [ 846 | { 847 | "listen": "test", 848 | "script": { 849 | "exec": [ 850 | "pm.test(\"Status code is 200\", function () {\r", 851 | " pm.response.to.have.status(200);\r", 852 | "});\r", 853 | "\r", 854 | "pm.test(\"Should return deleted course's id\", function () {\r", 855 | " const id = pm.environment.get(\"course_id\");\r", 856 | " pm.response.to.have.body(id);\r", 857 | "});" 858 | ], 859 | "type": "text/javascript" 860 | } 861 | } 862 | ], 863 | "request": { 864 | "auth": { 865 | "type": "bearer", 866 | "bearer": [ 867 | { 868 | "key": "token", 869 | "value": "{{access_token}}", 870 | "type": "string" 871 | } 872 | ] 873 | }, 874 | "method": "DELETE", 875 | "header": [], 876 | "url": { 877 | "raw": "http://localhost:5000/api/courses/{{course_id}}", 878 | "protocol": "http", 879 | "host": [ 880 | "localhost" 881 | ], 882 | "port": "5000", 883 | "path": [ 884 | "api", 885 | "courses", 886 | "{{course_id}}" 887 | ] 888 | } 889 | }, 890 | "response": [] 891 | }, 892 | { 893 | "name": "stats", 894 | "event": [ 895 | { 896 | "listen": "test", 897 | "script": { 898 | "exec": [ 899 | "pm.test(\"Status code is 200\", function () {\r", 900 | " pm.response.to.have.status(200);\r", 901 | "});\r", 902 | "\r", 903 | "pm.test(\"Should return stats\", function () {\r", 904 | " const jsonData = pm.response.json();\r", 905 | " pm.expect(jsonData.numberOfUsers).to.be.a(\"number\");\r", 906 | " pm.expect(jsonData.numberOfContents).to.be.a(\"number\");\r", 907 | " pm.expect(jsonData.numberOfCourses).to.be.a(\"number\");\r", 908 | "});" 909 | ], 910 | "type": "text/javascript" 911 | } 912 | } 913 | ], 914 | "request": { 915 | "method": "GET", 916 | "header": [], 917 | "url": { 918 | "raw": "http://localhost:5000/api/stats", 919 | "protocol": "http", 920 | "host": [ 921 | "localhost" 922 | ], 923 | "port": "5000", 924 | "path": [ 925 | "api", 926 | "stats" 927 | ] 928 | } 929 | }, 930 | "response": [] 931 | }, 932 | { 933 | "name": "auth/logout", 934 | "event": [ 935 | { 936 | "listen": "test", 937 | "script": { 938 | "exec": [ 939 | "pm.test(\"Status code is 200\", function () {\r", 940 | " pm.response.to.have.status(200);\r", 941 | "});\r", 942 | "\r", 943 | "pm.test(\"Should return true\", function () {\r", 944 | " pm.response.to.have.body(\"true\");\r", 945 | "});" 946 | ], 947 | "type": "text/javascript" 948 | } 949 | } 950 | ], 951 | "request": { 952 | "auth": { 953 | "type": "bearer", 954 | "bearer": [ 955 | { 956 | "key": "token", 957 | "value": "{{access_token}}", 958 | "type": "string" 959 | } 960 | ] 961 | }, 962 | "method": "POST", 963 | "header": [], 964 | "url": { 965 | "raw": "http://localhost:5000/api/auth/logout", 966 | "protocol": "http", 967 | "host": [ 968 | "localhost" 969 | ], 970 | "port": "5000", 971 | "path": [ 972 | "api", 973 | "auth", 974 | "logout" 975 | ] 976 | } 977 | }, 978 | "response": [] 979 | } 980 | ], 981 | "event": [ 982 | { 983 | "listen": "prerequest", 984 | "script": { 985 | "type": "text/javascript", 986 | "exec": [ 987 | "" 988 | ] 989 | } 990 | }, 991 | { 992 | "listen": "test", 993 | "script": { 994 | "type": "text/javascript", 995 | "exec": [ 996 | "" 997 | ] 998 | } 999 | } 1000 | ] 1001 | } --------------------------------------------------------------------------------