├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ ├── deploy.yml │ └── node.js.yml ├── .gitignore ├── .mocharc.js ├── .prettierignore ├── .prettierrc ├── .vscode ├── extensions.json ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── angular-src ├── .gitignore ├── .npmrc ├── angular.json ├── deploy.sh ├── e2e │ ├── protractor.conf.js │ ├── src │ │ ├── app.e2e-spec.ts │ │ └── app.po.ts │ └── tsconfig.e2e.json ├── local.js ├── ng-toolkit.json ├── package-lock.json ├── package.json ├── server.ts ├── server.ts.bak ├── src │ ├── .browserslistrc │ ├── app │ │ ├── app.component.html │ │ ├── app.component.scss │ │ ├── app.component.spec.ts │ │ ├── app.component.ts │ │ ├── app.module.ts │ │ ├── app.server.module.ts │ │ ├── components │ │ │ ├── example-page │ │ │ │ ├── example-page.component.html │ │ │ │ ├── example-page.component.scss │ │ │ │ ├── example-page.component.spec.ts │ │ │ │ └── example-page.component.ts │ │ │ ├── home │ │ │ │ ├── home.component.html │ │ │ │ ├── home.component.scss │ │ │ │ ├── home.component.spec.ts │ │ │ │ └── home.component.ts │ │ │ ├── login │ │ │ │ ├── login.component.html │ │ │ │ ├── login.component.scss │ │ │ │ ├── login.component.spec.ts │ │ │ │ └── login.component.ts │ │ │ ├── register │ │ │ │ ├── register.component.html │ │ │ │ ├── register.component.scss │ │ │ │ ├── register.component.spec.ts │ │ │ │ └── register.component.ts │ │ │ └── user-page │ │ │ │ ├── user-page.component.html │ │ │ │ ├── user-page.component.scss │ │ │ │ ├── user-page.component.spec.ts │ │ │ │ └── user-page.component.ts │ │ ├── core │ │ │ ├── app-http.interceptor.ts │ │ │ ├── components │ │ │ │ ├── footer │ │ │ │ │ ├── footer.component.html │ │ │ │ │ ├── footer.component.scss │ │ │ │ │ ├── footer.component.spec.ts │ │ │ │ │ └── footer.component.ts │ │ │ │ └── header │ │ │ │ │ ├── header.component.html │ │ │ │ │ ├── header.component.scss │ │ │ │ │ ├── header.component.spec.ts │ │ │ │ │ └── header.component.ts │ │ │ ├── core.module.spec.ts │ │ │ ├── core.module.ts │ │ │ ├── services │ │ │ │ ├── api.service.ts │ │ │ │ ├── app.service.ts │ │ │ │ ├── auth-guard.service.ts │ │ │ │ ├── auth.service.spec.ts │ │ │ │ ├── auth.service.ts │ │ │ │ ├── index.ts │ │ │ │ └── requests.service.ts │ │ │ └── universal-relative.interceptor.ts │ │ ├── shared │ │ │ ├── directives │ │ │ │ ├── form-validator.directive.ts │ │ │ │ └── index.ts │ │ │ ├── shared.module.spec.ts │ │ │ └── shared.module.ts │ │ ├── social-login │ │ │ ├── social-login-button │ │ │ │ ├── social-login-button.component.css │ │ │ │ ├── social-login-button.component.html │ │ │ │ ├── social-login-button.component.spec.ts │ │ │ │ └── social-login-button.component.ts │ │ │ ├── social-login.module.ts │ │ │ └── social-login.service.ts │ │ └── testing │ │ │ ├── mock │ │ │ ├── api.service.mock.ts │ │ │ └── core.module.mock.ts │ │ │ └── test_utils.ts │ ├── assets │ │ └── .gitkeep │ ├── environments │ │ ├── environment.prod.ts │ │ └── environment.ts │ ├── express.tokens.ts │ ├── favicon.ico │ ├── index.html │ ├── karma.conf.js │ ├── main.server.ts │ ├── main.ts │ ├── polyfills.ts │ ├── styles.scss │ ├── test.ts │ ├── tsconfig.app.json │ ├── tsconfig.server.json │ └── tsconfig.spec.json ├── tsconfig.json └── tslint.json ├── ansible ├── ansible.cfg ├── inventory ├── main.yml └── roles │ ├── app │ └── tasks │ │ └── main.yml │ ├── docker-compose │ ├── tasks │ │ ├── install_docker_with_compose.yml │ │ └── main.yml │ └── vars │ │ └── main.yml │ ├── install-common-deps │ ├── tasks │ │ └── main.yml │ └── vars │ │ └── main.yml │ └── python37 │ ├── tasks │ └── main.yml │ └── vars │ └── main.yml ├── certs ├── fullchain.pem └── privkey.pem ├── docker-compose.yml ├── nest-cli.json ├── nginx.conf ├── package-lock.json ├── package.json ├── rest.http ├── scripts ├── build.sh ├── copy-essentials.sh ├── get-version.js └── install_all.sh ├── shared ├── index.ts ├── models │ ├── index.ts │ ├── login-response.ts │ ├── user-profile.model.ts │ └── user-profile.ts ├── shared-utils.ts └── testing │ ├── mock │ └── user.mock.ts │ └── shared_test_utils.ts ├── src ├── app.module.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── jwt.strategy.ts │ ├── request-user.decorator.ts │ ├── roles.decorators.ts │ └── user-auth-guard.ts ├── config-manager │ ├── config-manager.module.ts │ ├── config-parser.spec.ts │ ├── config-parser.ts │ └── config.service.ts ├── config.ts ├── config │ ├── default.json │ ├── development.json │ ├── production.json │ └── test.json ├── controllers │ ├── api.controller.spec.ts │ └── api.controller.ts ├── database │ ├── database-connection-manager.ts │ ├── database.module.ts │ ├── database.service.ts │ └── models │ │ ├── index.ts │ │ └── user-profile.db.model.ts ├── forms │ ├── index.ts │ ├── register.form.spec.ts │ └── register.form.ts ├── main.ts ├── misc │ ├── angular-mounter.ts │ ├── env-config-loader.spec.ts │ ├── env-config-loader.ts │ ├── index.ts │ └── utils.ts ├── models │ ├── app-config.ts │ ├── app-req-res.ts │ └── index.ts ├── social-auth │ ├── social-auth.controller.ts │ ├── social-auth.models.ts │ ├── social-auth.module.ts │ └── social-auth.service.ts └── testing │ ├── services │ └── database.test.service.ts │ ├── test_db_setup.ts │ └── test_utils.ts ├── tsconfig.build.json └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/charts 16 | **/docker-compose* 17 | **/Dockerfile* 18 | **/node_modules 19 | **/npm-debug.log 20 | **/obj 21 | **/secrets.dev.yaml 22 | **/values.dev.yaml 23 | **/node_modules 24 | **/dist 25 | README.md -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | # Common variables we can replace using the shell 2 | NODE_ENV 3 | WEB_CONF_USE_SSR 4 | 5 | WEB_CONF_DB_URI=mongodb://root:root@db:27017/?authSource=admin 6 | WEB_CONF_LOGS_DIR=/logs 7 | WEB_CONF_ANGULAR_MOUNT=true 8 | 9 | # We configure the DB to allow the access from the web interface using compose 10 | MONGO_INITDB_ROOT_USERNAME=root 11 | MONGO_INITDB_ROOT_PASSWORD=root 12 | MONGO_INITDB_DATABASE=admin -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | # don't lint nyc coverage output 6 | coverage 7 | # don't lint eslint conf file 8 | .eslintrc.js 9 | 10 | # don't lint specific angular config files 11 | angular-src/e2e/protractor.conf.js 12 | angular-src/src/karma.conf.js 13 | angular-src/local.js -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | plugins: ['@typescript-eslint'], 4 | extends: [ 5 | 'eslint:recommended', 6 | 'plugin:@typescript-eslint/recommended', 7 | 'prettier/@typescript-eslint', 8 | ], 9 | root: true, 10 | env: { 11 | node: true, 12 | jest: true, 13 | }, 14 | rules: {}, 15 | }; 16 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto eol=lf -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Build & Deploy 2 | 3 | on: 4 | push: 5 | branches: [master] 6 | 7 | jobs: 8 | deploy: 9 | runs-on: ubuntu-latest 10 | 11 | strategy: 12 | matrix: 13 | node-version: [14.x] 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | - name: Build docker image 18 | run: | 19 | # Build the docker image 20 | docker-compose build web 21 | 22 | # Get the tag 23 | version=$(node ./scripts/get-version.js) 24 | 25 | # Now tag the existing image 26 | docker tag webapp:latest $version 27 | 28 | # Now save the image 29 | docker save -o webapp.tar webapp:latest 30 | 31 | - name: Copy the docker image to the server 32 | uses: appleboy/scp-action@master 33 | with: 34 | host: ${{secrets.SSH_HOST}} 35 | key: ${{secrets.SSH_KEY}} 36 | username: ${{secrets.SSH_USERNAME}} 37 | source: webapp.tar 38 | target: /tmp/ 39 | 40 | - name: Load the docker image and reload the app 41 | uses: appleboy/ssh-action@v0.1.2 42 | with: 43 | host: ${{secrets.SSH_HOST}} 44 | key: ${{secrets.SSH_KEY}} 45 | username: ${{secrets.SSH_USERNAME}} 46 | 47 | script: | 48 | docker load -i /tmp/webapp.tar 49 | rm /tmp/webapp.tar 50 | cd ~/app 51 | docker-compose rm -f web || true 52 | docker-compose up -d web 53 | -------------------------------------------------------------------------------- /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: ['master'] 9 | pull_request: 10 | branches: ['**'] 11 | 12 | jobs: 13 | test: 14 | runs-on: ubuntu-latest 15 | 16 | services: 17 | mongodb: 18 | image: mongo:latest 19 | env: 20 | MONGO_INITDB_ROOT_USERNAME: test 21 | MONGO_INITDB_ROOT_PASSWORD: test 22 | MONGO_INITDB_DATABASE: admin 23 | ports: 24 | - 27019:27017 25 | strategy: 26 | matrix: 27 | node-version: [14.x] 28 | 29 | steps: 30 | - uses: actions/checkout@v2 31 | - name: Use Node.js ${{ matrix.node-version }} 32 | uses: actions/setup-node@v1 33 | with: 34 | node-version: ${{ matrix.node-version }} 35 | # Install backend dependencies 36 | - run: npm ci --ignore-scripts 37 | # Forces mongo-memory-server to be installed 38 | - run: npm install mongodb-memory-server 39 | # Run backend tests 40 | - run: npm run test:ci 41 | # Install frontend dependencies 42 | - run: cd angular-src && npm ci --ignore-scripts 43 | # Run frontend tests 44 | - run: npm run ng:test:ci 45 | - name: Generate backend test report 46 | uses: dorny/test-reporter@v1 47 | if: success() || failure() # run this step even if previous step failed 48 | with: 49 | name: Backend Tests Report # Name of the check run which will be created 50 | path: test-results.json # Path to test results 51 | reporter: mocha-json # Format of test results 52 | -------------------------------------------------------------------------------- /.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 | test-results.json 13 | 14 | # OS 15 | .DS_Store 16 | 17 | # Tests 18 | /coverage 19 | /.nyc_output 20 | 21 | # IDEs and editors 22 | /.idea 23 | .project 24 | .classpath 25 | .c9/ 26 | *.launch 27 | .settings/ 28 | *.sublime-workspace 29 | 30 | # IDE - VSCode 31 | .vscode/* 32 | !.vscode/settings.json 33 | !.vscode/tasks.json 34 | !.vscode/launch.json 35 | !.vscode/extensions.json 36 | 37 | globalConfig.json -------------------------------------------------------------------------------- /.mocharc.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | // Here's a JavaScript-based config file. 4 | // If you need conditional logic, you might want to use this type of config. 5 | // Otherwise, JSON or YAML is recommended. 6 | // For options look for: https://mochajs.org/#command-line-usage 7 | // And: https://mochajs.org/#configuring-mocha-nodejs 8 | 9 | module.exports = { 10 | diff: true, 11 | extension: ['ts'], 12 | package: './package.json', 13 | reporter: 'spec', 14 | slow: 75, 15 | timeout: 4000, 16 | ui: 'bdd', 17 | exit: true, 18 | fullTrace: true, 19 | require: ['ts-node/register', 'tsconfig-paths/register'], 20 | spec: [ 21 | 'src/**/*.spec.ts', 22 | 'shared/**/*.spec.ts', 23 | ], 24 | }; 25 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | # don't ever lint node_modules 2 | node_modules 3 | # don't lint build output (make sure it's set to your correct build folder name) 4 | dist 5 | # don't lint nyc coverage output 6 | coverage 7 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "compulim.vscode-mocha", 4 | "spoonscen.es6-mocha-snippets", 5 | "hbenl.vscode-mocha-test-adapter" 6 | ] 7 | } -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [{ 7 | "type": "node", 8 | "request": "launch", 9 | "name": "Debug NestJS", 10 | "args": [ 11 | "${workspaceFolder}/src/main.ts" 12 | ], 13 | "runtimeArgs": [ 14 | "--nolazy", 15 | "-r", 16 | "ts-node/register", 17 | "-r", 18 | "tsconfig-paths/register" 19 | ], 20 | "sourceMaps": true, 21 | "cwd": "${workspaceRoot}", 22 | "console": "integratedTerminal", 23 | "protocol": "inspector", 24 | "preLaunchTask": "npm: copy:essentials" 25 | }, 26 | { 27 | "name": "Debug Mocha Tests", 28 | "type": "node", 29 | "request": "launch", 30 | "runtimeArgs": [ 31 | "--inspect-brk", 32 | "${workspaceRoot}/node_modules/.bin/mocha" 33 | ], 34 | "env": { 35 | "NODE_ENV": "test-verbose" 36 | }, 37 | "console": "integratedTerminal", 38 | "internalConsoleOptions": "neverOpen", 39 | "port": 9229 40 | }, 41 | { 42 | "name": "Debug Production", 43 | "type": "node", 44 | "request": "launch", 45 | "cwd": "${workspaceRoot}", 46 | "runtimeExecutable": "npm", 47 | "runtimeArgs": ["run-script", "start:debug"], 48 | "env": { 49 | "NODE_ENV": "production" 50 | }, 51 | "skipFiles": [ 52 | "/**" 53 | ], 54 | }, 55 | ] 56 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mochaExplorer.files": "src/**/*.spec.ts", 3 | "mochaExplorer.ignore": "angular-src/**/*", 4 | "mochaExplorer.env": { 5 | "NODE_ENV": "test" 6 | }, 7 | "mochaExplorer.pruneFiles": true, 8 | "files.exclude": { 9 | "**/.git": true, 10 | "**/.svn": true, 11 | "**/.hg": true, 12 | "**/CVS": true, 13 | "**/.DS_Store": true, 14 | // "**/dist**": true 15 | }, 16 | "files.watcherExclude": { 17 | "**/.git/objects/**": true, 18 | "**/.git/subtree-cache/**": true, 19 | "**/node_modules/*/**": true, 20 | "**/.hg/store/**": true, 21 | "**/dist/**": true 22 | }, 23 | "editor.codeActionsOnSave": { 24 | "source.fixAll.eslint": "explicit" 25 | }, 26 | "eslint.validate": ["typescript", "javascript"] 27 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Create global variables to be used later 2 | ARG workdir=/app 3 | ARG NODE_ENV=development 4 | 5 | FROM node:20.15-alpine3.20 as base 6 | 7 | # Configure environment variables 8 | ENV NODE_ENV ${NODE_ENV} 9 | 10 | FROM base as build 11 | 12 | ARG workdir 13 | ARG NODE_ENV 14 | 15 | # Compile code in /compile directory 16 | WORKDIR /compile 17 | 18 | # Add bash support to alpine 19 | # Read this guide for more info: https://www.cyberciti.biz/faq/alpine-linux-install-bash-using-apk-command/ 20 | RUN echo "NODE_ENV for build was set to: ${NODE_ENV}, starting build..." \ 21 | && apk add --no-cache bash python3 make gcc g++ build-base python3-dev py3-setuptools 22 | 23 | # Copy package.json and package-lock.json files for node_modules installation 24 | # This allows caching take place 25 | COPY ./package.json ./package.json 26 | COPY ./package-lock.json ./package-lock.json 27 | COPY ./angular-src/package.json ./angular-src/package.json 28 | COPY ./angular-src/package-lock.json ./angular-src/package-lock.json 29 | 30 | # Copy the scripts directory 31 | COPY ./scripts ./scripts 32 | 33 | # Give permissions to all of the scripts, and install all dependencies using the script 34 | RUN chmod +x ./scripts/* && npm run install:all 35 | 36 | # Copy all of the required leftover-files 37 | COPY . . 38 | 39 | # Build web and create a distribution 40 | RUN npm run build \ 41 | # Copy the files required to run to the workdir 42 | && mkdir ${workdir} && cp -Rf ./dist ${workdir} \ 43 | # Before we copy node modules, remove all dev modules 44 | && npm prune --production \ 45 | && cd ./angular-src && npm prune --production && cd ../ \ 46 | # Copy required node modules 47 | && cp -Rf ./node_modules ${workdir} \ 48 | && cp -Rf ./angular-src/node_modules/* ${workdir}/node_modules/ \ 49 | && cp ./package.json ${workdir} \ 50 | # Remove source files, and delete bash 51 | && rm -Rf /compile \ 52 | && apk del bash build-base python3-dev py3-setuptools --purge 53 | 54 | # Create clean image for distribution 55 | FROM base 56 | 57 | # Change the work dir to the directory we are actually running the code 58 | WORKDIR /app 59 | 60 | # Copy distribution files 61 | COPY --from=build /app . 62 | 63 | # Expose the port required for web (http and https) 64 | EXPOSE 80 443 3000 65 | 66 | # Give the node user permissions to access the /app 67 | RUN chown -R node /app 68 | USER node 69 | 70 | # Start the built distribution 71 | CMD [ "npm", "start" ] 72 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Shynet 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /angular-src/.gitignore: -------------------------------------------------------------------------------- 1 | # See http://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # compiled output 4 | /dist 5 | /tmp 6 | /out-tsc 7 | 8 | # dependencies 9 | /node_modules 10 | 11 | # IDEs and editors 12 | /.idea 13 | .project 14 | .classpath 15 | .c9/ 16 | *.launch 17 | .settings/ 18 | *.sublime-workspace 19 | 20 | # IDE - VSCode 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # misc 28 | /.angular/cache 29 | /.sass-cache 30 | /connect.lock 31 | /coverage 32 | /libpeerconnection.log 33 | npm-debug.log 34 | yarn-error.log 35 | testem.log 36 | /typings 37 | 38 | # System Files 39 | .DS_Store 40 | Thumbs.db -------------------------------------------------------------------------------- /angular-src/.npmrc: -------------------------------------------------------------------------------- 1 | legacy-peer-deps=true -------------------------------------------------------------------------------- /angular-src/angular.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/@angular/cli/lib/config/schema.json", 3 | "version": 1, 4 | "newProjectRoot": "projects", 5 | "projects": { 6 | "angular-src": { 7 | "root": "", 8 | "sourceRoot": "src", 9 | "projectType": "application", 10 | "prefix": "app", 11 | "schematics": {}, 12 | "architect": { 13 | "build": { 14 | "builder": "@angular-devkit/build-angular:browser", 15 | "options": { 16 | "outputPath": "dist/browser", 17 | "index": "src/index.html", 18 | "main": "src/main.ts", 19 | "polyfills": "src/polyfills.ts", 20 | "tsConfig": "src/tsconfig.app.json", 21 | "allowedCommonJsDependencies": [ 22 | "validator" 23 | ], 24 | "assets": [ 25 | "src/favicon.ico", 26 | "src/assets" 27 | ], 28 | "styles": [ 29 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 30 | "node_modules/roboto-fontface/css/roboto/roboto-fontface.css", 31 | "node_modules/bootstrap-social/bootstrap-social.css", 32 | "node_modules/ngx-toastr/toastr.css", 33 | "node_modules/@fortawesome/fontawesome-free/css/all.css", 34 | "src/styles.scss" 35 | ], 36 | "scripts": [ 37 | "node_modules/jquery/dist/jquery.min.js", 38 | "node_modules/tether/dist/js/tether.min.js", 39 | "node_modules/popper.js/dist/umd/popper.min.js", 40 | "node_modules/bootstrap/dist/js/bootstrap.min.js" 41 | ], 42 | "vendorChunk": true, 43 | "extractLicenses": false, 44 | "buildOptimizer": false, 45 | "sourceMap": true, 46 | "optimization": false, 47 | "namedChunks": true 48 | }, 49 | "configurations": { 50 | "development": { 51 | "budgets": [ 52 | { 53 | "type": "anyComponentStyle", 54 | "maximumWarning": "6kb" 55 | } 56 | ], 57 | "optimization": true, 58 | "outputHashing": "all", 59 | "sourceMap": false, 60 | "namedChunks": false, 61 | "extractLicenses": true, 62 | "vendorChunk": false, 63 | "buildOptimizer": true 64 | }, 65 | "production": { 66 | "budgets": [ 67 | { 68 | "type": "anyComponentStyle", 69 | "maximumWarning": "6kb" 70 | } 71 | ], 72 | "fileReplacements": [ 73 | { 74 | "replace": "src/environments/environment.ts", 75 | "with": "src/environments/environment.prod.ts" 76 | } 77 | ], 78 | "optimization": true, 79 | "outputHashing": "all", 80 | "sourceMap": false, 81 | "namedChunks": false, 82 | "extractLicenses": true, 83 | "vendorChunk": false, 84 | "buildOptimizer": true 85 | } 86 | }, 87 | "defaultConfiguration": "" 88 | }, 89 | "serve": { 90 | "builder": "@angular-devkit/build-angular:dev-server", 91 | "options": { 92 | "buildTarget": "angular-src:build" 93 | }, 94 | "configurations": { 95 | "production": { 96 | "buildTarget": "angular-src:build:production" 97 | } 98 | } 99 | }, 100 | "extract-i18n": { 101 | "builder": "@angular-devkit/build-angular:extract-i18n", 102 | "options": { 103 | "buildTarget": "angular-src:build" 104 | } 105 | }, 106 | "test": { 107 | "builder": "@angular-devkit/build-angular:karma", 108 | "options": { 109 | "main": "src/test.ts", 110 | "polyfills": "src/polyfills.ts", 111 | "tsConfig": "src/tsconfig.spec.json", 112 | "karmaConfig": "src/karma.conf.js", 113 | "styles": [ 114 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 115 | "node_modules/roboto-fontface/css/roboto/roboto-fontface.css", 116 | "node_modules/bootstrap-social/bootstrap-social.css", 117 | "node_modules/ngx-toastr/toastr.css", 118 | "node_modules/@fortawesome/fontawesome-free/css/all.css", 119 | "src/styles.scss" 120 | ], 121 | "scripts": [ 122 | "node_modules/jquery/dist/jquery.min.js", 123 | "node_modules/tether/dist/js/tether.min.js", 124 | "node_modules/popper.js/dist/umd/popper.min.js", 125 | "node_modules/bootstrap/dist/js/bootstrap.min.js" 126 | ], 127 | "assets": [ 128 | "src/favicon.ico", 129 | "src/assets" 130 | ] 131 | } 132 | }, 133 | "server": { 134 | "builder": "@angular-devkit/build-angular:server", 135 | "options": { 136 | "outputPath": "dist/server", 137 | "main": "server.ts", 138 | "tsConfig": "src/tsconfig.server.json", 139 | "sourceMap": true, 140 | "optimization": false 141 | }, 142 | "configurations": { 143 | "production": { 144 | "fileReplacements": [{ 145 | "replace": "src/environments/environment.ts", 146 | "with": "src/environments/environment.prod.ts" 147 | }], 148 | "optimization": true 149 | } 150 | }, 151 | "defaultConfiguration": "" 152 | }, 153 | "serve-ssr": { 154 | "builder": "@angular-devkit/build-angular:ssr-dev-server", 155 | "options": { 156 | "browserTarget": "angular-src:build", 157 | "serverTarget": "angular-src:server" 158 | }, 159 | "configurations": { 160 | "production": { 161 | "browserTarget": "angular-src:build:production", 162 | "serverTarget": "angular-src:server:production" 163 | } 164 | } 165 | }, 166 | "prerender": { 167 | "builder": "@angular-devkit/build-angular:prerender", 168 | "options": { 169 | "browserTarget": "angular-src:build:production", 170 | "serverTarget": "angular-src:server:production", 171 | "routes": [ 172 | "/" 173 | ] 174 | }, 175 | "configurations": { 176 | "production": {} 177 | } 178 | } 179 | } 180 | }, 181 | "angular-src-e2e": { 182 | "root": "e2e/", 183 | "projectType": "application", 184 | "architect": { 185 | "e2e": { 186 | "builder": "@angular-devkit/build-angular:protractor", 187 | "options": { 188 | "protractorConfig": "e2e/protractor.conf.js", 189 | "devServerTarget": "angular-src:serve" 190 | }, 191 | "configurations": { 192 | "production": { 193 | "devServerTarget": "angular-src:serve:production" 194 | } 195 | } 196 | } 197 | } 198 | } 199 | }, 200 | "schematics": { 201 | "@schematics/angular:component": { 202 | "style": "scss" 203 | } 204 | }, 205 | "cli": { 206 | "analytics": false 207 | } 208 | } -------------------------------------------------------------------------------- /angular-src/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Build the docker image 4 | docker-compose build web 5 | 6 | # Stop the running container, if failed continue 7 | docker-compose stop web || true 8 | 9 | # Get the tag 10 | version=${node ./scripts/get-version.js} 11 | 12 | # Now tag the existing image 13 | docker tag app:latest $version 14 | 15 | # Now run the new container 16 | docker-compose up -d web -------------------------------------------------------------------------------- /angular-src/e2e/protractor.conf.js: -------------------------------------------------------------------------------- 1 | // Protractor configuration file, see link for more information 2 | // https://github.com/angular/protractor/blob/master/lib/config.ts 3 | 4 | const { SpecReporter } = require('jasmine-spec-reporter'); 5 | 6 | exports.config = { 7 | allScriptsTimeout: 11000, 8 | specs: ['./src/**/*.e2e-spec.ts'], 9 | capabilities: { 10 | browserName: 'chrome', 11 | }, 12 | directConnect: true, 13 | baseUrl: 'http://localhost:4200/', 14 | framework: 'jasmine', 15 | jasmineNodeOpts: { 16 | showColors: true, 17 | defaultTimeoutInterval: 30000, 18 | print: function () {}, 19 | }, 20 | onPrepare() { 21 | require('ts-node').register({ 22 | project: require('path').join(__dirname, './tsconfig.e2e.json'), 23 | }); 24 | jasmine 25 | .getEnv() 26 | .addReporter(new SpecReporter({ spec: { displayStacktrace: true } })); 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /angular-src/e2e/src/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { AppPage } from './app.po'; 2 | 3 | describe('workspace-project App', () => { 4 | let page: AppPage; 5 | 6 | beforeEach(() => { 7 | page = new AppPage(); 8 | }); 9 | 10 | it('should display welcome message', () => { 11 | page.navigateTo(); 12 | expect(page.getParagraphText()).toEqual('Welcome to angular-src!'); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /angular-src/e2e/src/app.po.ts: -------------------------------------------------------------------------------- 1 | import { browser, by, element } from 'protractor'; 2 | 3 | export class AppPage { 4 | navigateTo(): Promise { 5 | return browser.get('/') as Promise; 6 | } 7 | 8 | getParagraphText(): Promise { 9 | return element(by.css('app-root h1')).getText() as Promise; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /angular-src/e2e/tsconfig.e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "module": "commonjs", 6 | "target": "es5", 7 | "types": [ 8 | "jasmine", 9 | "jasminewd2", 10 | "node" 11 | ] 12 | } 13 | } -------------------------------------------------------------------------------- /angular-src/local.js: -------------------------------------------------------------------------------- 1 | // generated by @ng-toolkit/universal 2 | const port = process.env.PORT || 8080; 3 | 4 | const server = require('./dist/server'); 5 | 6 | server.app.listen(port, () => { 7 | console.log("Listening on: http://localhost:" + port); 8 | }); 9 | -------------------------------------------------------------------------------- /angular-src/ng-toolkit.json: -------------------------------------------------------------------------------- 1 | { 2 | "universal": { 3 | "skipInstall": false, 4 | "directory": ".", 5 | "project": "angular-src" 6 | } 7 | } -------------------------------------------------------------------------------- /angular-src/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "angular-src", 3 | "version": "0.6.1", 4 | "scripts": { 5 | "ng": "ng", 6 | "start": "ng serve", 7 | "build": "ng build", 8 | "test": "ng test", 9 | "lint": "ng lint", 10 | "e2e": "ng e2e", 11 | "build:server:prod": "ng run angular-src:server && webpack --config webpack.server.config.js --progress --colors", 12 | "build:browser:prod": "ng build --configuration production", 13 | "build:prod": "npm run build:server:prod && npm run build:browser:prod", 14 | "server": "node local.js", 15 | "dev:ssr": "ng run angular-src:serve-ssr", 16 | "serve:ssr": "node dist/server/main.js", 17 | "build:ssr": "ng build --configuration production && ng run angular-src:server:production", 18 | "prerender": "ng run angular-src:prerender" 19 | }, 20 | "private": true, 21 | "dependencies": { 22 | "@angular/animations": "^17.2.4", 23 | "@angular/common": "^17.2.4", 24 | "@angular/compiler": "^17.2.4", 25 | "@angular/core": "^17.2.4", 26 | "@angular/forms": "^17.2.4", 27 | "@angular/platform-browser": "^17.2.4", 28 | "@angular/platform-browser-dynamic": "^17.2.4", 29 | "@angular/platform-server": "^17.2.4", 30 | "@angular/router": "^17.2.4", 31 | "@angular/ssr": "^17.2.3", 32 | "@fortawesome/fontawesome-free": "^5.14.0", 33 | "@ngx-loading-bar/core": "^6.0.2", 34 | "angularx-social-login": "^4.1.0", 35 | "bootstrap": "^4.5.2", 36 | "bootstrap-social": "^5.1.1", 37 | "class-transformer": "^0.2.3", 38 | "class-transformer-validator": "^0.8.0", 39 | "class-validator": "^0.12.2", 40 | "cors": "~2.8.4", 41 | "express": "^4.15.2", 42 | "jquery": "^3.5.1", 43 | "ngx-cookie": "^6.0.1", 44 | "ngx-toastr": "^18.0.0", 45 | "popper.js": "^1.16.1", 46 | "roboto-fontface": "^0.10.0", 47 | "rxjs": "^6.6.2", 48 | "tether": "^1.4.7", 49 | "ts-loader": "6.2.1", 50 | "tslib": "^2.0.0", 51 | "zone.js": "~0.14.4" 52 | }, 53 | "devDependencies": { 54 | "@angular-devkit/build-angular": "^17.2.3", 55 | "@angular/cli": "^17.2.3", 56 | "@angular/compiler-cli": "^17.2.4", 57 | "@angular/language-service": "^17.2.4", 58 | "@types/bootstrap": "^4.5.0", 59 | "@types/express": "^4.17.7", 60 | "@types/faker": "^4.1.12", 61 | "@types/jasmine": "~3.6.0", 62 | "@types/jasminewd2": "~2.0.8", 63 | "@types/jquery": "3.3.29", 64 | "@types/node": "^13.13.15", 65 | "browser-sync": "^3.0.0", 66 | "codelyzer": "^6.0.0", 67 | "faker": "^4.1.0", 68 | "jasmine-core": "~3.6.0", 69 | "jasmine-spec-reporter": "~5.0.0", 70 | "karma": "~6.4.0", 71 | "karma-chrome-launcher": "~3.1.0", 72 | "karma-coverage-istanbul-reporter": "~3.0.2", 73 | "karma-jasmine": "~4.0.0", 74 | "karma-jasmine-html-reporter": "^1.5.0", 75 | "karma-junit-reporter": "^2.0.1", 76 | "karma-mocha-reporter": "^2.2.5", 77 | "node-sass": "^8.0.0", 78 | "protractor": "~7.0.0", 79 | "ts-node": "~8.6.2", 80 | "tslint": "~6.1.0", 81 | "typescript": "5.2", 82 | "webpack-cli": "^3.3.12" 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /angular-src/server.ts: -------------------------------------------------------------------------------- 1 | 2 | import 'zone.js/node'; 3 | 4 | import * as express from 'express'; 5 | import * as fs from 'fs'; 6 | import * as path from 'path'; 7 | 8 | import { APP_BASE_HREF } from '@angular/common'; 9 | import { CommonEngine } from '@angular/ssr'; 10 | 11 | import { REQUEST, RESPONSE } from './src/express.tokens'; 12 | import bootstrap from './src/main.server'; 13 | import AppServerModule from './src/main.server'; 14 | 15 | // The Express app is exported so that it can be used by serverless Functions. 16 | export function app(): express.Express { 17 | const server = express(); 18 | const distFolder = path.join(process.cwd(), 'dist/browser'); 19 | const indexHtml = fs.existsSync(path.join(distFolder, 'index.original.html')) 20 | ? path.join(distFolder, 'index.original.html') 21 | : path.join(distFolder, 'index.html'); 22 | 23 | const commonEngine = new CommonEngine(); 24 | 25 | server.set('view engine', 'html'); 26 | server.set('views', distFolder); 27 | 28 | // Example Express Rest API endpoints 29 | // server.get('/api/**', (req, res) => { }); 30 | // Serve static files from /browser 31 | server.get('*.*', express.static(distFolder, { 32 | maxAge: '1y' 33 | })); 34 | 35 | // All regular routes use the Angular engine 36 | server.get('*', (req, res, next) => { 37 | const { protocol, originalUrl, baseUrl, headers } = req; 38 | 39 | commonEngine 40 | .render({ 41 | bootstrap: AppServerModule, 42 | documentFilePath: indexHtml, 43 | url: `${protocol}://${headers.host}${originalUrl}`, 44 | publicPath: distFolder, 45 | providers: [ 46 | { provide: APP_BASE_HREF, useValue: baseUrl }, 47 | { provide: RESPONSE, useValue: res }, 48 | { provide: REQUEST, useValue: req }, 49 | ], 50 | }) 51 | .then(html => res.send(html)) 52 | .catch(err => next(err)); 53 | }); 54 | 55 | return server; 56 | } 57 | 58 | function run(): void { 59 | const port = process.env['PORT'] || 4000; 60 | 61 | // Start up the Node server 62 | const server = app(); 63 | server.listen(port, () => { 64 | console.log(`Node Express server listening on http://localhost:${port}`); 65 | }); 66 | } 67 | 68 | // Webpack will replace 'require' with '__webpack_require__' 69 | // '__non_webpack_require__' is a proxy to Node 'require' 70 | // The below code is to ensure that the server is run only when not requiring the bundle. 71 | declare const __non_webpack_require__: NodeRequire; 72 | const mainModule = __non_webpack_require__.main; 73 | const moduleFilename = mainModule && mainModule.filename || ''; 74 | if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { 75 | run(); 76 | } 77 | 78 | export default bootstrap; 79 | -------------------------------------------------------------------------------- /angular-src/server.ts.bak: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'zone.js/node'; 3 | 4 | import * as express from 'express'; 5 | import { existsSync } from 'fs'; 6 | import { join } from 'path'; 7 | import { AppServerModule } from 'src/main.server'; 8 | 9 | import { APP_BASE_HREF } from '@angular/common'; 10 | import { ngExpressEngine } from '@nguniversal/express-engine'; 11 | 12 | /** 13 | * In order for angular to work with SSR with an already existing express app, 14 | * call this init to mount angular in SSR mode. 15 | * @param server The express app we want to mount angular SSR into 16 | * @param distFolder The angular browser dist directory where angular was compiled to 17 | */ 18 | export function init(server: express.Application, distFolder: string): void { 19 | const indexHtml = existsSync(join(distFolder, 'index.original.html')) 20 | ? 'index.original.html' 21 | : 'index'; 22 | 23 | // Our Universal express-engine (found @ https://github.com/angular/universal/tree/master/modules/express-engine) 24 | server.engine( 25 | 'html', 26 | ngExpressEngine({ 27 | bootstrap: AppServerModule, 28 | }) 29 | ); 30 | 31 | server.set('view engine', 'html'); 32 | server.set('views', distFolder); 33 | 34 | // Serve static files from /browser 35 | server.get( 36 | '*.*', 37 | express.static(distFolder, { 38 | maxAge: '1y', 39 | }) 40 | ); 41 | 42 | // All regular routes use the Universal engine 43 | server.get('*', (req, res) => { 44 | res.render(indexHtml, { 45 | req, 46 | providers: [{ provide: APP_BASE_HREF, useValue: req.baseUrl }], 47 | }); 48 | }); 49 | } 50 | 51 | /** 52 | * This is the main express app used for SSR, for development purposes. 53 | */ 54 | export function app(): express.Express { 55 | const server = express(); 56 | const distFolder = join(process.cwd(), 'dist/browser'); 57 | init(server, distFolder); 58 | 59 | return server; 60 | } 61 | 62 | function run(): void { 63 | const port = process.env.PORT || 4000; 64 | 65 | // Start up the Node server 66 | const server = app(); 67 | server.listen(port, () => { 68 | console.log(`Node Express server listening on http://localhost:${port}`); 69 | }); 70 | } 71 | // Webpack will replace 'require' with '__webpack_require__' 72 | // '__non_webpack_require__' is a proxy to Node 'require' 73 | // The below code is to ensure that the server is run only when not requiring the bundle. 74 | declare const __non_webpack_require__: NodeRequire; 75 | const mainModule = __non_webpack_require__.main; 76 | const moduleFilename = (mainModule && mainModule.filename) || ''; 77 | if (moduleFilename === __filename || moduleFilename.includes('iisnode')) { 78 | run(); 79 | } 80 | 81 | export * from './src/main.server'; 82 | -------------------------------------------------------------------------------- /angular-src/src/.browserslistrc: -------------------------------------------------------------------------------- 1 | # This file is currently used by autoprefixer to adjust CSS to support the below specified browsers 2 | # For additional information regarding the format and rule options, please see: 3 | # https://github.com/browserslist/browserslist#queries 4 | # For IE 9-11 support, please uncomment the last line of the file and adjust as needed 5 | > 0.5% 6 | last 2 versions 7 | Firefox ESR 8 | not dead 9 | # IE 9-11 -------------------------------------------------------------------------------- /angular-src/src/app/app.component.html: -------------------------------------------------------------------------------- 1 | 2 |
3 | 4 | 5 |
6 | 7 | 8 | -------------------------------------------------------------------------------- /angular-src/src/app/app.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/app.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { RouterTestingModule } from '@angular/router/testing'; 3 | 4 | import { AppComponent } from './app.component'; 5 | import { getCommonTestBed } from './testing/test_utils'; 6 | 7 | describe('AppComponent', () => { 8 | let component: AppComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(waitForAsync(() => { 12 | getCommonTestBed([AppComponent], [RouterTestingModule]).compileComponents(); 13 | })); 14 | 15 | beforeEach(waitForAsync(() => { 16 | fixture = TestBed.createComponent(AppComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /angular-src/src/app/app.component.ts: -------------------------------------------------------------------------------- 1 | import { isPlatformBrowser } from '@angular/common'; 2 | import { APP_ID, Component, Inject, PLATFORM_ID } from '@angular/core'; 3 | import { 4 | Event as RouterEvent, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router 5 | } from '@angular/router'; 6 | import { LoadingBarService } from '@ngx-loading-bar/core'; 7 | import { AppService } from '@services'; 8 | 9 | // Quick hack, because importing jquery with bootstrap makes issues with SSR, we use this alternative 10 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 11 | declare let $: any; 12 | 13 | @Component({ 14 | selector: 'app-root', 15 | templateUrl: './app.component.html', 16 | styleUrls: ['./app.component.scss'], 17 | }) 18 | export class AppComponent { 19 | constructor( 20 | private router: Router, 21 | private loadingBarService: LoadingBarService, 22 | public appService: AppService, 23 | @Inject(PLATFORM_ID) private platformId: unknown, 24 | @Inject(APP_ID) private appId: string, 25 | ) { 26 | if (isPlatformBrowser(platformId)) 27 | this.router.events.subscribe(this.navigationInterceptor.bind(this)); 28 | } 29 | 30 | private isAppLoading = false; 31 | 32 | get isLoading(): boolean { 33 | return this.isAppLoading; 34 | } 35 | set isLoading(newValue: boolean) { 36 | if (newValue) { 37 | this.loadingBarService.start(); 38 | } else { 39 | this.loadingBarService.complete(); 40 | } 41 | 42 | this.isAppLoading = newValue; 43 | } 44 | 45 | navigationInterceptor(event: RouterEvent): void { 46 | if (event instanceof NavigationStart) { 47 | this.isLoading = true; 48 | 49 | // Toogle navbar collapse when clicking on link 50 | const navbarCollapse = $('.navbar-collapse'); 51 | if (navbarCollapse != null) { 52 | navbarCollapse.collapse('hide'); 53 | } 54 | } 55 | if (event instanceof NavigationEnd) { 56 | this.isLoading = false; 57 | } 58 | 59 | // Set loading state to false in both of the below events to hide the spinner in case a request fails 60 | if (event instanceof NavigationCancel) { 61 | this.isLoading = false; 62 | } 63 | if (event instanceof NavigationError) { 64 | this.isLoading = false; 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /angular-src/src/app/app.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | import { FormsModule } from '@angular/forms'; 4 | import { BrowserModule, provideClientHydration } from '@angular/platform-browser'; 5 | import { Route, RouterModule } from '@angular/router'; 6 | import { LoadingBarModule } from '@ngx-loading-bar/core'; 7 | import { AuthGuardService } from '@services'; 8 | 9 | import { AppComponent } from './app.component'; 10 | import { ExamplePageComponent } from './components/example-page/example-page.component'; 11 | import { HomeComponent } from './components/home/home.component'; 12 | import { LoginComponent } from './components/login/login.component'; 13 | import { RegisterComponent } from './components/register/register.component'; 14 | import { UserPageComponent } from './components/user-page/user-page.component'; 15 | import { CoreModule } from './core/core.module'; 16 | import { SharedModule } from './shared/shared.module'; 17 | 18 | const routes: Route[] = [ 19 | { 20 | path: '', 21 | pathMatch: 'full', 22 | component: HomeComponent, 23 | }, 24 | { 25 | path: 'login', 26 | component: LoginComponent, 27 | }, 28 | { 29 | path: 'example', 30 | pathMatch: 'full', 31 | component: ExamplePageComponent, 32 | }, 33 | { 34 | path: 'register', 35 | component: RegisterComponent, 36 | }, 37 | { 38 | path: 'user', 39 | component: UserPageComponent, 40 | canActivate: [AuthGuardService], 41 | }, 42 | { 43 | path: 'admin', 44 | component: UserPageComponent, 45 | canActivate: [AuthGuardService], 46 | data: { roles: ['admin'] }, 47 | }, 48 | ]; 49 | 50 | @NgModule({ 51 | declarations: [ 52 | AppComponent, 53 | HomeComponent, 54 | ExamplePageComponent, 55 | LoginComponent, 56 | UserPageComponent, 57 | RegisterComponent, 58 | ], 59 | imports: [ 60 | BrowserModule.withServerTransition({ appId: 'app-root' }), 61 | CommonModule, 62 | SharedModule, 63 | CoreModule, 64 | RouterModule.forRoot(routes, { 65 | enableTracing: false, 66 | initialNavigation: 'enabledNonBlocking', 67 | }), 68 | FormsModule, 69 | LoadingBarModule, 70 | ], 71 | providers: [provideClientHydration()], 72 | bootstrap: [AppComponent], 73 | }) 74 | export class AppModule {} 75 | -------------------------------------------------------------------------------- /angular-src/src/app/app.server.module.ts: -------------------------------------------------------------------------------- 1 | import { HTTP_INTERCEPTORS } from '@angular/common/http'; 2 | import { NgModule } from '@angular/core'; 3 | import { NoopAnimationsModule } from '@angular/platform-browser/animations'; 4 | import { ServerModule, ServerTransferStateModule } from '@angular/platform-server'; 5 | import { UniversalRelativeInterceptor } from '@core/universal-relative.interceptor'; 6 | 7 | import { AppComponent } from './app.component'; 8 | import { AppModule } from './app.module'; 9 | 10 | @NgModule({ 11 | imports: [AppModule, ServerModule, NoopAnimationsModule, ServerTransferStateModule], 12 | providers: [ 13 | // Add server-only providers here. 14 | { 15 | provide: HTTP_INTERCEPTORS, 16 | useClass: UniversalRelativeInterceptor, 17 | multi: true, 18 | }, 19 | ], 20 | bootstrap: [AppComponent], 21 | }) 22 | export class AppServerModule {} 23 | -------------------------------------------------------------------------------- /angular-src/src/app/components/example-page/example-page.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | This is an example page! 5 |
6 |
7 |
-------------------------------------------------------------------------------- /angular-src/src/app/components/example-page/example-page.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/components/example-page/example-page.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/example-page/example-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { ExamplePageComponent } from './example-page.component'; 4 | 5 | describe('ExamplePageComponent', () => { 6 | let component: ExamplePageComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ ExamplePageComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(ExamplePageComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /angular-src/src/app/components/example-page/example-page.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-example-page', 5 | templateUrl: './example-page.component.html', 6 | styleUrls: ['./example-page.component.scss'], 7 | }) 8 | export class ExamplePageComponent {} 9 | -------------------------------------------------------------------------------- /angular-src/src/app/components/home/home.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Welcome to the starter template!

4 |

This template comes prepacked with bootstrap.

5 |

6 | Login with a userOr register here 7 |

8 |
9 |
10 | -------------------------------------------------------------------------------- /angular-src/src/app/components/home/home.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/components/home/home.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { HomeComponent } from './home.component'; 4 | 5 | describe('HomeComponent', () => { 6 | let component: HomeComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ HomeComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HomeComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /angular-src/src/app/components/home/home.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-home', 5 | templateUrl: './home.component.html', 6 | styleUrls: ['./home.component.scss'], 7 | }) 8 | export class HomeComponent {} 9 | -------------------------------------------------------------------------------- /angular-src/src/app/components/login/login.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 | Login 5 |
6 |
7 |
8 |
9 | 10 | 12 |
13 | 14 |
15 | 16 | 18 | We'll never share your email with anyone else. 19 |
20 | 21 |
22 |
23 | 25 | 26 | 27 | 28 | 29 |
30 | 31 |
32 |
33 |
34 | 35 |
36 | -------------------------------------------------------------------------------- /angular-src/src/app/components/login/login.component.scss: -------------------------------------------------------------------------------- 1 | .social-login { 2 | display: inline-block; 3 | margin-left: 8px; 4 | } 5 | 6 | .social-login>.btn { 7 | font-size: 12px; 8 | margin-left: 10px; 9 | } -------------------------------------------------------------------------------- /angular-src/src/app/components/login/login.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { FormsModule } from '@angular/forms'; 3 | 4 | import { getCommonTestBed } from '../../testing/test_utils'; 5 | import { LoginComponent } from './login.component'; 6 | 7 | describe('LoginComponent', () => { 8 | let component: LoginComponent; 9 | let htmlElement: HTMLElement; 10 | let fixture: ComponentFixture; 11 | 12 | let userInput: HTMLInputElement; 13 | let passInput: HTMLInputElement; 14 | let loginBtn: HTMLInputElement; 15 | 16 | const setUsernamePasswordInput = (username: string, password: string) => { 17 | userInput.value = username; 18 | userInput.dispatchEvent(new Event('input')); 19 | 20 | passInput.value = password; 21 | passInput.dispatchEvent(new Event('input')); 22 | }; 23 | 24 | beforeEach(waitForAsync(() => { 25 | getCommonTestBed([LoginComponent], [FormsModule]).compileComponents(); 26 | })); 27 | 28 | beforeEach(waitForAsync(() => { 29 | fixture = TestBed.createComponent(LoginComponent); 30 | component = fixture.componentInstance; 31 | fixture.detectChanges(); 32 | 33 | htmlElement = fixture.debugElement.nativeElement; 34 | userInput = htmlElement.querySelector('input[name=email]'); 35 | passInput = htmlElement.querySelector('input[name=password]'); 36 | loginBtn = htmlElement.querySelector('#login_btn'); 37 | })); 38 | 39 | it('should create', () => { 40 | expect(component).toBeTruthy(); 41 | }); 42 | 43 | it('login should be disabled', () => { 44 | const expectLoginDisabled = () => { 45 | expect(loginBtn.disabled).toBeTruthy(); 46 | }; 47 | 48 | setUsernamePasswordInput('username', ''); 49 | fixture.detectChanges(); 50 | expectLoginDisabled(); 51 | 52 | setUsernamePasswordInput('', 'password'); 53 | fixture.detectChanges(); 54 | expectLoginDisabled(); 55 | 56 | setUsernamePasswordInput('', ''); 57 | fixture.detectChanges(); 58 | expectLoginDisabled(); 59 | }); 60 | 61 | it('should set username & password, login should be enabled', () => { 62 | fixture.detectChanges(); 63 | const username = 'my_random_user'; 64 | const password = 'my_password'; 65 | 66 | // Set the input username and password 67 | setUsernamePasswordInput(username, password); 68 | fixture.detectChanges(); 69 | 70 | expect(component.email).toEqual(username); 71 | expect(component.password).toEqual(password); 72 | expect(loginBtn.disabled).toBeFalsy(); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /angular-src/src/app/components/login/login.component.ts: -------------------------------------------------------------------------------- 1 | import { ToastrService } from 'ngx-toastr'; 2 | import { Subscription } from 'rxjs'; 3 | 4 | import { Component, OnDestroy, OnInit } from '@angular/core'; 5 | import { Router } from '@angular/router'; 6 | import { AuthService } from '@services'; 7 | 8 | @Component({ 9 | selector: 'app-login', 10 | templateUrl: './login.component.html', 11 | styleUrls: ['./login.component.scss'], 12 | }) 13 | export class LoginComponent implements OnInit, OnDestroy { 14 | private authSubscription: Subscription; 15 | email: string; 16 | password: string; 17 | 18 | constructor( 19 | private router: Router, 20 | private authService: AuthService, 21 | private toastService: ToastrService 22 | ) {} 23 | 24 | ngOnInit(): void { 25 | this.authSubscription = this.authService.userChanged.subscribe((user) => { 26 | if (user) { 27 | this.toastService.success( 28 | `Login successfully`, 29 | `You are now logged in` 30 | ); 31 | 32 | this.router.navigateByUrl('/user'); 33 | } 34 | }); 35 | } 36 | 37 | ngOnDestroy(): void { 38 | this.authSubscription.unsubscribe(); 39 | } 40 | 41 | onLoginClick(): void { 42 | this.authService.login(this.email, this.password).subscribe(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /angular-src/src/app/components/register/register.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
Register a new user
4 |
5 |
7 |
8 |
9 |
10 |
11 | 12 | 14 |
15 | 16 |
17 | 18 | 20 |
21 | 22 |
23 | 24 |
25 |
26 | 27 | 29 |
30 | 31 |
32 | 33 | 35 |
36 |
37 | 38 |
39 | 41 |
42 |
43 |
44 | 45 |
46 |
47 |
48 |
-------------------------------------------------------------------------------- /angular-src/src/app/components/register/register.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/components/register/register.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/register/register.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; 2 | import { FormsModule } from '@angular/forms'; 3 | 4 | import { 5 | getCommonTestBed, getInputElementValidationDiv, setInputValueWithEvent, tickAndDetectChanges 6 | } from '../../testing/test_utils'; 7 | import { RegisterComponent } from './register.component'; 8 | 9 | describe('RegisterComponent', () => { 10 | let component: RegisterComponent; 11 | let fixture: ComponentFixture; 12 | let htmlElement: HTMLElement; 13 | 14 | let emailField: HTMLInputElement; 15 | let passwordField: HTMLInputElement; 16 | let firstNameField: HTMLInputElement; 17 | let lastNameField: HTMLInputElement; 18 | let registerButton: HTMLInputElement; 19 | 20 | // const getAllTextInputFields = (): Array => { 21 | // return Array.apply( 22 | // null, 23 | // htmlElement.querySelectorAll('input[type="text"] input[type="password"]') 24 | // ); 25 | // }; 26 | 27 | const anyFieldHasInvalidDescription = () => { 28 | return document.querySelector('.invalid-feedback'); 29 | }; 30 | 31 | const fieldHasInvalidDescription = (field: HTMLInputElement) => { 32 | return getInputElementValidationDiv(field).classList.contains( 33 | 'invalid-feedback' 34 | ); 35 | }; 36 | 37 | beforeEach(waitForAsync(() => { 38 | getCommonTestBed([RegisterComponent], [FormsModule]).compileComponents(); 39 | })); 40 | 41 | beforeEach(waitForAsync(() => { 42 | fixture = TestBed.createComponent(RegisterComponent); 43 | component = fixture.componentInstance; 44 | fixture.detectChanges(); 45 | 46 | htmlElement = fixture.debugElement.nativeElement; 47 | emailField = htmlElement.querySelector('#email'); 48 | passwordField = htmlElement.querySelector('#password'); 49 | firstNameField = htmlElement.querySelector('#firstName'); 50 | lastNameField = htmlElement.querySelector('#lastName'); 51 | registerButton = htmlElement.querySelector('button[type="submit"]'); 52 | })); 53 | 54 | it('should create', () => { 55 | expect(component).toBeTruthy(); 56 | }); 57 | 58 | it('should register button be disabled', () => { 59 | // By default the register button should be disabled as no data is presented 60 | expect(registerButton.disabled).toBeTruthy(); 61 | }); 62 | 63 | it('should render email field validation correctly', fakeAsync(() => { 64 | // By default email is empty and should have an invalid description 65 | expect(fieldHasInvalidDescription(emailField)).toBeTruthy(); 66 | 67 | // Enter a valid email 68 | setInputValueWithEvent(emailField, 'test@mail.com'); 69 | tickAndDetectChanges(fixture); 70 | 71 | // Email should now be valid 72 | expect(fieldHasInvalidDescription(emailField)).toBeFalsy(); 73 | 74 | // Now enter an invalid email address 75 | setInputValueWithEvent(emailField, 'someinvalidmailaddress'); 76 | tickAndDetectChanges(fixture); 77 | 78 | // Now check to see that the error is rendered 79 | expect(fieldHasInvalidDescription(emailField)).toBeTruthy(); 80 | })); 81 | 82 | it('should render password validation correctly', fakeAsync(() => { 83 | // By default password is empty and should have an invalid description 84 | expect(fieldHasInvalidDescription(passwordField)).toBeTruthy(); 85 | 86 | // Enter a valid password 87 | setInputValueWithEvent(passwordField, 'thisisavalidpassword'); 88 | tickAndDetectChanges(fixture); 89 | 90 | // Password should now be valid 91 | expect(fieldHasInvalidDescription(passwordField)).toBeFalsy(); 92 | 93 | // Set an invalid short password 94 | setInputValueWithEvent(passwordField, 'short'); 95 | tickAndDetectChanges(fixture); 96 | 97 | // Now check to see that error is rendered 98 | expect(fieldHasInvalidDescription(passwordField)).toBeTruthy(); 99 | })); 100 | 101 | it('should render first name & last name field validation correctly', fakeAsync(() => { 102 | const checkFirstOrLastNameField = (field: HTMLInputElement) => { 103 | // By default first\last field is empty and should have an invalid description 104 | expect(fieldHasInvalidDescription(field)).toBeTruthy(); 105 | 106 | // Enter a valid input 107 | setInputValueWithEvent(field, 'generic name'); 108 | tickAndDetectChanges(fixture); 109 | 110 | // Field should now be valid 111 | expect(fieldHasInvalidDescription(field)).toBeFalsy(); 112 | 113 | // Set an invalid first\last name field 114 | setInputValueWithEvent(field, ''); 115 | tickAndDetectChanges(fixture); 116 | 117 | // Now check to see that error is rendered 118 | expect(fieldHasInvalidDescription(field)).toBeTruthy(); 119 | }; 120 | 121 | checkFirstOrLastNameField(firstNameField); 122 | checkFirstOrLastNameField(lastNameField); 123 | })); 124 | 125 | it('should fill all fields, register button be enabled and no errors should render', fakeAsync(() => { 126 | setInputValueWithEvent(emailField, 'test@mai.com'); 127 | setInputValueWithEvent(passwordField, 'password'); 128 | setInputValueWithEvent(firstNameField, 'first'); 129 | setInputValueWithEvent(lastNameField, 'last'); 130 | 131 | tickAndDetectChanges(fixture); 132 | 133 | expect(anyFieldHasInvalidDescription()).toBeFalsy(); 134 | expect(registerButton.disabled).toBeFalsy(); 135 | })); 136 | }); 137 | -------------------------------------------------------------------------------- /angular-src/src/app/components/register/register.component.ts: -------------------------------------------------------------------------------- 1 | import { ToastrService } from 'ngx-toastr'; 2 | 3 | import { Component } from '@angular/core'; 4 | import { Router } from '@angular/router'; 5 | 6 | import { UserProfileModel } from '../../../../../shared/models/user-profile.model'; 7 | import { ApiService } from '../../core/services/api.service'; 8 | 9 | @Component({ 10 | selector: 'app-register', 11 | templateUrl: './register.component.html', 12 | styleUrls: ['./register.component.scss'], 13 | }) 14 | export class RegisterComponent { 15 | userProfile: UserProfileModel = new UserProfileModel(); 16 | isFormValid: boolean; 17 | 18 | constructor( 19 | private apiService: ApiService, 20 | private toastyService: ToastrService, 21 | private router: Router 22 | ) {} 23 | 24 | onFormValidChange(isValid: boolean): void { 25 | this.isFormValid = isValid; 26 | } 27 | 28 | onRegisterClick(): void { 29 | this.apiService.register(this.userProfile).subscribe(() => { 30 | this.toastyService.success( 31 | `User successfully registered! please login now` 32 | ); 33 | this.router.navigateByUrl('/login'); 34 | }); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /angular-src/src/app/components/user-page/user-page.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |

Hello there {{ user?.email }}

4 |

It seems like your login went well!

5 |

6 | Logout 7 |

8 |
9 |
-------------------------------------------------------------------------------- /angular-src/src/app/components/user-page/user-page.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/components/user-page/user-page.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/user-page/user-page.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, fakeAsync, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { AppService } from '../../core/services/app.service'; 4 | import { getCommonTestBed } from '../../testing/test_utils'; 5 | import { UserPageComponent } from './user-page.component'; 6 | 7 | describe('UserPageComponent', () => { 8 | let component: UserPageComponent; 9 | let appService: AppService; 10 | let fixture: ComponentFixture; 11 | let htmlElement: HTMLElement; 12 | 13 | let heading: HTMLElement; 14 | 15 | beforeEach(waitForAsync(() => { 16 | getCommonTestBed([UserPageComponent], []).compileComponents(); 17 | })); 18 | 19 | beforeEach(() => { 20 | fixture = TestBed.createComponent(UserPageComponent); 21 | component = fixture.componentInstance; 22 | fixture.detectChanges(); 23 | 24 | appService = TestBed.inject(AppService); 25 | htmlElement = fixture.debugElement.nativeElement; 26 | heading = htmlElement.querySelector('.jumbotron-heading'); 27 | }); 28 | 29 | it('should create', () => { 30 | expect(component).toBeTruthy(); 31 | }); 32 | 33 | it('should render the correct user email', fakeAsync(() => { 34 | // Create a mock user with the email we expect to receive when the user is connected 35 | spyOnProperty(appService, 'user').and.returnValue({ 36 | email: 'fake@mail.com', 37 | }); 38 | 39 | fixture.detectChanges(); 40 | expect(heading.textContent).toEqual('Hello there fake@mail.com'); 41 | })); 42 | }); 43 | -------------------------------------------------------------------------------- /angular-src/src/app/components/user-page/user-page.component.ts: -------------------------------------------------------------------------------- 1 | import { ToastrService } from 'ngx-toastr'; 2 | 3 | import { Component } from '@angular/core'; 4 | import { Router } from '@angular/router'; 5 | 6 | import { UserProfile } from '../../../../../shared/models/user-profile'; 7 | import { AppService, AuthService } from '../../core/services'; 8 | 9 | @Component({ 10 | selector: 'app-user-page', 11 | templateUrl: './user-page.component.html', 12 | styleUrls: ['./user-page.component.scss'], 13 | }) 14 | export class UserPageComponent { 15 | constructor( 16 | private router: Router, 17 | private appService: AppService, 18 | private toastService: ToastrService, 19 | private authService: AuthService 20 | ) {} 21 | 22 | get user(): UserProfile { 23 | return this.appService.user; 24 | } 25 | 26 | logout(): void { 27 | this.authService.logout().then(() => { 28 | this.router.navigateByUrl('/'); 29 | this.toastService.success( 30 | `You are logged out`, 31 | `You have succesfully logged out!` 32 | ); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /angular-src/src/app/core/app-http.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 4 | import { Injectable } from '@angular/core'; 5 | import { AuthService, RequestsService } from '@services'; 6 | 7 | /** 8 | * This interceptor handles all of the ongoing requests. 9 | * It adds an authentication token if available in the auth-service. 10 | * All of the ongoing requests are passed to the requests-service to handle and show an error if required. 11 | */ 12 | @Injectable() 13 | export class AppHttpInterceptor implements HttpInterceptor { 14 | constructor( 15 | public authService: AuthService, 16 | private requestsService: RequestsService 17 | ) {} 18 | intercept( 19 | request: HttpRequest, 20 | next: HttpHandler 21 | ): Observable> { 22 | // Add our authentication token if existing 23 | if (this.authService.hasCredentials) { 24 | // Check if this request does already contains a credentials to send, if so, don't append our token 25 | if (!request.withCredentials) { 26 | const cloneOptions = { 27 | setHeaders: { 28 | Authorization: `Bearer ${this.authService.savedToken}`, 29 | }, 30 | }; 31 | 32 | request = request.clone(cloneOptions); 33 | } 34 | } 35 | 36 | return this.handleRequest(next.handle(request)); 37 | } 38 | 39 | handleRequest( 40 | request: Observable> 41 | ): Observable> { 42 | return this.requestsService.onRequestStarted(request); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /angular-src/src/app/core/components/footer/footer.component.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 | - This is a simple sticky footer - 6 |
7 |
8 |
9 |
-------------------------------------------------------------------------------- /angular-src/src/app/core/components/footer/footer.component.scss: -------------------------------------------------------------------------------- 1 | footer { 2 | background-color: #f5f5f5; 3 | padding: 30px; 4 | } -------------------------------------------------------------------------------- /angular-src/src/app/core/components/footer/footer.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { FooterComponent } from './footer.component'; 4 | 5 | describe('FooterComponent', () => { 6 | let component: FooterComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ FooterComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(FooterComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /angular-src/src/app/core/components/footer/footer.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-footer', 5 | templateUrl: './footer.component.html', 6 | styleUrls: ['./footer.component.scss'], 7 | }) 8 | export class FooterComponent {} 9 | -------------------------------------------------------------------------------- /angular-src/src/app/core/components/header/header.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /angular-src/src/app/core/components/header/header.component.scss: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/app/core/components/header/header.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/core/components/header/header.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { HeaderComponent } from './header.component'; 4 | 5 | describe('HeaderComponent', () => { 6 | let component: HeaderComponent; 7 | let fixture: ComponentFixture; 8 | 9 | beforeEach(waitForAsync(() => { 10 | TestBed.configureTestingModule({ 11 | declarations: [ HeaderComponent ] 12 | }) 13 | .compileComponents(); 14 | })); 15 | 16 | beforeEach(() => { 17 | fixture = TestBed.createComponent(HeaderComponent); 18 | component = fixture.componentInstance; 19 | fixture.detectChanges(); 20 | }); 21 | 22 | it('should create', () => { 23 | expect(component).toBeTruthy(); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /angular-src/src/app/core/components/header/header.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | @Component({ 4 | selector: 'app-header', 5 | templateUrl: './header.component.html', 6 | styleUrls: ['./header.component.scss'], 7 | }) 8 | export class HeaderComponent {} 9 | -------------------------------------------------------------------------------- /angular-src/src/app/core/core.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { CoreModule } from './core.module'; 2 | 3 | describe('CoreModule', () => { 4 | let coreModule: CoreModule; 5 | 6 | beforeEach(() => { 7 | coreModule = new CoreModule(); 8 | }); 9 | 10 | it('should create an instance', () => { 11 | expect(coreModule).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /angular-src/src/app/core/core.module.ts: -------------------------------------------------------------------------------- 1 | import { CookieModule } from 'ngx-cookie'; 2 | import { ToastrModule } from 'ngx-toastr'; 3 | 4 | import { CommonModule } from '@angular/common'; 5 | import { 6 | HTTP_INTERCEPTORS, HttpClientModule, provideHttpClient, withFetch 7 | } from '@angular/common/http'; 8 | import { NgModule } from '@angular/core'; 9 | import { BrowserModule } from '@angular/platform-browser'; 10 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 11 | import { RouterModule } from '@angular/router'; 12 | import { LoadingBarModule } from '@ngx-loading-bar/core'; 13 | import { ApiService, AppService, AuthGuardService, AuthService, RequestsService } from '@services'; 14 | 15 | import { SharedModule } from '../shared/shared.module'; 16 | import { SocialLoginModule } from '../social-login/social-login.module'; 17 | import { AppHttpInterceptor } from './app-http.interceptor'; 18 | import { FooterComponent } from './components/footer/footer.component'; 19 | import { HeaderComponent } from './components/header/header.component'; 20 | 21 | @NgModule({ 22 | imports: [ 23 | CommonModule, 24 | HttpClientModule, 25 | BrowserModule, 26 | BrowserAnimationsModule, 27 | CookieModule.withOptions(), 28 | LoadingBarModule, 29 | ToastrModule.forRoot({ 30 | timeOut: 5000, 31 | positionClass: 'toast-bottom-right', 32 | }), 33 | RouterModule, 34 | SharedModule, 35 | SocialLoginModule, 36 | ], 37 | declarations: [HeaderComponent, FooterComponent], 38 | providers: [ 39 | ApiService, 40 | AuthService, 41 | AuthGuardService, 42 | AppService, 43 | { 44 | provide: HTTP_INTERCEPTORS, 45 | useClass: AppHttpInterceptor, 46 | multi: true, 47 | }, 48 | RequestsService, 49 | provideHttpClient(withFetch()), 50 | ], 51 | exports: [ 52 | HeaderComponent, 53 | FooterComponent, 54 | LoadingBarModule, 55 | ToastrModule, 56 | SocialLoginModule, 57 | ], 58 | }) 59 | export class CoreModule {} 60 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/api.service.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import { HttpClient } from '@angular/common/http'; 4 | import { Injectable } from '@angular/core'; 5 | 6 | import { UserProfile } from '../../../../../shared/models'; 7 | import { LoginResponse } from '../../../../../shared/models/login-response'; 8 | import { environment } from '../../../environments/environment'; 9 | 10 | @Injectable() 11 | export class ApiService { 12 | constructor(private httpService: HttpClient) {} 13 | 14 | get serverUrl(): string { 15 | return environment.apiServer; 16 | } 17 | 18 | get apiUrl(): string { 19 | return `${this.serverUrl}/api`; 20 | } 21 | 22 | getApiEndpoint(endpoint: string): string { 23 | return `${this.apiUrl}/${endpoint}`; 24 | } 25 | 26 | login(username: string, password: string): Observable { 27 | const url = this.getApiEndpoint(`login`); 28 | 29 | return this.httpService.post(url, { 30 | username, 31 | password, 32 | }); 33 | } 34 | 35 | socialLogin(provider: string, authToken: string): Observable { 36 | const url = this.getApiEndpoint(`social-login/${provider}`); 37 | return this.httpService.get(url, { 38 | headers: { 39 | Authorization: `Bearer ${authToken}`, 40 | access_token: `${authToken}`, 41 | }, 42 | withCredentials: true, 43 | }); 44 | } 45 | 46 | register(user: UserProfile): Observable { 47 | const url = this.getApiEndpoint('register/'); 48 | return this.httpService.post(url, user); 49 | } 50 | 51 | logout(): Observable { 52 | const url = this.getApiEndpoint('logout/'); 53 | return this.httpService.get(url); 54 | } 55 | 56 | getProfile(): Observable { 57 | const url = this.getApiEndpoint(`profile/`); 58 | return this.httpService.get(url); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/app.service.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject } from 'rxjs'; 2 | 3 | import { Injectable } from '@angular/core'; 4 | 5 | import { UserProfile } from '../../../../../shared/models'; 6 | import { AuthService } from './auth.service'; 7 | 8 | @Injectable() 9 | export class AppService { 10 | isRequestLoading = false; 11 | 12 | get user(): UserProfile { 13 | return this.authService.user; 14 | } 15 | 16 | get userChanged(): BehaviorSubject { 17 | return this.authService.userChanged; 18 | } 19 | 20 | get isLoggedIn(): boolean { 21 | return this.user != null && this.loginChecked; 22 | } 23 | 24 | get loginChecked(): boolean { 25 | return this.authService.loginChecked; 26 | } 27 | 28 | constructor(public authService: AuthService) {} 29 | } 30 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/auth-guard.service.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | 3 | import { Injectable } from '@angular/core'; 4 | import { ActivatedRouteSnapshot, CanActivate, CanLoad, Route, Router } from '@angular/router'; 5 | 6 | import { AuthService } from './auth.service'; 7 | 8 | @Injectable() 9 | export class AuthGuardService implements CanActivate, CanLoad { 10 | constructor(public router: Router, private authService: AuthService) {} 11 | 12 | canActivate(route: ActivatedRouteSnapshot): boolean | Observable { 13 | return this.checkAuthentication(route.data && route.data['roles']); 14 | } 15 | 16 | canLoad(route: Route): boolean | Observable { 17 | return this.checkAuthentication(route.data && route.data['roles']); 18 | } 19 | 20 | checkAuthentication(roles?: string[]): boolean | Observable { 21 | if (roles) { 22 | return this.authService.hasRolesAsync(roles); 23 | } 24 | 25 | return this.authService.isLoggedInAsync; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { CookieService } from 'ngx-cookie'; 2 | 3 | import { TestBed, waitForAsync } from '@angular/core/testing'; 4 | 5 | import { getCommonTestBed } from '../../testing/test_utils'; 6 | import { AuthService } from './auth.service'; 7 | 8 | describe('AuthService', () => { 9 | let service: AuthService; 10 | beforeEach(waitForAsync(() => { 11 | getCommonTestBed([]).compileComponents(); 12 | service = TestBed.inject(AuthService); 13 | 14 | const cookieService = TestBed.inject(CookieService); 15 | 16 | // Delete any previous cookie to create clean tests 17 | cookieService.remove('auth_token'); 18 | })); 19 | 20 | it('should create', () => { 21 | expect(service).toBeTruthy(); 22 | }); 23 | 24 | it('should return initial state with no users or credentials stored', () => { 25 | expect(service.user).toBeUndefined(); 26 | expect(service.loginChecked).toBeFalsy(); 27 | expect(service.hasCredentials).toBeFalsy(); 28 | }); 29 | 30 | it('should login with the correct credentials and emit userChanged', async () => { 31 | // User should be udefined at first 32 | expect(service.user).toBeUndefined(); 33 | 34 | // Expect if userChanged was called 35 | const spy = spyOn(service.userChanged, 'next'); 36 | 37 | await service.login('admin', 'admin').toPromise(); 38 | 39 | // Expect that the user was set 40 | expect(service.user).toBeTruthy(); 41 | expect(service.loginChecked).toBeTruthy(); 42 | expect(service.hasCredentials).toBeTruthy(); 43 | // Expect userChange to be called once 44 | expect(spy).toHaveBeenCalledTimes(1); 45 | }); 46 | 47 | it('should have "admin" role and fail on "some" role"', async () => { 48 | await service.login('admin', 'admin').toPromise(); 49 | 50 | expect(service.hasRole('admin')).toBeTruthy(); 51 | expect(service.hasRole('some')).toBeFalsy(); 52 | }); 53 | 54 | it('should fail to login with incorrect credentials and userChanged should not be called', async () => { 55 | const spy = spyOn(service.userChanged, 'next'); 56 | 57 | await expectAsync( 58 | service.login('incorrect', 'incorrect').toPromise() 59 | ).toBeRejected(); 60 | 61 | // User should be undefined and no userChanged should be called 62 | expect(service.user).toBeUndefined(); 63 | expect(service.hasCredentials).toBeFalsy(); 64 | expect(spy).toHaveBeenCalledTimes(0); 65 | }); 66 | }); 67 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { CookieService } from 'ngx-cookie'; 2 | import { BehaviorSubject, Observable } from 'rxjs'; 3 | import { map, tap } from 'rxjs/operators'; 4 | 5 | import { Injectable } from '@angular/core'; 6 | 7 | import { LoginResponse, UserProfile } from '../../../../../shared/models'; 8 | import { ApiService } from './api.service'; 9 | 10 | @Injectable() 11 | export class AuthService { 12 | _user: UserProfile; 13 | userChanged: BehaviorSubject = new BehaviorSubject( 14 | null, 15 | ); 16 | private _loginChecked: boolean; 17 | 18 | get user(): UserProfile { 19 | return this._user; 20 | } 21 | 22 | set user(user: UserProfile) { 23 | if (user !== this._user) { 24 | if (user !== null) { 25 | this.loginChecked = true; 26 | } 27 | 28 | this._user = user; 29 | this.userChanged.next(user); 30 | } 31 | } 32 | 33 | get loginChecked(): boolean { 34 | return this._loginChecked; 35 | } 36 | 37 | set loginChecked(loginChecked: boolean) { 38 | this._loginChecked = loginChecked; 39 | } 40 | 41 | get hasCredentials(): boolean { 42 | return !!this.savedToken; 43 | } 44 | 45 | get savedToken(): string { 46 | return this.cookieService.get('auth_token'); 47 | } 48 | 49 | get isLoggedIn(): boolean { 50 | return this.hasCredentials && !!this._user; 51 | } 52 | 53 | get isLoggedInAsync(): Observable | boolean { 54 | if (!this.hasCredentials) { 55 | return false; 56 | } 57 | 58 | if (!this.loginChecked) { 59 | return this.checkLogin().pipe(map(user => !!user)); 60 | } 61 | 62 | return this.isLoggedIn; 63 | } 64 | 65 | /** 66 | * Checks if a user has a specific role. 67 | * @param roleName 68 | * @param user If not specified, will use the local authenticated user. 69 | */ 70 | hasRole(roleName: string, user?: UserProfile): string { 71 | if (!user) user = this._user; 72 | return user.roles.find(role => roleName === role); 73 | } 74 | 75 | /** 76 | * Checks if a user has specific roles. 77 | * @param roles The roles to check if exists 78 | * @param user If not specified, will use the local authenticated user. 79 | */ 80 | hasRoles(roles: string[], user?: UserProfile): boolean { 81 | for (const role of roles) { 82 | if (!this.hasRole(role, user)) return false; 83 | } 84 | 85 | return true; 86 | } 87 | 88 | hasRolesAsync(roles: string[]): boolean | Observable { 89 | if (this.isLoggedIn) return this.hasRoles(roles); 90 | 91 | if (!this.loginChecked) { 92 | return this.checkLogin().pipe(map(user => this.hasRoles(roles, user))); 93 | } 94 | 95 | return false; 96 | } 97 | 98 | constructor( 99 | private apiService: ApiService, 100 | private cookieService: CookieService, 101 | ) {} 102 | 103 | checkLogin(): Observable { 104 | if (!this.hasCredentials) { 105 | this.loginChecked = true; 106 | return; 107 | } 108 | 109 | this.loginChecked = false; 110 | return this.apiService.getProfile().pipe( 111 | tap( 112 | response => { 113 | this.loginChecked = true; 114 | this.user = response; 115 | }, 116 | () => { 117 | this.loginChecked = true; 118 | }, 119 | ), 120 | ); 121 | } 122 | 123 | login(email: string, password: string): Observable { 124 | return this.apiService.login(email, password).pipe( 125 | tap( 126 | result => { 127 | this.cookieService.put(`auth_token`, result.token); 128 | this.user = result.user; 129 | }, 130 | error => { 131 | this.userChanged.error(error); 132 | console.error(error); 133 | }, 134 | ), 135 | ); 136 | } 137 | 138 | /** 139 | * Signs into using the social authentication credentails provided. 140 | * @param provider 141 | * @param authToken 142 | */ 143 | socialLogin(provider: string, authToken: string): Promise { 144 | return this.apiService 145 | .socialLogin(provider, authToken) 146 | .toPromise() 147 | .then(result => { 148 | this.cookieService.put(`auth_token`, result.token); 149 | this.user = result.user; 150 | return this.user; 151 | }) 152 | .catch(error => { 153 | this.userChanged.error(error); 154 | return error; 155 | }); 156 | } 157 | 158 | logout(): Promise { 159 | this.user = null; 160 | this.cookieService.remove('auth_token'); 161 | this.loginChecked = true; 162 | 163 | // We return a promise so we can notify that everything went well (add your own logout logic if required) 164 | return Promise.resolve(null); 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/index.ts: -------------------------------------------------------------------------------- 1 | export { AuthGuardService } from './auth-guard.service'; 2 | export { AppService } from './app.service'; 3 | export { ApiService } from './api.service'; 4 | export { AuthService } from './auth.service'; 5 | export { RequestsService } from './requests.service'; 6 | -------------------------------------------------------------------------------- /angular-src/src/app/core/services/requests.service.ts: -------------------------------------------------------------------------------- 1 | import { ToastrService } from 'ngx-toastr'; 2 | import { Observable, Subject } from 'rxjs'; 3 | import { tap } from 'rxjs/operators'; 4 | 5 | import { HttpErrorResponse, HttpEvent, HttpResponse } from '@angular/common/http'; 6 | import { Injectable, Input } from '@angular/core'; 7 | 8 | export enum RequestState { 9 | started, 10 | ended, 11 | } 12 | 13 | /** 14 | * An error generated from the server. 15 | */ 16 | interface ServerErrorResponse { 17 | statusCode: number; 18 | message: string; 19 | error: string; 20 | } 21 | 22 | /** 23 | * Handles all of the ongoing requests state, which allows us to detect whether a request is currently happening in the background or not. 24 | * You can use this service to create an app request loading bar (in the header for example). 25 | */ 26 | @Injectable() 27 | export class RequestsService { 28 | private requestsCount = 0; 29 | 30 | @Input() 31 | disableErrorToast = false; 32 | onRequestStateChanged: Subject = new Subject(); 33 | 34 | get isRequestLoading(): boolean { 35 | return this.requestsCount > 0; 36 | } 37 | 38 | constructor(private toastService: ToastrService) {} 39 | 40 | onRequestStarted( 41 | request: Observable>, 42 | ): Observable> { 43 | // If we have detected that no previous request is running, emit and event that a request is ongoing now 44 | if (!this.isRequestLoading) { 45 | this.onRequestStateChanged.next(RequestState.started); 46 | } 47 | 48 | // Add the request to the count 49 | ++this.requestsCount; 50 | 51 | // Handle the request data obtained and show an error toast if nessecary 52 | return request.pipe( 53 | tap( 54 | (event: HttpEvent) => { 55 | if (event instanceof HttpResponse) { 56 | this.onRequestEnded(); 57 | } 58 | }, 59 | errorResponse => { 60 | if (errorResponse instanceof HttpErrorResponse) { 61 | if (!this.disableErrorToast) { 62 | const errorBody = errorResponse.error as ServerErrorResponse; 63 | this.toastService.error( 64 | `An error had occurred`, 65 | errorBody.message, 66 | ); 67 | } 68 | 69 | this.onRequestEnded(); 70 | } 71 | }, 72 | ), 73 | ); 74 | } 75 | 76 | private onRequestEnded(): void { 77 | if (--this.requestsCount === 0) { 78 | this.onRequestStateChanged.next(RequestState.ended); 79 | } 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /angular-src/src/app/core/universal-relative.interceptor.ts: -------------------------------------------------------------------------------- 1 | /* 2 | Credits to: 3 | https://bcodes.io/blog/post/angular-universal-relative-to-absolute-http-interceptor 4 | for this great code! 5 | */ 6 | 7 | import { Request } from 'express'; 8 | import { Observable } from 'rxjs'; 9 | 10 | import { HttpEvent, HttpHandler, HttpInterceptor, HttpRequest } from '@angular/common/http'; 11 | import { Inject, Injectable, Optional } from '@angular/core'; 12 | import { REQUEST } from '../../express.tokens'; 13 | 14 | // case insensitive check against config and value 15 | const startsWithAny = (arr: string[] = []) => (value = '') => { 16 | return arr.some((test) => value.toLowerCase().startsWith(test.toLowerCase())); 17 | }; 18 | 19 | // http, https, protocol relative 20 | const isAbsoluteURL = startsWithAny(['http', '//']); 21 | 22 | @Injectable() 23 | export class UniversalRelativeInterceptor implements HttpInterceptor { 24 | constructor(@Optional() @Inject(REQUEST) protected request: Request) {} 25 | 26 | intercept( 27 | req: HttpRequest, 28 | next: HttpHandler 29 | ): Observable> { 30 | if (this.request && !isAbsoluteURL(req.url)) { 31 | const protocolHost = `${this.request.protocol}://${this.request.get( 32 | 'host' 33 | )}`; 34 | const pathSeparator = !req.url.startsWith('/') ? '/' : ''; 35 | const url = protocolHost + pathSeparator + req.url; 36 | const serverRequest = req.clone({ url }); 37 | return next.handle(serverRequest); 38 | } else { 39 | return next.handle(req); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /angular-src/src/app/shared/directives/form-validator.directive.ts: -------------------------------------------------------------------------------- 1 | import { validate, ValidationError } from 'class-validator'; 2 | 3 | import { 4 | AfterViewInit, Directive, ElementRef, EventEmitter, Input, OnChanges, OnDestroy, Output, 5 | SimpleChanges 6 | } from '@angular/core'; 7 | 8 | /** 9 | * This directive simply updates all of the fields in the form according to the model validations 10 | * using class-validator (https://github.com/typestack/class-validator). 11 | * It follows the bootstrap standard to mark fields ans invalid or valid (https://getbootstrap.com/docs/4.0/components/forms/#validation). 12 | */ 13 | @Directive({ 14 | selector: '[appFormValidator]', 15 | }) 16 | export class FormValidatorDirective 17 | implements AfterViewInit, OnChanges, OnDestroy { 18 | /** 19 | * Called each time the form is completely valid or invalid. 20 | * 21 | * @type {EventEmitter} 22 | * @memberof FormValidatorDirective 23 | */ 24 | @Output() appFormValidatorIsFormValidChange: EventEmitter< 25 | boolean 26 | > = new EventEmitter(); 27 | 28 | @Input() appFormValidator: unknown; 29 | /** 30 | *Hides all of the form validation errors text. 31 | * 32 | * @memberof FormValidatorDirective 33 | */ 34 | @Input() appFormValidatorHideErrorText = false; 35 | /** 36 | * Forces all fields to show valid or invalid even if the user hasn't changed the value. 37 | */ 38 | @Input() appFormValidatorForce: boolean; 39 | private _isFormValid: boolean; 40 | private fieldsWritten: { [name: string]: boolean } = {}; // A dictionary containing data about fields already written 41 | private inputValueChangeEventFunc: (event) => void; 42 | 43 | get appFormValidatorIsFormValid(): boolean { 44 | return this._isFormValid; 45 | } 46 | 47 | private getValidationErrorFromFieldName( 48 | name: string, 49 | validationErrors: ValidationError[] 50 | ): ValidationError { 51 | let lastValidationError: ValidationError = null; 52 | 53 | // We split using '-' which represents a deeper property 54 | const split = name.split('-'); 55 | 56 | // eslint-disable-next-line no-constant-condition 57 | while (true) { 58 | const property = split.shift(); 59 | 60 | if (lastValidationError) 61 | lastValidationError = lastValidationError.children.find( 62 | (v) => v.property === property 63 | ); 64 | else 65 | lastValidationError = validationErrors.find( 66 | (v) => v.property === property 67 | ); 68 | 69 | // If no validation error was found, return null 70 | if (!lastValidationError) return; 71 | 72 | // Update the last validation error 73 | 74 | // If it's the end of the field name, return the validation error 75 | if (split.length === 0) return lastValidationError; 76 | } 77 | } 78 | 79 | constructor(private elementRef: ElementRef) { 80 | this.inputValueChangeEventFunc = (event: Event) => { 81 | const el = event.target as HTMLInputElement; 82 | const name = el.name; 83 | 84 | if (name) { 85 | this.fieldsWritten[name] = true; 86 | this.updateForm(); 87 | } 88 | }; 89 | } 90 | 91 | ngAfterViewInit(): void { 92 | this.attachEventListeners(); 93 | this.updateForm(); 94 | } 95 | 96 | /** 97 | * Get all of the form group inputs, and listen to all of the input events. 98 | */ 99 | attachEventListeners(): void { 100 | // Get all of the form group inputs 101 | this.getFormGroupInputs().forEach((input: HTMLInputElement) => { 102 | // Detect when a value was changed in one of the fields 103 | input.addEventListener('input', this.inputValueChangeEventFunc); 104 | 105 | // Add Description text field if not exists 106 | const next = input.nextElementSibling as HTMLElement; 107 | if (!next) { 108 | const div = document.createElement('div'); 109 | div.className = 'input-description-validation'; 110 | input.parentElement.appendChild(div); 111 | } else next.classList.add('input-description-validation'); 112 | }); 113 | } 114 | 115 | /** 116 | * Removes all of the form group input listeners. 117 | */ 118 | detachEventListeners(): void { 119 | this.getFormGroupInputs().forEach((input: HTMLInputElement) => { 120 | input.removeEventListener('input', this.inputValueChangeEventFunc); 121 | }); 122 | } 123 | 124 | /** 125 | * Removes the attached event listeners, and re-attaches to them. 126 | */ 127 | reattachEventListeners(): void { 128 | this.detachEventListeners(); 129 | this.attachEventListeners(); 130 | } 131 | 132 | ngOnChanges(changes: SimpleChanges): void { 133 | if ( 134 | 'appFormValidator' in changes || 135 | 'appFormValidatorHideErrorText' in changes 136 | ) 137 | this.updateForm(); 138 | } 139 | 140 | /** 141 | * Updates a specific element state according to it's validation error. 142 | * @param el 143 | * @param error 144 | */ 145 | updateFormField(el: HTMLInputElement, error?: ValidationError): void { 146 | const name = el.name; 147 | 148 | // Check if this fields has been written, if not don't update it's validation state until it is 149 | if (!this.appFormValidatorForce && !this.fieldsWritten[name]) return; 150 | el.classList.remove('is-valid', 'is-invalid'); 151 | el.classList.add(error ? 'is-invalid' : 'is-valid'); 152 | 153 | // If we don't want to show any validation error text 154 | if (this.appFormValidatorHideErrorText) return; 155 | const validationDesc = el.nextElementSibling; 156 | 157 | if (validationDesc) { 158 | validationDesc.classList.remove('invalid-feedback', 'valid-feedback'); 159 | validationDesc.classList.add( 160 | error ? 'invalid-feedback' : 'valid-feedback' 161 | ); 162 | validationDesc.innerHTML = null; 163 | 164 | if (error) { 165 | let errHTML = `
    `; 166 | for (const key of Object.keys(error.constraints)) { 167 | const constraint = error.constraints[key]; 168 | errHTML += `
  • ${constraint}
  • `; 169 | } 170 | 171 | errHTML += `
`; 172 | validationDesc.innerHTML = errHTML; 173 | } 174 | } 175 | } 176 | 177 | /** 178 | * Goes through all of the form and checks for any issues, updates all of the fields accordingly. 179 | */ 180 | updateForm(): Promise { 181 | return validate(this.appFormValidator).then((validationErrors) => { 182 | const prevIsFormValid = this._isFormValid; 183 | this._isFormValid = true; 184 | 185 | this.getFormGroupInputs().forEach((input: HTMLInputElement) => { 186 | const name = input.name; 187 | const validationError = this.getValidationErrorFromFieldName( 188 | name, 189 | validationErrors 190 | ); 191 | this.updateFormField(input, validationError); 192 | 193 | if (validationError) this._isFormValid = false; 194 | }); 195 | 196 | if (prevIsFormValid !== this._isFormValid) 197 | this.appFormValidatorIsFormValidChange.emit(this._isFormValid); 198 | 199 | return this._isFormValid; 200 | }); 201 | } 202 | 203 | /** 204 | * Returns all of the form groups found according to bootstrap standard. 205 | */ 206 | getFormGroupInputs(): NodeListOf { 207 | const formElement = this.elementRef.nativeElement; 208 | return formElement.querySelectorAll('.form-group>input'); 209 | } 210 | 211 | ngOnDestroy(): void { 212 | this.detachEventListeners(); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /angular-src/src/app/shared/directives/index.ts: -------------------------------------------------------------------------------- 1 | export { FormValidatorDirective } from './form-validator.directive'; 2 | -------------------------------------------------------------------------------- /angular-src/src/app/shared/shared.module.spec.ts: -------------------------------------------------------------------------------- 1 | import { SharedModule } from './shared.module'; 2 | 3 | describe('SharedModule', () => { 4 | let sharedModule: SharedModule; 5 | 6 | beforeEach(() => { 7 | sharedModule = new SharedModule(); 8 | }); 9 | 10 | it('should create an instance', () => { 11 | expect(sharedModule).toBeTruthy(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /angular-src/src/app/shared/shared.module.ts: -------------------------------------------------------------------------------- 1 | import { CommonModule } from '@angular/common'; 2 | import { NgModule } from '@angular/core'; 3 | 4 | import { FormValidatorDirective } from './directives'; 5 | 6 | @NgModule({ 7 | imports: [CommonModule], 8 | exports: [FormValidatorDirective], 9 | declarations: [FormValidatorDirective], 10 | }) 11 | export class SharedModule {} 12 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login-button/social-login-button.component.css: -------------------------------------------------------------------------------- 1 | .social-login { 2 | display: inline-block; 3 | margin-left: 8px; 4 | } 5 | 6 | .social-login>.btn { 7 | font-size: 12px; 8 | margin-left: 10px; 9 | } -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login-button/social-login-button.component.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login-button/social-login-button.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; 2 | 3 | import { getCommonTestBed } from '../../testing/test_utils'; 4 | import { SocialLoginModule } from '../social-login.module'; 5 | import { SocialLoginButtonComponent } from './social-login-button.component'; 6 | 7 | describe('SocialLoginButtonComponent', () => { 8 | let component: SocialLoginButtonComponent; 9 | let fixture: ComponentFixture; 10 | 11 | beforeEach(waitForAsync(() => { 12 | getCommonTestBed([SocialLoginButtonComponent], [SocialLoginModule]).compileComponents(); 13 | })); 14 | 15 | beforeEach(waitForAsync(() => { 16 | fixture = TestBed.createComponent(SocialLoginButtonComponent); 17 | component = fixture.componentInstance; 18 | fixture.detectChanges(); 19 | })); 20 | 21 | it('should create', () => { 22 | expect(component).toBeTruthy(); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login-button/social-login-button.component.ts: -------------------------------------------------------------------------------- 1 | import { Component } from '@angular/core'; 2 | 3 | import { SocialLoginService } from '../social-login.service'; 4 | 5 | @Component({ 6 | selector: 'app-social-login-button', 7 | templateUrl: './social-login-button.component.html', 8 | styleUrls: ['./social-login-button.component.css'], 9 | }) 10 | export class SocialLoginButtonComponent { 11 | constructor(private socialLoginService: SocialLoginService) {} 12 | 13 | onSocialLoginClick(provider: string): Promise { 14 | return this.socialLoginService.signIn(provider); 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login.module.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FacebookLoginProvider, GoogleLoginProvider, SocialAuthServiceConfig, 3 | SocialLoginModule as NgxSocialLogin 4 | } from 'angularx-social-login'; 5 | 6 | import { CommonModule } from '@angular/common'; 7 | import { NgModule } from '@angular/core'; 8 | 9 | import { environment } from '../../environments/environment'; 10 | import { SocialLoginButtonComponent } from './social-login-button/social-login-button.component'; 11 | import { SocialLoginService } from './social-login.service'; 12 | 13 | const config: SocialAuthServiceConfig = { 14 | providers: [ 15 | { 16 | id: GoogleLoginProvider.PROVIDER_ID, 17 | provider: new GoogleLoginProvider(environment.socialLogin.google), 18 | }, 19 | { 20 | id: FacebookLoginProvider.PROVIDER_ID, 21 | provider: new FacebookLoginProvider(environment.socialLogin.facebook), 22 | }, 23 | ], 24 | }; 25 | 26 | export function provideConfig(): SocialAuthServiceConfig { 27 | return config; 28 | } 29 | 30 | @NgModule({ 31 | imports: [CommonModule, NgxSocialLogin], 32 | declarations: [SocialLoginButtonComponent], 33 | providers: [ 34 | { 35 | provide: 'SocialAuthServiceConfig', 36 | useFactory: provideConfig, 37 | }, 38 | SocialLoginService, 39 | ], 40 | exports: [NgxSocialLogin, SocialLoginButtonComponent], 41 | }) 42 | export class SocialLoginModule {} 43 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | FacebookLoginProvider, GoogleLoginProvider, SocialAuthService, SocialUser 3 | } from 'angularx-social-login'; 4 | import { Subject } from 'rxjs'; 5 | 6 | import { Injectable } from '@angular/core'; 7 | import { AuthService } from '@services'; 8 | 9 | import { UserProfile } from '../../../../shared/models'; 10 | 11 | @Injectable() 12 | export class SocialLoginService { 13 | loginStateChanged: Subject = new Subject(); 14 | 15 | constructor( 16 | private socialAuthService: SocialAuthService, 17 | private authService: AuthService, 18 | ) {} 19 | 20 | signIn(provider: string): Promise { 21 | return this.signInByProvider(provider) 22 | .then(socialUser => { 23 | if (socialUser) { 24 | const authToken = socialUser.authToken; 25 | 26 | // After the social login succeded, signout from the social service 27 | this.authService 28 | .socialLogin(provider, authToken) 29 | .then(result => { 30 | this.socialAuthService.signOut().then(() => { 31 | this.loginStateChanged.next(result); 32 | }); 33 | }) 34 | .catch(error => { 35 | this.loginStateChanged.error(error); 36 | }); 37 | } 38 | }) 39 | .catch(error => { 40 | console.error(error); 41 | this.loginStateChanged.error(error); 42 | }); 43 | } 44 | 45 | private signInByProvider(provider: string): Promise { 46 | switch (provider) { 47 | case 'google': 48 | return this.socialAuthService.signIn(GoogleLoginProvider.PROVIDER_ID); 49 | case 'facebook': 50 | return this.socialAuthService.signIn(FacebookLoginProvider.PROVIDER_ID); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /angular-src/src/app/testing/mock/api.service.mock.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of, throwError } from 'rxjs'; 2 | 3 | import { Injectable } from '@angular/core'; 4 | 5 | import { UserProfile } from '../../../../../shared/models'; 6 | import { LoginResponse } from '../../../../../shared/models/login-response'; 7 | import { generateMockRootUser } from '../../../../../shared/testing/mock/user.mock'; 8 | 9 | @Injectable() 10 | export class MockApiService { 11 | // The root user 12 | rootUser: UserProfile = generateMockRootUser(); 13 | 14 | // The list of users registered 15 | registeredUsers: UserProfile[] = []; 16 | 17 | login(username: string, password: string): Observable { 18 | if (username === 'admin' && password === 'admin') { 19 | return of({ 20 | token: 'randomtoken', 21 | user: this.rootUser, 22 | }); 23 | } 24 | 25 | // All other requests should return an error 26 | return throwError({ 27 | status: 'error', 28 | error: 'Invalid username\\password entered!', 29 | }); 30 | } 31 | 32 | socialLogin(): Observable { 33 | return throwError('Not implemented!'); 34 | } 35 | 36 | register(user: UserProfile): Observable { 37 | this.registeredUsers.push(user); 38 | return of(user); 39 | } 40 | 41 | getProfile(): Observable { 42 | return of(this.rootUser); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /angular-src/src/app/testing/mock/core.module.mock.ts: -------------------------------------------------------------------------------- 1 | import { SocialLoginModule } from 'angularx-social-login'; 2 | import { CookieModule } from 'ngx-cookie'; 3 | import { ToastrModule } from 'ngx-toastr'; 4 | 5 | import { CommonModule } from '@angular/common'; 6 | import { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; 7 | import { NgModule } from '@angular/core'; 8 | import { BrowserModule } from '@angular/platform-browser'; 9 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 10 | import { RouterTestingModule } from '@angular/router/testing'; 11 | import { AppHttpInterceptor } from '@core/app-http.interceptor'; 12 | import { FooterComponent } from '@core/components/footer/footer.component'; 13 | import { HeaderComponent } from '@core/components/header/header.component'; 14 | import { LoadingBarModule } from '@ngx-loading-bar/core'; 15 | import { ApiService, AppService, AuthGuardService, AuthService, RequestsService } from '@services'; 16 | import { SharedModule } from '@shared/shared.module'; 17 | 18 | import { MockApiService } from './api.service.mock'; 19 | 20 | @NgModule({ 21 | imports: [ 22 | CommonModule, 23 | HttpClientModule, 24 | BrowserModule, 25 | BrowserAnimationsModule, 26 | CookieModule.withOptions(), 27 | LoadingBarModule, 28 | ToastrModule.forRoot({ 29 | timeOut: 5000, 30 | positionClass: 'toast-bottom-right', 31 | }), 32 | SharedModule, 33 | SocialLoginModule, 34 | RouterTestingModule, 35 | ], 36 | declarations: [HeaderComponent, FooterComponent], 37 | providers: [ 38 | { 39 | useClass: MockApiService, 40 | provide: ApiService, 41 | }, 42 | AuthService, 43 | AuthGuardService, 44 | AppService, 45 | { 46 | provide: HTTP_INTERCEPTORS, 47 | useClass: AppHttpInterceptor, 48 | multi: true, 49 | }, 50 | RequestsService, 51 | ], 52 | exports: [ 53 | HeaderComponent, 54 | FooterComponent, 55 | LoadingBarModule, 56 | ToastrModule, 57 | SocialLoginModule, 58 | ], 59 | }) 60 | export class MockCoreModule {} 61 | -------------------------------------------------------------------------------- /angular-src/src/app/testing/test_utils.ts: -------------------------------------------------------------------------------- 1 | import { ComponentFixture, TestBed, TestBedStatic, tick } from '@angular/core/testing'; 2 | import { SharedModule } from '@shared/shared.module'; 3 | 4 | import { MockCoreModule } from './mock/core.module.mock'; 5 | 6 | /** 7 | * Returns the common test bed to be used across all of the project. 8 | * @param declarations 9 | * @param providers 10 | */ 11 | export function getCommonTestBed( 12 | declarations: unknown[], 13 | imports: unknown[] = [], 14 | providers: unknown[] = [] 15 | ): TestBed { 16 | const testBed = TestBed.configureTestingModule({ 17 | declarations: [...declarations], 18 | imports: [MockCoreModule, SharedModule, ...imports], 19 | providers: [...providers], 20 | }); 21 | 22 | return testBed; 23 | } 24 | 25 | /** 26 | * Returns the input validation related to the provided input element. 27 | * @param input 28 | */ 29 | export function getInputElementValidationDiv(input: HTMLInputElement): Element { 30 | return input.parentElement.querySelector('.input-description-validation'); 31 | } 32 | 33 | export function setInputValueWithEvent( 34 | input: HTMLInputElement, 35 | value: string 36 | ): void { 37 | input.value = value; 38 | input.dispatchEvent(new Event('input')); 39 | } 40 | 41 | export function tickAndDetectChanges(fixture: ComponentFixture): void { 42 | tick(); 43 | fixture.detectChanges(); 44 | } 45 | -------------------------------------------------------------------------------- /angular-src/src/assets/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/assets/.gitkeep -------------------------------------------------------------------------------- /angular-src/src/environments/environment.prod.ts: -------------------------------------------------------------------------------- 1 | export const environment = { 2 | production: true, 3 | apiServer: 'http://localhost:3000', 4 | socialLogin: { 5 | 'facebook': '223045385190067', 6 | 'google': '1086867360709-69ko7vlgcc3uuq8a42dmmvgjng1vg02l.apps.googleusercontent.com' 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /angular-src/src/environments/environment.ts: -------------------------------------------------------------------------------- 1 | // This file can be replaced during build by using the `fileReplacements` array. 2 | // `ng build ---prod` replaces `environment.ts` with `environment.prod.ts`. 3 | // The list of file replacements can be found in `angular.json`. 4 | 5 | export const environment = { 6 | production: false, 7 | apiServer: 'http://localhost:3000', 8 | socialLogin: { 9 | 'facebook': '223045385190067', 10 | 'google': '1086867360709-69ko7vlgcc3uuq8a42dmmvgjng1vg02l.apps.googleusercontent.com' 11 | } 12 | }; 13 | 14 | /* 15 | * In development mode, to ignore zone related error stack frames such as 16 | * `zone.run`, `zoneDelegate.invokeTask` for easier debugging, you can 17 | * import the following file, but please comment it out in production mode 18 | * because it will have performance impact when throw error 19 | */ 20 | // import 'zone.js/plugins/zone-error'; // Included with Angular CLI. 21 | -------------------------------------------------------------------------------- /angular-src/src/express.tokens.ts: -------------------------------------------------------------------------------- 1 | 2 | import { InjectionToken } from '@angular/core'; 3 | import { Request, Response } from 'express'; 4 | 5 | export const REQUEST = new InjectionToken('REQUEST'); 6 | export const RESPONSE = new InjectionToken('RESPONSE'); 7 | -------------------------------------------------------------------------------- /angular-src/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nestjs-angular-starter/55f3333a090743b69afcadbb719bdf985e3115ac/angular-src/src/favicon.ico -------------------------------------------------------------------------------- /angular-src/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | NodeJS-Angular-Starter 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /angular-src/src/karma.conf.js: -------------------------------------------------------------------------------- 1 | // Karma configuration file, see link for more information 2 | // https://karma-runner.github.io/1.0/config/configuration-file.html 3 | 4 | module.exports = function (config) { 5 | const isRunningInPipeline = process.env.IS_CI === 'true'; 6 | 7 | config.set({ 8 | basePath: '', 9 | frameworks: ['jasmine', '@angular-devkit/build-angular'], 10 | plugins: [ 11 | require('karma-jasmine'), 12 | require('karma-chrome-launcher'), 13 | require('karma-jasmine-html-reporter'), 14 | require('karma-coverage-istanbul-reporter'), 15 | require('karma-junit-reporter'), 16 | require('karma-mocha-reporter'), 17 | require('@angular-devkit/build-angular/plugins/karma'), 18 | ], 19 | client: { 20 | clearContext: false, // leave Jasmine Spec Runner output visible in browser 21 | }, 22 | coverageIstanbulReporter: { 23 | dir: require('path').join(__dirname, '../coverage'), 24 | reports: ['html', 'lcovonly'], 25 | fixWebpackSourcePaths: true, 26 | }, 27 | junitReporter: { 28 | outputFile: 'TEST-Angular.xml', 29 | }, 30 | reporters: isRunningInPipeline ? ['mocha', 'junit'] : ['progress', 'kjhtml'], 31 | port: 9876, 32 | colors: true, 33 | logLevel: config.LOG_INFO, 34 | autoWatch: true, 35 | browsers: ['Chrome'], 36 | singleRun: false, 37 | customLaunchers: { 38 | Headless_Chrome: { 39 | base: 'ChromeHeadless', 40 | flags: ['--no-sandbox', '--disable-gpu'], 41 | }, 42 | ChromeDebug: { 43 | base: 'Chrome', 44 | flags: ['--remote-debugging-port=9333'], 45 | }, 46 | }, 47 | }); 48 | }; 49 | -------------------------------------------------------------------------------- /angular-src/src/main.server.ts: -------------------------------------------------------------------------------- 1 | export { AppServerModule as default } from './app/app.server.module'; 2 | -------------------------------------------------------------------------------- /angular-src/src/main.ts: -------------------------------------------------------------------------------- 1 | import { enableProdMode } from '@angular/core'; 2 | import { platformBrowserDynamic } from '@angular/platform-browser-dynamic'; 3 | 4 | import { AppModule } from './app/app.module'; 5 | import { environment } from './environments/environment'; 6 | 7 | if (environment.production) { 8 | enableProdMode(); 9 | } 10 | 11 | document.addEventListener('DOMContentLoaded', () => { 12 | platformBrowserDynamic() 13 | .bootstrapModule(AppModule) 14 | .catch((err) => console.error(err)); 15 | }); 16 | -------------------------------------------------------------------------------- /angular-src/src/polyfills.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file includes polyfills needed by Angular and is loaded before the app. 3 | * You can add your own extra polyfills to this file. 4 | * 5 | * This file is divided into 2 sections: 6 | * 1. Browser polyfills. These are applied before loading ZoneJS and are sorted by browsers. 7 | * 2. Application imports. Files imported after ZoneJS that should be loaded before your main 8 | * file. 9 | * 10 | * The current setup is for so-called "evergreen" browsers; the last versions of browsers that 11 | * automatically update themselves. This includes Safari >= 10, Chrome >= 55 (including Opera), 12 | * Edge >= 13 on the desktop, and iOS 10 and Chrome on mobile. 13 | * 14 | * Learn more in https://angular.io/docs/ts/latest/guide/browser-support.html 15 | */ 16 | /*************************************************************************************************** 17 | * BROWSER POLYFILLS 18 | */ 19 | /** IE9, IE10 and IE11 requires all of the following polyfills. **/ 20 | // import 'core-js/es6/symbol'; 21 | // import 'core-js/es6/object'; 22 | // import 'core-js/es6/function'; 23 | // import 'core-js/es6/parse-int'; 24 | // import 'core-js/es6/parse-float'; 25 | // import 'core-js/es6/number'; 26 | // import 'core-js/es6/math'; 27 | // import 'core-js/es6/string'; 28 | // import 'core-js/es6/date'; 29 | // import 'core-js/es6/array'; 30 | // import 'core-js/es6/regexp'; 31 | // import 'core-js/es6/map'; 32 | // import 'core-js/es6/weak-map'; 33 | // import 'core-js/es6/set'; 34 | /** IE10 and IE11 requires the following for the Reflect API. */ 35 | // import 'core-js/es6/reflect'; 36 | /** Evergreen browsers require these. **/ 37 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 38 | // import 'core-js/es7/reflect'; 39 | /** 40 | * By default, zone.js will patch all possible macroTask and DomEvents 41 | * user can disable parts of macroTask/DomEvents patch by setting following flags 42 | */ 43 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 44 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 45 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 46 | /* 47 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 48 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 49 | */ 50 | // (window as any).__Zone_enable_cross_context_check = true; 51 | /*************************************************************************************************** 52 | * Zone JS is required by default for Angular itself. 53 | */ 54 | import 'zone.js'; // Included with Angular CLI. 55 | 56 | /*************************************************************************************************** 57 | * APPLICATION IMPORTS 58 | */ 59 | -------------------------------------------------------------------------------- /angular-src/src/styles.scss: -------------------------------------------------------------------------------- 1 | html { 2 | width: 100%; 3 | height: 100%; 4 | } 5 | 6 | body { 7 | padding-top: 4.5rem; 8 | width: 100%; 9 | height: 100%; 10 | font-family: 'Roboto', 'Arial', 'sans-serif'; 11 | } 12 | 13 | .site_wrapper { 14 | min-height: 100%; 15 | height: auto !important; 16 | height: 100%; 17 | margin: 0 auto -84px; 18 | } 19 | 20 | .site_wrapper::after { 21 | content: ""; 22 | height: 84px; 23 | display: block; 24 | } 25 | 26 | .toast { 27 | font-size: 0.85rem; 28 | } -------------------------------------------------------------------------------- /angular-src/src/test.ts: -------------------------------------------------------------------------------- 1 | // This file is required by karma.conf.js and loads recursively all the .spec and framework files 2 | import 'zone.js/testing'; 3 | 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, platformBrowserDynamicTesting 7 | } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | // First, initialize the Angular testing environment. 10 | getTestBed().initTestEnvironment( 11 | BrowserDynamicTestingModule, 12 | platformBrowserDynamicTesting(), { 13 | teardown: { destroyAfterEach: false } 14 | } 15 | ); 16 | -------------------------------------------------------------------------------- /angular-src/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [], 6 | }, 7 | "files": [ 8 | "main.ts", 9 | "polyfills.ts" 10 | ], 11 | } -------------------------------------------------------------------------------- /angular-src/src/tsconfig.server.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.app.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/app", 5 | "types": [ 6 | "node" 7 | ] 8 | , 9 | }, 10 | "angularCompilerOptions": { 11 | "entryModule": "app/app.server.module#AppServerModule" 12 | }, 13 | "files": [ 14 | "main.server.ts", 15 | "../server.ts" 16 | ] 17 | } -------------------------------------------------------------------------------- /angular-src/src/tsconfig.spec.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.json", 3 | "compilerOptions": { 4 | "outDir": "../out-tsc/spec", 5 | "types": [ 6 | "jasmine", 7 | "node" 8 | ] 9 | }, 10 | "files": [ 11 | "test.ts", 12 | "polyfills.ts" 13 | ], 14 | "include": [ 15 | "**/*.spec.ts", 16 | "**/*.d.ts" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /angular-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "baseUrl": "./", 5 | "module": "es2020", 6 | "outDir": "./dist/out-tsc", 7 | "sourceMap": true, 8 | "declaration": false, 9 | "allowSyntheticDefaultImports": true, 10 | "moduleResolution": "node", 11 | "experimentalDecorators": true, 12 | "target": "ES2022", 13 | "typeRoots": [ 14 | "node_modules/@types" 15 | ], 16 | "lib": [ 17 | "es2017", 18 | "dom" 19 | ], 20 | "paths": { 21 | "@services": [ 22 | "src/app/core/services/index.ts" 23 | ], 24 | "@core/*": [ 25 | "src/app/core/*" 26 | ], 27 | "@shared/*": [ 28 | "src/app/shared/*" 29 | ] 30 | }, 31 | "useDefineForClassFields": false 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /angular-src/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rulesDirectory": [ 3 | "node_modules/codelyzer" 4 | ], 5 | "rules": { 6 | "arrow-return-shorthand": true, 7 | "callable-types": true, 8 | "class-name": true, 9 | "comment-format": [ 10 | true, 11 | "check-space" 12 | ], 13 | "curly": false, 14 | "deprecation": { 15 | "severity": "warn" 16 | }, 17 | "eofline": true, 18 | "forin": true, 19 | "import-blacklist": [ 20 | true, 21 | "rxjs/Rx" 22 | ], 23 | "import-spacing": true, 24 | "indent": [ 25 | true, 26 | "spaces" 27 | ], 28 | "interface-over-type-literal": true, 29 | "label-position": true, 30 | "max-line-length": [ 31 | true, 32 | 140 33 | ], 34 | "member-access": false, 35 | "member-ordering": [ 36 | true, 37 | { 38 | "order": [ 39 | "static-field", 40 | "instance-field", 41 | "static-method", 42 | "instance-method" 43 | ] 44 | } 45 | ], 46 | "no-arg": true, 47 | "no-bitwise": true, 48 | "no-console": [ 49 | true, 50 | "debug", 51 | "info", 52 | "time", 53 | "timeEnd", 54 | "trace" 55 | ], 56 | "no-construct": true, 57 | "no-debugger": true, 58 | "no-duplicate-super": true, 59 | "no-empty": false, 60 | "no-empty-interface": true, 61 | "no-eval": true, 62 | "no-inferrable-types": [ 63 | true, 64 | "ignore-params" 65 | ], 66 | "no-misused-new": true, 67 | "no-non-null-assertion": true, 68 | "no-shadowed-variable": true, 69 | "no-string-literal": false, 70 | "no-string-throw": true, 71 | "no-switch-case-fall-through": true, 72 | "no-trailing-whitespace": true, 73 | "no-unnecessary-initializer": true, 74 | "no-unused-expression": true, 75 | "no-var-keyword": true, 76 | "object-literal-sort-keys": false, 77 | "one-line": [ 78 | true, 79 | "check-open-brace", 80 | "check-catch", 81 | "check-else", 82 | "check-whitespace" 83 | ], 84 | "prefer-const": true, 85 | "quotemark": [ 86 | true, 87 | "single" 88 | ], 89 | "radix": true, 90 | "semicolon": [ 91 | true, 92 | "always" 93 | ], 94 | "triple-equals": [ 95 | true, 96 | "allow-null-check" 97 | ], 98 | "typedef-whitespace": [ 99 | true, 100 | { 101 | "call-signature": "nospace", 102 | "index-signature": "nospace", 103 | "parameter": "nospace", 104 | "property-declaration": "nospace", 105 | "variable-declaration": "nospace" 106 | } 107 | ], 108 | "unified-signatures": true, 109 | "variable-name": false, 110 | "whitespace": [ 111 | true, 112 | "check-branch", 113 | "check-decl", 114 | "check-operator", 115 | "check-separator", 116 | "check-type" 117 | ], 118 | "no-output-on-prefix": true, 119 | "use-input-property-decorator": true, 120 | "use-output-property-decorator": true, 121 | "use-host-property-decorator": true, 122 | "no-input-rename": true, 123 | "no-output-rename": true, 124 | "use-life-cycle-interface": true, 125 | "use-pipe-transform-interface": true, 126 | "component-class-suffix": true, 127 | "directive-class-suffix": true 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /ansible/ansible.cfg: -------------------------------------------------------------------------------- 1 | [defaults] 2 | inventory = ./inventory 3 | host_key_checking = False 4 | remote_user = root 5 | # Replace with your own website ssh key if required 6 | private_key_file = ~/.ssh/id_rsa -------------------------------------------------------------------------------- /ansible/inventory: -------------------------------------------------------------------------------- 1 | [all] 2 | 3 | main ansible_host=yourownwebsite.com -------------------------------------------------------------------------------- /ansible/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Setup server 3 | hosts: all 4 | tags: setup 5 | roles: 6 | - install-common-deps 7 | - docker-compose 8 | - python37 9 | 10 | - name: Setup app 11 | hosts: all 12 | tags: app 13 | roles: 14 | - app 15 | vars: 16 | # TODO: Replace with your own git repository 17 | - git_repo: 'git@github.com:shy2net/nestjs-angular-starter.git' 18 | - app_dir: '~/app' 19 | -------------------------------------------------------------------------------- /ansible/roles/app/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | - name: Create user 'deployment' 3 | user: 4 | name: deployment 5 | comment: User for app deployment 6 | group: docker 7 | become: yes 8 | 9 | - name: Clone app 10 | # Make sure the deployment user the access key generated (ssh-keygen) 11 | git: 12 | repo: '{{ git_repo }}' 13 | dest: '{{ app_dir }}' 14 | become: yes 15 | become_user: deployment 16 | 17 | - name: Start app 18 | vars: 19 | ansible_python_interpreter: python3 20 | docker_compose: 21 | project_src: '{{ app_dir }}' 22 | state: present 23 | become: yes 24 | become_user: deployment 25 | retries: 5 26 | delay: 15 27 | -------------------------------------------------------------------------------- /ansible/roles/docker-compose/tasks/install_docker_with_compose.yml: -------------------------------------------------------------------------------- 1 | # Install docker and docker-compose 2 | --- 3 | - name: Update yum 4 | yum: 5 | name: '*' 6 | state: latest 7 | become: yes 8 | 9 | - name: Install yum utils 10 | yum: 11 | name: yum-utils 12 | state: latest 13 | become: yes 14 | 15 | - name: Add docker repository 16 | get_url: 17 | url: https://download.docker.com/linux/centos/docker-ce.repo 18 | dest: /etc/yum.repos.d/docer-ce.repo 19 | become: yes 20 | 21 | - name: Install docker 22 | yum: 23 | name: 24 | - docker-ce 25 | - docker-ce-cli 26 | - containerd.io 27 | state: present 28 | become: yes 29 | 30 | - name: Create docker group 31 | group: 32 | name: docker 33 | state: present 34 | become: yes 35 | 36 | - name: Add user to docker group 37 | user: 38 | name: centos 39 | groups: docker 40 | append: yes 41 | become: yes 42 | 43 | - name: Start docker service 44 | service: 45 | name: docker 46 | enabled: true 47 | state: started 48 | become: yes 49 | 50 | - name: Install docker-compose 51 | get_url: 52 | url: https://github.com/docker/compose/releases/download/1.26.2/docker-compose-Linux-x86_64 53 | dest: /usr/local/bin/docker-compose 54 | become: yes 55 | 56 | - name: Enable execute on docker-compose 57 | file: 58 | path: /usr/local/bin/docker-compose 59 | state: file 60 | mode: a+x 61 | become: yes 62 | 63 | - name: reset ssh connection to allow user changes to affect 'current login user' 64 | ansible.builtin.meta: 65 | reset_connection -------------------------------------------------------------------------------- /ansible/roles/docker-compose/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Installs Docker-Compose 2 | --- 3 | - include_tasks: install_docker_with_compose.yml 4 | tags: install_docker_with_compose -------------------------------------------------------------------------------- /ansible/roles/docker-compose/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for playbooks/roles/docker-compose 3 | -------------------------------------------------------------------------------- /ansible/roles/install-common-deps/tasks/main.yml: -------------------------------------------------------------------------------- 1 | # Install most common dependencies 2 | --- 3 | 4 | - name: Install git 5 | yum: name=git state=latest 6 | become: yes 7 | 8 | # - name: Install dependencies for Python 2.7 9 | # yum: 10 | # name: 11 | # - scl-utils 12 | # - centos-release-scl-rh 13 | # state: present 14 | # become: yes 15 | 16 | # - name: Install Python 2.7 17 | # yum: 18 | # name: 19 | # - python27 20 | # state: present 21 | # become: yes 22 | 23 | # - name: Install Python 2.7 Pip 24 | # shell: | 25 | # curl https://bootstrap.pypa.io/pip/2.7/get-pip.py -o /tmp/get-pip.py 26 | # python /tmp/get-pip.py 27 | # become: yes 28 | 29 | - name: Update yum 30 | yum: 31 | name: '*' 32 | state: latest 33 | become: yes 34 | 35 | - name: Install yum utils 36 | yum: 37 | name: yum-utils 38 | state: latest 39 | become: yes 40 | 41 | - name: Install Python 3 42 | yum: 43 | name: 44 | - python3 45 | - python3-pip 46 | state: present 47 | become: yes 48 | 49 | - name: Install docker module for Python 50 | pip: 51 | executable: pip3 52 | name: 53 | - docker 54 | - docker-compose 55 | state: present 56 | become: yes 57 | 58 | -------------------------------------------------------------------------------- /ansible/roles/install-common-deps/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for playbooks/roles/install-common-deps 3 | -------------------------------------------------------------------------------- /ansible/roles/python37/tasks/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # tasks file for playbooks/roles/python37 3 | - name: Check if python3.7 installed 4 | command: which python3.7 5 | ignore_errors: yes 6 | register: installed_python 7 | 8 | - name: Print python output 9 | debug: 10 | msg: '{{ installed_python }}' 11 | 12 | - name: Setup python3.7 13 | block: 14 | - name: Set variables 15 | set_fact: 16 | install_path: '/tmp/python3.7' 17 | 18 | - name: Install required deps 19 | yum: 20 | name: 21 | - gcc 22 | - openssl-devel 23 | - bzip2-devel 24 | - libffi-devel 25 | - zlib-devel 26 | - xz-devel 27 | state: present 28 | 29 | become: yes 30 | 31 | - name: Download Python 3.7 32 | get_url: 33 | url: https://www.python.org/ftp/python/3.7.11/Python-3.7.11.tgz 34 | dest: '{{ install_path }}.tgz' 35 | 36 | # - name: Extract Python 3.7 37 | # unarchive: 38 | # src: '{{ install_path }}.tgz' 39 | # dest: '{{ install_path }}' 40 | # become: yes 41 | 42 | - name: Extract & Install Python 3.7 43 | shell: | 44 | cd /tmp 45 | tar xzf python3.7.tgz 46 | cd Python-3.7.11 47 | ./configure --enable-optimizations 48 | make altinstall 49 | 50 | become: yes 51 | when: installed_python.rc != 0 -------------------------------------------------------------------------------- /ansible/roles/python37/vars/main.yml: -------------------------------------------------------------------------------- 1 | --- 2 | # vars file for playbooks/roles/python37 3 | -------------------------------------------------------------------------------- /certs/fullchain.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDdTCCAl2gAwIBAgIUDT7D94dc74rIcKBfDLaxHDn8JFQwDQYJKoZIhvcNAQEL 3 | BQAwSjELMAkGA1UEBhMCaWwxCzAJBgNVBAgMAmlsMQswCQYDVQQHDAJpbDEhMB8G 4 | A1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMB4XDTIyMTAyMzIwNDAyMVoX 5 | DTIzMTAyMzIwNDAyMVowSjELMAkGA1UEBhMCaWwxCzAJBgNVBAgMAmlsMQswCQYD 6 | VQQHDAJpbDEhMB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjAN 7 | BgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAwX7JX2p4zuD4VEDsN07IbusQnR38 8 | HycBl/+xxBrBal8qjE20UaOa6Ln3Bb4yQeuLgo8seDH4Bf99ifMTxh1wGdyRjnof 9 | NKM6Znj7KLe2BjlMkedRlpP+s9V20ocy55rB0q9+BfBrpDdltiGfZk9UiXomAU9T 10 | gevym8Yci2Zo0SUw4LE1kefH029xZfx0n33EO4qCmFtkJtEvus36GAVqsFZFSYS9 11 | OzkrNUJ5AfCIBq7AUKvZ+Oy/YDqRLd1JqNplK32/FiRkldZ9YoKxp+8k5BhRK8Rn 12 | cDKkxcO/84SCslh4yCgn6dgh9Js2H+s60V/YnpcVDzF8AI5UZuNxummW5QIDAQAB 13 | o1MwUTAdBgNVHQ4EFgQUX3BzIYs7ATmtk/rXI++B09ql9CIwHwYDVR0jBBgwFoAU 14 | X3BzIYs7ATmtk/rXI++B09ql9CIwDwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0B 15 | AQsFAAOCAQEAoA1tzc8YNPLlUSCwC4TmSaCyYlmjgpAXxBWjpck5vIBhx/fXkTwf 16 | WRWD4lOLWn/T0n1FrmcVl9Ll6hXxPaaOLZ+jbUpwc+GlDMx+3oz670+Y2DTMeZbt 17 | O73mYAZ1TIeYGiKxFZUXtF6BWguoCI2BELIHqBm/nkF+bObfS0QP4sl/h0cCp0BS 18 | n40RZK0oHtzgUhRdWZzJQAXrXx5Z2uNpAagLngFfVnkBfLz7ROtogXYg7vYXrESD 19 | 47i72XihiXQPuiuQrFJRF4O47k/aOncHJ8uGEhuqnZTfV58Im8zdRiWfZbxU3+/v 20 | m6bzLZdyj+LZPuJATXPjw77fxQUui/qZQg== 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /certs/privkey.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN PRIVATE KEY----- 2 | MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQDBfslfanjO4PhU 3 | QOw3Tshu6xCdHfwfJwGX/7HEGsFqXyqMTbRRo5roufcFvjJB64uCjyx4MfgF/32J 4 | 8xPGHXAZ3JGOeh80ozpmePsot7YGOUyR51GWk/6z1XbShzLnmsHSr34F8GukN2W2 5 | IZ9mT1SJeiYBT1OB6/KbxhyLZmjRJTDgsTWR58fTb3Fl/HSffcQ7ioKYW2Qm0S+6 6 | zfoYBWqwVkVJhL07OSs1QnkB8IgGrsBQq9n47L9gOpEt3Umo2mUrfb8WJGSV1n1i 7 | grGn7yTkGFErxGdwMqTFw7/zhIKyWHjIKCfp2CH0mzYf6zrRX9ielxUPMXwAjlRm 8 | 43G6aZblAgMBAAECggEAVFcFQ1fPbK1W0LKzw6/NYbuINFPbj2CbKzvCqm3XHJx6 9 | mUlNbcBYR1S3vYMXuPAIkVIC2ik9qK+icrzHQ7WVJVClCWtlqrXzQLM/FpJs/u1+ 10 | 2KHxSCceABjtf/p6T8V+8myYC9KtuJiE9wxUxG2TtZDOfAetqJXF2+xQiNqMmYxQ 11 | P4h9fhF6IP+cr4oyWYof5I6N//wgXuT2y6DcJqxM3hxgcoQknyztfnJ0fh9fBGIY 12 | mhH4YFcI8bc0XOsOkQ/S4J7ugrkB7Bt52gS9MGlAYfWJ+LsOKugkXfscLyEXk18C 13 | X6dI/tBAE1Wg8Rji82s6Cx1/WmQfHcoAhSDO/rFLCQKBgQDn7P8Wj6yKQbnX0lei 14 | IzdfhmHawSLASIyc5Pp2mdJ1CJzQ+4MemTlUF/nNDRsfq7fwyd3ziSKpNPuEyKhy 15 | lG9/JExY6D6/aj1YoXxQH2CL9V34/qRBxjJUN2AQhyLklp/HgCW1M9P5h4CYuuCZ 16 | apHHcuyc5ul3IL0Zdv7F31TFzwKBgQDVlJHU9k28VlBDiLh3nbvVJypjoycSbbb/ 17 | 1xDnPau2tFlqa8D+3qNnPfoD7+yALYAYWQ8DCiaq2tKX2zON5b3UqAV6Bgoq1UQ9 18 | ot9Z1X+Nc2E2Q0ULGhdoEhPMlSVH9tkHU1uu00GON4iXIgcHBkkreqP6FcoBBYSk 19 | B6E+CyY5CwKBgQCe4kRKz3iTar2HNgllAR0xKt2kwEUvV0RFQ7S2RWDhXloX7QYB 20 | k1Sr4JT4Pa9EPh6QLasI6Py+0JYyfEix3jCX+GT5T3mVJpIKQu7n8ObyWtDbAI/E 21 | o6Pc/+amy/1CflHNmWO7xFrGima1uuidEV33NisUvjhYrSv+4v9czUfFDwKBgQCL 22 | g8NHM6VP06oWk2a8bELYV/8umtLZDOYvdRNLmlNiw6htBfpKArEGrct1avI4QICe 23 | Zv6RY74ieVJ7c+rXVC2OjjsVnIBjvnAXOx9fFUF+6tGjmomtWNvVVYmtZ/e0bKEU 24 | UfVf3AvElK4a21Vv10FQr4oRwM31oN8mLv27A5lyRQKBgBp1xJjynVAQzNOgQQs4 25 | XlK4N31j8wa2y20ua1VHAZaXQdXYyiQWxr9h7KjQBgx+hH7tdKLLAQP6n4Thjv2Z 26 | At4jITndovSDNzi1o5KiPGS227sdHQR+dvGJFdm39vTKg2AZI4snyqfkEMcq7Tuk 27 | wJJM+B73iSQ8h13AQ1okRI5g 28 | -----END PRIVATE KEY----- 29 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | web: 5 | image: nodejs-angular-starter 6 | build: 7 | context: . 8 | args: 9 | - NODE_ENV 10 | env_file: 11 | - .env 12 | ports: 13 | - 3000:3000 14 | depends_on: 15 | - db 16 | volumes: 17 | - '~/dockers_data/web/logs:/logs' 18 | db: 19 | image: mongo:latest 20 | env_file: 21 | - .env 22 | ports: 23 | - 27017:27017 24 | volumes: 25 | - 'mongodata:/data/db' 26 | proxy: 27 | image: nginx:latest 28 | restart: always 29 | ports: 30 | - 80:80 31 | - 443:443 32 | 33 | volumes: 34 | - ./nginx.conf:/etc/nginx/nginx.conf:ro 35 | - ./certs:/ssl 36 | 37 | volumes: 38 | mongodata: 39 | -------------------------------------------------------------------------------- /nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "collection": "@nestjs/schematics", 3 | "sourceRoot": "src" 4 | } 5 | -------------------------------------------------------------------------------- /nginx.conf: -------------------------------------------------------------------------------- 1 | events {} 2 | http { 3 | # Redirect all HTTP traffic into HTTPS 4 | server { 5 | listen 80; 6 | # TODO: Change to your domain 7 | server_name yourwebsite.com; 8 | return 301 https://$host$request_uri; 9 | } 10 | 11 | server { 12 | listen 443 ssl; 13 | # TODO: Change to your domain 14 | server_name yourwebsite.com; 15 | 16 | ssl_certificate /ssl/fullchain.pem; 17 | ssl_certificate_key /ssl/privkey.pem; 18 | # ssl_client_certificate /ssl/chain.crt; 19 | 20 | location / { 21 | # Redirect everything to our web microservice 22 | proxy_pass http://web:3000; 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nestjs-angular-starter", 3 | "version": "0.6.1", 4 | "description": "", 5 | "author": "", 6 | "private": true, 7 | "license": "UNLICENSED", 8 | "scripts": { 9 | "install:all": "bash ./scripts/install_all.sh", 10 | "build": "bash ./scripts/build.sh", 11 | "build:nest": "rm -rf dist && tsc -p .", 12 | "build:angular": "cd angular-src && ng build --aot --prod", 13 | "format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\"", 14 | "angular": "cd angular-src && npm start", 15 | "start": "node dist/src/main.js", 16 | "start:debug": "node --inspect-brk dist/src/main.js", 17 | "start:dev": "npm run copy:essentials && nest start --watch", 18 | "copy:essentials": "bash ./scripts/copy-essentials.sh", 19 | "ng:test": "cd ./angular-src && ./node_modules/.bin/ng test", 20 | "ng:test:ci": "cross-env IS_CI=true; cd ./angular-src && ./node_modules/.bin/ng test --no-watch --no-progress --browsers=Headless_Chrome", 21 | "ng:e2e": "cd ./angular-src && ./node_modules/.bin/ng e2e", 22 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 23 | "test:verbose": "cross-env NODE_ENV=test-verbose mocha", 24 | "test": "cross-env NODE_ENV=test mocha", 25 | "test:ci": "cross-env NODE_ENV=test mocha --reporter json > test-results.json" 26 | }, 27 | "dependencies": { 28 | "@nestjs/common": "^9.0.7", 29 | "@nestjs/core": "^9.0.7", 30 | "@nestjs/jwt": "^9.0.0", 31 | "@nestjs/passport": "^9.0.0", 32 | "@nestjs/platform-express": "^9.0.7", 33 | "@nestjs/serve-static": "^3.0.0", 34 | "bcryptjs": "^2.4.3", 35 | "class-transformer": "^0.3.1", 36 | "class-transformer-validator": "^0.9.1", 37 | "class-validator": "^0.12.2", 38 | "config": "^1.30.0", 39 | "jsonwebtoken": "^8.5.1", 40 | "lodash": "^4.17.20", 41 | "mongoose": "^5.10.6", 42 | "passport": "^0.4.1", 43 | "passport-facebook-token": "^4.0.0", 44 | "passport-google-token": "^0.1.2", 45 | "passport-jwt": "^4.0.0", 46 | "passport-local": "^1.0.0", 47 | "randomstring": "^1.1.5", 48 | "reflect-metadata": "^0.1.13", 49 | "rimraf": "^3.0.2", 50 | "rxjs": "^7.5.7" 51 | }, 52 | "devDependencies": { 53 | "@nestjs/cli": "^9.0.0", 54 | "@nestjs/schematics": "^9.0.1", 55 | "@nestjs/testing": "^9.0.7", 56 | "@types/bcryptjs": "^2.4.2", 57 | "@types/chai": "^4.3.0", 58 | "@types/express": "^4.17.3", 59 | "@types/faker": "^5.1.0", 60 | "@types/jsonwebtoken": "^8.5.8", 61 | "@types/lodash": "^4.14.161", 62 | "@types/mocha": "^9.1.0", 63 | "@types/mongoose": "^5.7.36", 64 | "@types/node": "^13.9.1", 65 | "@types/passport-jwt": "^3.0.3", 66 | "@types/passport-local": "^1.0.33", 67 | "@types/randomstring": "^1.1.6", 68 | "@types/supertest": "^2.0.8", 69 | "@typescript-eslint/eslint-plugin": "3.9.1", 70 | "@typescript-eslint/parser": "3.9.1", 71 | "chai": "^4.3.6", 72 | "cross-env": "^7.0.3", 73 | "eslint": "7.7.0", 74 | "eslint-config-prettier": "^6.10.0", 75 | "eslint-plugin-import": "^2.20.1", 76 | "faker": "^5.1.0", 77 | "mocha": "^9.2.0", 78 | "mongodb-memory-server": "^7.6.3", 79 | "prettier": "^1.19.1", 80 | "supertest": "^4.0.2", 81 | "ts-loader": "^6.2.1", 82 | "ts-node": "^10.9.1", 83 | "tsconfig-paths": "^3.9.0", 84 | "typescript": "5.2" 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /rest.http: -------------------------------------------------------------------------------- 1 | @api = http://localhost:3000/api 2 | @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6W10sIl9pZCI6IjVmNmUxMWY0M2Q4NWFkYzZhMjY4OThjZSIsImVtYWlsIjoiam9obm55QGJyYXZvLmNvbSIsImZpcnN0TmFtZSI6IkpvaG5ueSIsImxhc3ROYW1lIjoiQnJhdm8iLCJfX3YiOjAsImlhdCI6MTYwMTA0OTA5M30.ekaqSEkw1RAma73fByKyCd4NoeaeW8qzyQ2pvyffvLM 3 | 4 | ### Test API 5 | GET {{api}}/test 6 | 7 | ### Test login 8 | POST {{api}}/login HTTP/1.1 9 | content-type: application/json 10 | 11 | { 12 | "username": "johnny@bravo.com", 13 | "password": "mypass" 14 | } 15 | 16 | ### Get user profile 17 | GET {{api}}/profile HTTP/1.1 18 | Authorization: Bearer {{token}} 19 | 20 | ### Register an example user 21 | POST {{api}}/register HTTP/1.1 22 | content-type: application/json 23 | 24 | { 25 | "email":"johnny@bravo.com", 26 | "password": "mypass", 27 | "firstName": "Johnny", 28 | "lastName": "Bravo" 29 | } -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | check_errcode() { 4 | status=$? 5 | 6 | if [ $status -ne 0 ]; then 7 | echo "${1}" 8 | exit $status 9 | fi 10 | } 11 | 12 | echo "Checking for missing dependencies before build..." 13 | 14 | # Check if node_modules exists, if not throw an error 15 | if [ ! -d "./node_modules" ] || [ ! -d "./angular-src/node_modules" ]; then 16 | echo "node_modules are missing! running install script..." 17 | npm run install:all 18 | echo "Installed all missing dependencies! starting installation..." 19 | else 20 | echo "All dependencies are installed! Ready to run build!" 21 | fi 22 | 23 | # This script compiles typescript and Angular 7 application and puts them into a single NodeJS project 24 | ENV=${NODE_ENV:-production} 25 | echo -e "\n-- Started build script for Angular & NodeJS (environment $ENV) --" 26 | echo "Removing dist directory..." 27 | rm -rf dist 28 | 29 | echo "Compiling typescript..." 30 | ./node_modules/.bin/tsc -p ./tsconfig.build.json 31 | check_errcode "Failed to compile typescript! aborting script!" 32 | 33 | echo "Copying essential files..." 34 | bash ./scripts/copy-essentials.sh 35 | 36 | check_errcode "Failed to copy essential files! aborting script!" 37 | 38 | echo "Starting to configure Angular app..." 39 | pushd angular-src 40 | 41 | echo "Building Angular app for $ENV..." 42 | ./node_modules/.bin/ng build --aot --configuration $ENV 43 | check_errcode "Failed to build angular! stopping script!" 44 | 45 | # TODO: Remove this 'if' statment until the 'fi' if you don't want SSR at all 46 | if [ $ENV == "production" ]; then 47 | echo "Building Angular app for SSR..." 48 | ./node_modules/.bin/ng run angular-src:server:production 49 | check_errcode "Failed to build Angular app for SSR! aborting script!" 50 | else 51 | echo "Skipping build for SSR as environment is NOT production" 52 | fi 53 | 54 | echo "Copying angular dist into dist directory..." 55 | mkdir ../dist/angular 56 | cp -Rf dist/* ../dist/angular 57 | check_errcode "Failed to copy angular dist files! aborting script!" 58 | 59 | echo "Removing angular-src dist directory..." 60 | rm -rf dist 61 | 62 | # Go back to the current directory 63 | popd 64 | 65 | echo "-- Finished building Angular & NodeJS, check dist directory --" 66 | -------------------------------------------------------------------------------- /scripts/copy-essentials.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | check_errcode() { 4 | status=$? 5 | 6 | if [ $status -ne 0 ]; then 7 | echo "${1}" 8 | exit $status 9 | fi 10 | } 11 | 12 | # This script copies required essential files before running 13 | 14 | echo "Copying configuration files..." 15 | rm -rf ./dist/src/config 16 | check_errcode "Failed to delete config files!" 17 | mkdir -p ./dist/src/config 18 | check_errcode "Failed to create configuration directory at dist!" 19 | cp -Rf ./src/config/* ./dist/src/config 20 | check_errcode "Failed to copy configuration files!" 21 | echo "Configuration files succesfully copied!" 22 | -------------------------------------------------------------------------------- /scripts/get-version.js: -------------------------------------------------------------------------------- 1 | const packageJson = require('../package.json'); 2 | console.log(`v${packageJson.version}`); 3 | -------------------------------------------------------------------------------- /scripts/install_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | function install_deps() { 4 | npm ci --include=dev 5 | } 6 | 7 | echo "Installing all dependencies for NodeJS & Angular..." 8 | install_deps 9 | 10 | # Install the angular deps 11 | pushd angular-src 12 | install_deps 13 | popd 14 | echo "Finished installing dependencies!" 15 | -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './models'; 2 | export * from './shared-utils'; 3 | -------------------------------------------------------------------------------- /shared/models/index.ts: -------------------------------------------------------------------------------- 1 | export { UserProfile } from './user-profile'; 2 | export { UserProfileModel } from './user-profile.model'; 3 | export * from './login-response'; 4 | -------------------------------------------------------------------------------- /shared/models/login-response.ts: -------------------------------------------------------------------------------- 1 | import { UserProfile } from './user-profile'; 2 | 3 | export interface LoginResponse { 4 | token: string; 5 | user: UserProfile; 6 | } 7 | -------------------------------------------------------------------------------- /shared/models/user-profile.model.ts: -------------------------------------------------------------------------------- 1 | import { IsEmail, MinLength } from 'class-validator'; 2 | 3 | import { UserProfile } from './user-profile'; 4 | 5 | export class UserProfileModel implements UserProfile { 6 | @IsEmail() 7 | email: string; 8 | 9 | @MinLength(1) 10 | firstName: string; 11 | @MinLength(1) 12 | lastName: string; 13 | @MinLength(6) 14 | password: string; 15 | 16 | roles?: string[]; 17 | } 18 | -------------------------------------------------------------------------------- /shared/models/user-profile.ts: -------------------------------------------------------------------------------- 1 | export interface UserProfile { 2 | email: string; 3 | firstName: string; 4 | lastName: string; 5 | password: string; 6 | roles?: string[]; 7 | } 8 | -------------------------------------------------------------------------------- /shared/shared-utils.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'class-validator'; 2 | 3 | /** 4 | * Returns a textual presentation of ValidationErrors array detected with the class-validator library. 5 | * @param errors 6 | */ 7 | export function getFormValidationErrorText( 8 | errors: Array 9 | ): string { 10 | let output = `Supplied form is invalid, please fix the following issues:\n`; 11 | errors 12 | .map((issue) => getTextualValidationError(issue)) 13 | .forEach((issueStr) => (output += issueStr)); 14 | 15 | return output; 16 | } 17 | 18 | /** 19 | * Returns a textual presentation of a validation error. 20 | * @param error 21 | */ 22 | export function getTextualValidationError(error: ValidationError): string { 23 | let output = `${error.property}:\n`; 24 | 25 | if (error.constraints) { 26 | Object.keys(error.constraints).forEach((constraint) => { 27 | output += '- ' + error.constraints[constraint] + '\n'; 28 | }); 29 | } 30 | 31 | if (error.children && error.children.length > 0) { 32 | for (const child of error.children) 33 | output += this.getTextualValidationError(child) + '\n'; 34 | } 35 | 36 | return output; 37 | } 38 | -------------------------------------------------------------------------------- /shared/testing/mock/user.mock.ts: -------------------------------------------------------------------------------- 1 | import * as faker from 'faker'; 2 | 3 | import { UserProfile } from '../../models/user-profile'; 4 | 5 | /** 6 | * Generates a root user we can connect with to the web interface. 7 | */ 8 | export function generateMockRootUser(): UserProfile { 9 | return { 10 | ...generateMockUser(), 11 | email: 'root@mail.com', 12 | password: 'root', 13 | roles: ['admin'], 14 | }; 15 | } 16 | 17 | export function generateMockViewerUser(): UserProfile { 18 | return { 19 | ...generateMockUser(), 20 | email: 'viewer@mail.com', 21 | password: 'viewer', 22 | roles: ['viewer'], 23 | }; 24 | } 25 | 26 | /** 27 | * Generates a mock user for the tests. 28 | * @param roles 29 | */ 30 | export function generateMockUser(roles: string[] = []): UserProfile { 31 | return { 32 | email: faker.internet.email(), 33 | firstName: faker.name.firstName(), 34 | lastName: faker.name.lastName(), 35 | password: faker.internet.password(), 36 | roles: roles, 37 | }; 38 | } 39 | -------------------------------------------------------------------------------- /shared/testing/shared_test_utils.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Generates an array mock objects from the generator function. 3 | * @param generateFn 4 | * @param count 5 | */ 6 | export function generateMockArray(generateFn: (index: number) => T, count: number): T[] { 7 | const output: T[] = []; 8 | for (let i = 0; i < count; i++) output.push(generateFn(i)); 9 | 10 | return output; 11 | } 12 | -------------------------------------------------------------------------------- /src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { AuthModule } from './auth/auth.module'; 4 | import config from './config'; 5 | import { ConfigManagerModule } from './config-manager/config-manager.module'; 6 | import { ApiController } from './controllers/api.controller'; 7 | import { DatabaseModule } from './database/database.module'; 8 | import { SocialAuthServices } from './social-auth/social-auth.models'; 9 | import { SocialAuthModule } from './social-auth/social-auth.module'; 10 | 11 | @Module({ 12 | imports: [ 13 | DatabaseModule.register({ uri: config.DB_URI }), 14 | AuthModule, 15 | SocialAuthModule.register({ 16 | socialAuthServices: config.SOCIAL_CREDENTIALS as SocialAuthServices, 17 | }), 18 | ConfigManagerModule, 19 | ], 20 | controllers: [ApiController], 21 | providers: [], 22 | }) 23 | export class AppModule {} 24 | -------------------------------------------------------------------------------- /src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { LoginResponse, UserProfile } from 'shared'; 3 | 4 | import { generateMockUser } from '../../shared/testing/mock/user.mock'; 5 | import { cleanTestDB } from '../testing/test_db_setup'; 6 | import { closeNestApp, getNestApp, getRequest, setAdminHeaders } from '../testing/test_utils'; 7 | 8 | describe('AuthController', () => { 9 | before(getNestApp); 10 | beforeEach(cleanTestDB); 11 | after(closeNestApp); 12 | 13 | it('/login (POST) should login as root and obtain a token', async () => { 14 | const response = await (await getRequest()) 15 | .post('/login') 16 | .send({ username: 'root@mail.com', password: 'root' }) 17 | .expect(200); 18 | 19 | const result = response.body as LoginResponse; 20 | 21 | // Check that the token looks fine 22 | expect(typeof result.token).to.equal('string'); 23 | expect(result.token.length).to.be.greaterThan(0); 24 | 25 | // Check that the user if fine 26 | expect(result.user.email).to.eq('root@mail.com'); 27 | }); 28 | 29 | it('/login (POST) should fail to login as root with an invalid password', async () => { 30 | await (await getRequest()) 31 | .post('/login') 32 | .send({ username: 'root@mail.com', password: 'wrongpassword' }) 33 | .expect(401) 34 | .expect({ 35 | statusCode: 401, 36 | message: 'Email or password are invalid!', 37 | error: 'Unauthorized', 38 | }); 39 | }); 40 | 41 | it('/profile (GET) should return the root user profile', async () => { 42 | const response = await setAdminHeaders( 43 | (await getRequest()).get('/profile'), 44 | ).expect(200); 45 | 46 | const result: UserProfile = response.body; 47 | 48 | // Check that we obtained the correct user by it's email address 49 | expect(result.email).to.eq('root@mail.com'); 50 | }); 51 | 52 | it('/profile (GET) should fail to return a user profile because no credentials provided', async () => { 53 | await (await getRequest()) 54 | .get('/profile') 55 | .expect(401) 56 | .expect({ 57 | statusCode: 401, 58 | message: 'Unauthorized', 59 | }); 60 | }); 61 | 62 | it('/register (POST) should register a new user successfully', async () => { 63 | const testUser = generateMockUser(); 64 | const response = await (await getRequest()) 65 | .post('/register') 66 | .send(testUser) 67 | .expect(201); 68 | 69 | const returnedUser: UserProfile = response.body; 70 | 71 | // Remove the password field for compare 72 | delete testUser.password; 73 | 74 | // Delete fields that are interrupting 75 | delete returnedUser['_id']; 76 | delete returnedUser['__v']; 77 | 78 | // Now check if the returned user is the same as the created test user 79 | expect(returnedUser).to.deep.eq(testUser); 80 | }); 81 | 82 | it('/register (POST) should fail to register a new user because one field is not valid', async () => { 83 | const testUser: UserProfile = generateMockUser(); 84 | testUser.email = 'notanemail'; 85 | 86 | await (await getRequest()) 87 | .post('/register') 88 | .send(testUser) 89 | .expect(400) 90 | .expect({ 91 | statusCode: 400, 92 | message: ['email must be an email'], 93 | error: 'Bad Request', 94 | }); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { LoginResponse } from 'shared'; 2 | 3 | import { Body, Controller, Get, HttpCode, Post, UseGuards } from '@nestjs/common'; 4 | 5 | import { UserProfile } from '../../shared/models/user-profile'; 6 | import { UserProfileDbModel } from '../database/models/user-profile.db.model'; 7 | import { RegisterForm } from '../forms/register.form'; 8 | import { AuthService } from './auth.service'; 9 | import { RequestUser } from './request-user.decorator'; 10 | import { UserAuthGuard } from './user-auth-guard'; 11 | 12 | @Controller() 13 | export class AuthController { 14 | constructor(private authService: AuthService) {} 15 | 16 | /** 17 | * Performs the login process of a user, if the username and password are correct, 18 | * returns the token and related user profile data. 19 | * @param username 20 | * @param password 21 | */ 22 | @Post('/login') 23 | @HttpCode(200) 24 | login( 25 | @Body('username') username: string, 26 | @Body('password') password: string, 27 | ): Promise { 28 | return this.authService.authenticate(username, password).then(user => { 29 | const token = this.authService.generateToken(user.toJSON()); 30 | 31 | return { 32 | token: token, 33 | user, 34 | }; 35 | }); 36 | } 37 | 38 | /** 39 | * Registers a new local user. 40 | * @param registerForm 41 | */ 42 | @Post('/register') 43 | register(@Body() registerForm: RegisterForm): Promise { 44 | // Hash the user password and create it afterwards 45 | return registerForm.getHashedPassword().then(hashedPassword => { 46 | return UserProfileDbModel.create({ 47 | ...registerForm, 48 | password: hashedPassword, 49 | }); 50 | }); 51 | } 52 | 53 | @UseGuards(UserAuthGuard) 54 | @Get('/profile') 55 | getProfile(@RequestUser() user: UserProfile): UserProfile { 56 | return user; 57 | } 58 | 59 | @UseGuards(UserAuthGuard) 60 | @Get('/logout') 61 | logout(): void { 62 | // TODO: Perform your own logout logic (blacklisting token, etc) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /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 config from '../config'; 6 | import { AuthController } from './auth.controller'; 7 | import { AuthService } from './auth.service'; 8 | import { JwtStrategy } from './jwt.strategy'; 9 | 10 | /** 11 | * Responsible of authenticating the user requests using JWT authentication 12 | * and Passport. It exposes the AuthService which allows managing user authentication, 13 | * and the UserAuthGuard which allows authenticating each user request. 14 | */ 15 | @Module({ 16 | imports: [ 17 | PassportModule, 18 | JwtModule.register({ 19 | secret: config.JWT.SECRET, 20 | signOptions: config.JWT.OPTIONS, 21 | }), 22 | ], 23 | providers: [AuthService, JwtStrategy], 24 | exports: [AuthService], 25 | controllers: [AuthController], 26 | }) 27 | export class AuthModule {} 28 | -------------------------------------------------------------------------------- /src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { assert, expect } from 'chai'; 2 | 3 | import { UnauthorizedException } from '@nestjs/common'; 4 | 5 | import { UserProfile } from '../../shared/models/user-profile'; 6 | import { generateMockUser } from '../../shared/testing/mock/user.mock'; 7 | import { cleanTestDB, closeTestDB } from '../testing/test_db_setup'; 8 | import { createTestModuleWithDB, getMockRootUserFromDB } from '../testing/test_utils'; 9 | import { AuthModule } from './auth.module'; 10 | import { AuthService } from './auth.service'; 11 | 12 | describe('AuthService', () => { 13 | let authService: AuthService; 14 | 15 | before(async () => { 16 | const { module } = await createTestModuleWithDB({ 17 | imports: [AuthModule], 18 | }); 19 | 20 | authService = module.get(AuthService); 21 | }); 22 | 23 | beforeEach(cleanTestDB); 24 | after(closeTestDB); 25 | 26 | it('should authenticate successfully', async () => { 27 | const user = await authService.authenticate('root@mail.com', 'root'); 28 | expect(user.email).to.eq('root@mail.com'); 29 | }); 30 | 31 | it('should fail to authenticate and return Unauthorized exception', async () => { 32 | try { 33 | await authService.authenticate('random@mail.com', 'randompassword'); 34 | assert.fail('Should have failed to authenticate user, but succeed'); 35 | } catch (error) { 36 | expect(error).to.be.instanceof(UnauthorizedException); 37 | } 38 | }); 39 | 40 | it('should generate a token for the provided user and decode it back', async () => { 41 | const rootUser = (await getMockRootUserFromDB()).toJSON() as UserProfile; 42 | const token = authService.generateToken(rootUser); 43 | expect(typeof token).to.eq('string'); 44 | expect(token.length > 50).to.be.true; 45 | 46 | // Expect the root user to be equal to the decoded user from the token 47 | const decodedUser = authService.decodeToken(token); 48 | expect(decodedUser.email).to.eq(rootUser.email); 49 | }); 50 | 51 | it('should return true for user having role "admin', () => { 52 | const testUser = generateMockUser(['admin']); 53 | expect(authService.userHasRoles(testUser, 'admin')).to.be.true; 54 | }); 55 | 56 | it('should return true for user having role "admin" and "operator"', () => { 57 | const testUser = generateMockUser(['admin', 'operator']); 58 | expect(authService.userHasRoles(testUser, 'admin', 'operator')).to.be.true; 59 | }); 60 | 61 | it('should return false for user having role "admin"', () => { 62 | const testUser = generateMockUser(); 63 | expect(authService.userHasRoles(testUser, 'admin')).to.be.false; 64 | }); 65 | }); 66 | -------------------------------------------------------------------------------- /src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { DocumentQuery } from 'mongoose'; 3 | import { UserProfile } from 'shared'; 4 | 5 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 6 | import { JwtService } from '@nestjs/jwt'; 7 | 8 | import { IUserProfileDbModel, UserProfileDbModel } from '../database/models/user-profile.db.model'; 9 | 10 | /** 11 | * Responsible of authenticating users, it uses JWT for the authentication process. 12 | */ 13 | @Injectable() 14 | export class AuthService { 15 | constructor(private jwtService: JwtService) {} 16 | 17 | /** 18 | * Checks if the provided username and password valid, if so, returns the user match. If not, returns null. 19 | * @param email 20 | * @param password 21 | */ 22 | authenticate(email: string, password: string): Promise { 23 | return UserProfileDbModel.findOne({ email }).then(user => { 24 | // If this user does not exist, throw the same error for security reasons 25 | if (!user) 26 | throw new UnauthorizedException('Email or password are invalid!'); 27 | 28 | return bcrypt.compare(password, user.password).then(match => { 29 | // The password do not match the one saved on the database 30 | if (!match) 31 | throw new UnauthorizedException('Email or password are invalid!'); 32 | return user; 33 | }); 34 | }); 35 | } 36 | 37 | getUserFromDB( 38 | email: string, 39 | ): DocumentQuery { 40 | return UserProfileDbModel.findOne({ email }); 41 | } 42 | 43 | getUserFromToken( 44 | token: string, 45 | ): DocumentQuery { 46 | // Decode the token 47 | const decodedUser = this.jwtService.verify(token) as IUserProfileDbModel; 48 | 49 | if (decodedUser) { 50 | // If the user has been decoded successfully, check it against the database 51 | return UserProfileDbModel.findById(decodedUser._id); 52 | } 53 | } 54 | 55 | userHasRoles(user: UserProfile, ...roles: string[]): boolean { 56 | // If the user don't have this amount of roles, don't pass 57 | if (user.roles.length < roles.length) return false; 58 | 59 | // Go through each role, and check if the user has it, if not, return false 60 | for (const role of roles) if (!user.roles.includes(role)) return false; 61 | 62 | return true; 63 | } 64 | 65 | /** 66 | * Generates a JWT token with the specified user data. 67 | * @param user 68 | */ 69 | generateToken(user: UserProfile): string { 70 | return this.jwtService.sign(user); 71 | } 72 | 73 | /** 74 | * Decodes a JWT token and returns the user found. 75 | * @param token 76 | */ 77 | decodeToken(token: string): UserProfile { 78 | return this.jwtService.verify(token) as UserProfile; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /src/auth/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { ExtractJwt, Strategy } from 'passport-jwt'; 2 | 3 | import { Injectable } from '@nestjs/common'; 4 | import { PassportStrategy } from '@nestjs/passport'; 5 | 6 | import { UserProfile } from '../../shared/models/user-profile'; 7 | import config from '../config'; 8 | import { AuthService } from './auth.service'; 9 | 10 | /** 11 | * This service is reponsible of implementing the local passport authentication strategy which is based 12 | * on JWT and being provided using the 'Bearer' header. 13 | */ 14 | @Injectable() 15 | export class JwtStrategy extends PassportStrategy(Strategy) { 16 | constructor(private authService: AuthService) { 17 | super({ 18 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 19 | ignoreExpiration: false, 20 | secretOrKey: config.JWT.SECRET, 21 | }); 22 | } 23 | 24 | validate(payload: UserProfile): Promise { 25 | return this.authService.getUserFromDB(payload.email).exec(); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/auth/request-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | 3 | /** 4 | * Returns the authenticated user associated with this request. 5 | */ 6 | export const RequestUser = createParamDecorator( 7 | (data: unknown, ctx: ExecutionContext) => { 8 | const request = ctx.switchToHttp().getRequest(); 9 | return request.user; 10 | }, 11 | ); 12 | -------------------------------------------------------------------------------- /src/auth/roles.decorators.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | /** 4 | * Requires the user to have all of the provided roles in order to access the provided endpoint. 5 | * @param roles 6 | */ 7 | // eslint-disable-next-line @typescript-eslint/explicit-module-boundary-types 8 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles); 9 | -------------------------------------------------------------------------------- /src/auth/user-auth-guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, ForbiddenException, Injectable } from '@nestjs/common'; 2 | import { Reflector } from '@nestjs/core'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | import appConfig from '../config'; 6 | import { AppRequest } from '../models'; 7 | import { AuthService } from './auth.service'; 8 | 9 | /** 10 | * Responsible of guarding endpoints by authenticating them, including their roles using JWT. 11 | */ 12 | @Injectable() 13 | export class UserAuthGuard extends AuthGuard('jwt') { 14 | constructor(private authService: AuthService, private reflector: Reflector) { 15 | super(); 16 | } 17 | 18 | async canActivate(context: ExecutionContext): Promise { 19 | // First validate the user 20 | const isUserValidated = await (>( 21 | this.validateAndAppendUserToRequest(context) 22 | )); 23 | 24 | // If the user is validated, validate it's roles 25 | if (isUserValidated) { 26 | const userHasRoles = this.validateUserRoles(context); 27 | 28 | // If the user don't have the required roles 29 | if (!userHasRoles) 30 | throw new ForbiddenException(`You don't have the required roles!`); 31 | return true; 32 | } 33 | 34 | return false; 35 | } 36 | 37 | /** 38 | * Validates the request, add the user to the request, and return true or false if request is authorized. 39 | * @param context 40 | */ 41 | async validateAndAppendUserToRequest( 42 | context: ExecutionContext, 43 | ): Promise { 44 | // If we are on test environment, use 'simplified' authentication 45 | if (appConfig.ENVIRONMENT === 'test') { 46 | const testUserValidated = await this.validateAndAppendUserForTest( 47 | context, 48 | ); 49 | 50 | if (testUserValidated) return true; 51 | } 52 | 53 | // Use normal JWT authentication 54 | return >super.canActivate(context); 55 | } 56 | 57 | async validateAndAppendUserForTest( 58 | context: ExecutionContext, 59 | ): Promise { 60 | const request: AppRequest = context.switchToHttp().getRequest(); 61 | const authorizationHeader: string = request.header('authorization'); 62 | 63 | // If this authorization header is bearer 64 | if ( 65 | authorizationHeader && 66 | authorizationHeader.toLowerCase().startsWith('bearer') 67 | ) { 68 | // Get the token itself (removes the 'bearer') 69 | const token = this.extractTokenFromBearerHeader(authorizationHeader); 70 | 71 | // If it's the admin user token 72 | if (token === 'admin') { 73 | // This is the administrator token, use the root user 74 | const rootUser = await this.authService.getUserFromDB('root@mail.com'); 75 | request.user = rootUser; 76 | return true; 77 | } else if (token === 'viewer') { 78 | // This is the viewer token, use the viewer user 79 | const viewerUser = await this.authService.getUserFromDB( 80 | 'viewer@mail.com', 81 | ); 82 | request.user = viewerUser; 83 | return true; 84 | } 85 | } 86 | } 87 | 88 | /** 89 | * Validates that the user has all of the required roles, if no roles were provided, automatically returns true. 90 | * @param context 91 | */ 92 | validateUserRoles(context: ExecutionContext): boolean { 93 | const request: AppRequest = context.switchToHttp().getRequest(); 94 | 95 | // Get the user from the request 96 | const user = request.user; 97 | 98 | // Get the roles if provided in the metadata 99 | const roles = this.reflector.get('roles', context.getHandler()); 100 | 101 | // If no roles were specified, continue 102 | if (!roles) return true; 103 | 104 | // If roles were specified, make sure the user meets them 105 | return this.authService.userHasRoles(user, ...roles); 106 | } 107 | 108 | /** 109 | * Extracts only the token portion from the bearer header. 110 | * @param bearerHeader 111 | */ 112 | private extractTokenFromBearerHeader(bearerHeader: string): string { 113 | return /(?<=bearer ).+/gi.exec(bearerHeader)[0]; 114 | } 115 | } 116 | -------------------------------------------------------------------------------- /src/config-manager/config-manager.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | 3 | import { ConfigService } from './config.service'; 4 | 5 | /** 6 | * This module is a NestJS representation of 'config.ts', which is a singleton file containing all of the configurations. 7 | * You can use it to inject the configurations anywhere you want. 8 | * 9 | * You can also import 'config.ts' directly and access those configurations without any injection. 10 | */ 11 | @Module({ 12 | providers: [ConfigService], 13 | exports: [ConfigService], 14 | }) 15 | export class ConfigManagerModule {} 16 | -------------------------------------------------------------------------------- /src/config-manager/config-parser.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import * as process from 'process'; 3 | 4 | import { parseEnvString } from './config-parser'; 5 | 6 | describe('config-parser', () => { 7 | it('check home directory (~) parse', () => { 8 | const value = '~/some/long/path'; 9 | expect(parseEnvString(value)).not.contain('~/'); 10 | }); 11 | 12 | it('check cwd parse (process.cwd)', () => { 13 | const value = './this/cwd/test'; 14 | 15 | // Create an expected value by replace the './' variables with the current working directory manually 16 | const expectedValue = `${process.cwd()}/${value.substring(2)}`; 17 | 18 | expect(parseEnvString(value)).not.contain('./'); 19 | expect(parseEnvString(value)).to.eq(expectedValue); 20 | }); 21 | 22 | it('should return boolean values', () => { 23 | const trueValue = 'true'; 24 | const falseValue = 'false'; 25 | 26 | expect(parseEnvString(trueValue)).to.be.true; 27 | expect(parseEnvString(falseValue)).to.be.false; 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/config-manager/config-parser.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as process from 'process'; 3 | 4 | /** 5 | * Parse environment variables, parse out path strings, and boolean values. 6 | * @param value 7 | */ 8 | export const parseEnvString = (value: string): string | boolean => { 9 | // If it's a boolean string, return it as a boolean 10 | if (value === 'true' || value === 'false') { 11 | return value === 'true' ? true : false; 12 | } 13 | 14 | if ( 15 | value.startsWith('/') || 16 | value.startsWith('./') || 17 | value.startsWith('~') 18 | ) { 19 | // Replace home directory with correct value (if exists) 20 | const output = value.replace( 21 | '~/', 22 | `${process.env.HOME || process.env.USERPROFILE}/` 23 | ); 24 | 25 | // Parse other values 26 | return path.resolve(output); 27 | } 28 | 29 | // Don't do anything to paths that are not relative or non-path related values 30 | return value; 31 | }; 32 | 33 | /** 34 | * Parses all values from specified configuration, replacing string with special characters such as '~' to 35 | * which represents user home directory. 36 | * @param config 37 | */ 38 | export const parseConfig = (config: unknown): void => { 39 | if (config instanceof Object) { 40 | for (const key in config) { 41 | const value = config[key]; 42 | 43 | if (value instanceof Object) parseConfig(value); 44 | else if (typeof value === 'string') config[key] = parseEnvString(value); 45 | } 46 | } 47 | }; 48 | -------------------------------------------------------------------------------- /src/config-manager/config.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | import config from '../config'; 4 | import { AppConfig } from '../models/app-config'; 5 | 6 | /** 7 | * You can use the config service in order to inject the configurations to other services. 8 | */ 9 | @Injectable() 10 | export class ConfigService { 11 | getConfig(): AppConfig { 12 | return config; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import * as cors from 'cors'; 2 | import * as _ from 'lodash'; 3 | import * as path from 'path'; 4 | 5 | /** 6 | * This file is responsible for the configurations, it is generated according to the 7 | * environment specified in the NODE_ENV environment variable. 8 | */ 9 | import { Logger } from '@nestjs/common'; 10 | 11 | import { parseConfig } from './config-manager/config-parser'; 12 | import { getEnvConfig } from './misc/env-config-loader'; 13 | import { AppConfig } from './models'; 14 | 15 | let config: AppConfig; 16 | let ENVIRONMENT = process.env['NODE_ENV'] || 'development'; 17 | 18 | function loadEnvironmentAndConfigurations() { 19 | let isVerboseTest = false; 20 | 21 | // if it's a 'test-verbose' environment (where we print all logs as usual) 22 | if (process.env.NODE_ENV === 'test-verbose') { 23 | // Set the env as 'test' just for the configurations from 'config/test.json' to be loaded correctly 24 | process.env.NODE_ENV = 'test'; 25 | isVerboseTest = true; 26 | } 27 | 28 | // The directory where all of the configurations are stored 29 | process.env['NODE_CONFIG_DIR'] = path.join(__dirname, '/config'); 30 | 31 | // Now load all of the configurations using the 'config' library 32 | // eslint-disable-next-line @typescript-eslint/no-var-requires 33 | config = require('config'); 34 | 35 | // If it's a verbose test, return the NODE_ENV to the correct one 36 | if (isVerboseTest) { 37 | process.env.NODE_ENV = 'verbose-test'; 38 | 39 | // Special 'test-verbose' case where test is still treated in code but output is still being generated 40 | // (NestJS by default hides all output if 'test' environment is being set) 41 | ENVIRONMENT = 'test'; 42 | } 43 | } 44 | 45 | loadEnvironmentAndConfigurations(); 46 | 47 | let exportedConfig = config as AppConfig; 48 | 49 | /* 50 | This file is responsible for the entire configuration of the server. 51 | */ 52 | let isDebugging = false; 53 | 54 | // Get the environment configurations 55 | const webEnvConfigs = getEnvConfig(); 56 | 57 | // Read the supplied arguments 58 | process.argv.forEach(function(val) { 59 | if (val != null && typeof val === 'string') { 60 | if (val === '-debug') isDebugging = true; 61 | } 62 | }); 63 | 64 | const DEBUG_MODE = isDebugging; 65 | 66 | const CORS_OPTIONS: cors.CorsOptions = { 67 | origin: exportedConfig.CLIENT_URL, 68 | optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204 69 | credentials: true, 70 | }; 71 | 72 | exportedConfig = { 73 | ...exportedConfig, 74 | ENVIRONMENT, 75 | CORS_OPTIONS, 76 | DEBUG_MODE, 77 | }; 78 | 79 | /* 80 | Merge the web env configs with the exported configs (so we won't delete any existing values), 81 | environment configurations always have higher priority. 82 | */ 83 | exportedConfig = _.merge(exportedConfig, webEnvConfigs); 84 | 85 | // Parse all config values to replace special chars such as '~' 86 | parseConfig(exportedConfig); 87 | 88 | // Print out the configurations we are loading 89 | if (process.env.NODE_ENV !== 'test') 90 | Logger.log(`Loaded config: ${JSON.stringify(exportedConfig, null, 2)}`); 91 | 92 | export default exportedConfig as AppConfig; 93 | -------------------------------------------------------------------------------- /src/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOG_LEVEL": "info", 3 | "USE_SSR": false, 4 | "ANGULAR": { 5 | "MOUNT": "false" 6 | } 7 | } -------------------------------------------------------------------------------- /src/config/development.json: -------------------------------------------------------------------------------- 1 | { 2 | "DB_URI": "mongodb://root:root@localhost:27017/?authSource=admin", 3 | "CLIENT_URL": "http://localhost:4200", 4 | "JWT": { 5 | "SECRET": "T{qVK:zv:4[y'GMPvRBkA3>!BP$C5hnakvZP[f=['.f]Lg9SUJ*Y{:b*G4`3^S/C" 6 | }, 7 | "SOCIAL_CREDENTIALS": { 8 | "facebook": { 9 | "APP_ID": 223045385190067, 10 | "APP_SECRET": "99f023bf540a03481d24d496ecbe250e" 11 | }, 12 | "google": { 13 | "APP_ID": "1086867360709-69ko7vlgcc3uuq8a42dmmvgjng1vg02l.apps.googleusercontent.com", 14 | "APP_SECRET": "VCj6R9XOnro6I8sRfFBS19pr" 15 | } 16 | }, 17 | "LOG_LEVEL": "debug" 18 | } -------------------------------------------------------------------------------- /src/config/production.json: -------------------------------------------------------------------------------- 1 | { 2 | "DB_URI": "production-mongo-uri", 3 | "CLIENT_URL": "http://yourwebsite.com", 4 | "ANGULAR": { 5 | "MOUNT": "true" 6 | } 7 | } -------------------------------------------------------------------------------- /src/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "CLIENT_URL": "http://localhost:4200", 3 | "JWT": { 4 | "SECRET": "T{qVK:zv:4[y'GMPvRBkA3>!BP$C5hnakvZP[f=['.f]Lg9SUJ*Y{:b*G4`3^S/C" 5 | }, 6 | "SOCIAL_CREDENTIALS": { 7 | "facebook": { 8 | "APP_ID": 223045385190067, 9 | "APP_SECRET": "99f023bf540a03481d24d496ecbe250e" 10 | }, 11 | "google": { 12 | "APP_ID": "1086867360709-69ko7vlgcc3uuq8a42dmmvgjng1vg02l.apps.googleusercontent.com", 13 | "APP_SECRET": "VCj6R9XOnro6I8sRfFBS19pr" 14 | } 15 | } 16 | } -------------------------------------------------------------------------------- /src/controllers/api.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { cleanTestDB } from '../testing/test_db_setup'; 2 | import { 3 | closeNestApp, getNestApp, getRequest, setAdminHeaders, setViewerHeaders 4 | } from '../testing/test_utils'; 5 | 6 | describe('ApiController', () => { 7 | before(getNestApp); 8 | beforeEach(cleanTestDB); 9 | after(closeNestApp); 10 | 11 | it('/test (GET) should return status ok', async () => { 12 | await (await getRequest()) 13 | .get('/test') 14 | .expect(200) 15 | .expect({ status: 'ok' }); 16 | }); 17 | 18 | it('/admin (GET) should return You are an admin', async () => { 19 | await setAdminHeaders((await getRequest()).get('/admin')) 20 | .expect(200) 21 | .expect('You are an admin!'); 22 | }); 23 | 24 | it('/admin (GET) should fail to access route with status code 403', async () => { 25 | await setViewerHeaders( 26 | (await getRequest()).get('/admin').expect(403), 27 | ).expect({ 28 | statusCode: 403, 29 | message: `You don't have the required roles!`, 30 | error: 'Forbidden', 31 | }); 32 | }); 33 | }); 34 | -------------------------------------------------------------------------------- /src/controllers/api.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Query, UseGuards } from '@nestjs/common'; 2 | 3 | import { Roles } from '../auth/roles.decorators'; 4 | import { UserAuthGuard } from '../auth/user-auth-guard'; 5 | 6 | interface TestResponse { 7 | status: 'ok'; 8 | } 9 | 10 | @Controller() 11 | export class ApiController { 12 | @Get('/test') 13 | test(): TestResponse { 14 | return { status: 'ok' }; 15 | } 16 | 17 | @Get('/say-something') 18 | saySomething(@Query('whatToSay') whatToSay: string): { said: string } { 19 | return { said: whatToSay }; 20 | } 21 | 22 | // An example of accessing allowing only admins to access specific route 23 | @UseGuards(UserAuthGuard) 24 | @Roles('admin') 25 | @Get('/admin') 26 | admin(): string { 27 | return `You are an admin!`; 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /src/database/database-connection-manager.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import { Logger } from '@nestjs/common'; 4 | 5 | /** 6 | * Allows managing open database connections easily. 7 | * Use as a single instance only. 8 | */ 9 | export class DatabaseConnectionManager { 10 | private static _instance: DatabaseConnectionManager; 11 | static get instance(): DatabaseConnectionManager { 12 | if (!this._instance) this._instance = new DatabaseConnectionManager(); 13 | 14 | return this._instance; 15 | } 16 | 17 | private _connections: mongoose.Connection[] = []; 18 | 19 | /** 20 | * The list of connections currently opened. 21 | * 22 | * @readonly 23 | * @type {mongoose.Connection[]} 24 | * @memberof DatabaseConnectionManager 25 | */ 26 | get connections(): mongoose.Connection[] { 27 | return this._connections; 28 | } 29 | 30 | /** 31 | * Returns the 1st open connection (if exists). 32 | * 33 | * @readonly 34 | * @type {mongoose.Connection} 35 | * @memberof DatabaseConnectionManager 36 | */ 37 | get openConnection(): mongoose.Connection { 38 | return this._connections.length > 0 && this._connections[0]; 39 | } 40 | 41 | /** 42 | * Connects to the specific mongo database, and stores the connection in the list of connections. 43 | * @param uri 44 | */ 45 | connectDatabase(uri: string): Promise { 46 | return new Promise((resolve, reject) => { 47 | Logger.log(`Connecting to database...`); 48 | 49 | mongoose.connect(uri, { 50 | useNewUrlParser: true, 51 | useUnifiedTopology: true, 52 | useCreateIndex: true, 53 | }); 54 | 55 | const connection = mongoose.connection; 56 | 57 | connection.on('error', error => { 58 | Logger.error('Failed to connect to MongoDB server: ${error}'); 59 | reject(error); 60 | }); 61 | 62 | connection.once('open', () => { 63 | Logger.log('Connected to MongoDB server'); 64 | 65 | // Add this connection to the list of connections 66 | this._connections.push(connection); 67 | 68 | // Clear this connection after it was closed 69 | connection.once('close', () => { 70 | // Remove this connection from the list of connections 71 | this._connections.splice( 72 | this._connections.findIndex(c => c === connection), 73 | 1, 74 | ); 75 | }); 76 | 77 | // Return the connection 78 | resolve(connection); 79 | }); 80 | }); 81 | } 82 | 83 | /** 84 | * Closes all of the connections currently opened. 85 | */ 86 | closeConnections(): Promise { 87 | return Promise.all( 88 | this._connections.map(async c => { 89 | await c.close(); 90 | return c; 91 | }), 92 | ); 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/database/database.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | 3 | import appConfig from '../config'; 4 | import { DatabaseService } from './database.service'; 5 | 6 | export interface DatabaseModuleConfig { 7 | uri: string; 8 | retryCount?: number; 9 | } 10 | 11 | /** 12 | * Responsible of connecting mongo based database, exposes the DatabaseService which handles 13 | * the first connection. You can create new models at the `models` directory and access the schemas 14 | * as you would with mongoose. 15 | * 16 | * The database will be automatically connected when this module is imported. 17 | */ 18 | @Module({ 19 | providers: [DatabaseService], 20 | exports: [DatabaseService], 21 | }) 22 | export class DatabaseModule { 23 | /** 24 | * Loads the database connection with the provided configurations.a1 25 | * If env is set to 'test', will automatically return the in-memory mongo database 26 | * and ignore all configurations. 27 | * @param config 28 | */ 29 | static async register(config: DatabaseModuleConfig): Promise { 30 | // If we are running on test, return the test module 31 | if (appConfig.ENVIRONMENT === 'test') return DatabaseModule.forTest(); 32 | 33 | return { 34 | module: DatabaseModule, 35 | providers: [{ provide: 'DATABASE_MODULE_CONFIG', useValue: config }], 36 | }; 37 | } 38 | 39 | /** 40 | * Uses the database module for test environment, which uses in-memory mongo database. 41 | */ 42 | static async forTest(): Promise { 43 | /* We are using lazy import, because if running on production and will be imported normally, 44 | it will fail because of missing dev dependencies import */ 45 | const dbTestImport = await import( 46 | '../testing/services/database.test.service' 47 | ); 48 | 49 | return { 50 | module: DatabaseModule, 51 | providers: [ 52 | { 53 | provide: DatabaseService, 54 | useClass: dbTestImport.DatabaseTestService, 55 | }, 56 | ], 57 | }; 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /src/database/database.service.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import { Inject, Injectable, Logger, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 4 | 5 | import { sleepAsync } from '../misc/utils'; 6 | import { DatabaseConnectionManager } from './database-connection-manager'; 7 | import { DatabaseModuleConfig } from './database.module'; 8 | 9 | export interface IDatabaseService { 10 | connect(): Promise; 11 | close(): Promise; 12 | } 13 | 14 | /** 15 | * Responsible of connecting and closing the mongo database connection, it will reconnect a few times 16 | * if connection cannot be established on the first time. 17 | */ 18 | @Injectable() 19 | export class DatabaseService 20 | implements IDatabaseService, OnModuleInit, OnModuleDestroy { 21 | connectionManager: DatabaseConnectionManager = 22 | DatabaseConnectionManager.instance; 23 | connection: mongoose.Connection; 24 | 25 | constructor( 26 | @Inject('DATABASE_MODULE_CONFIG') 27 | private readonly config: DatabaseModuleConfig, 28 | ) {} 29 | 30 | /** 31 | * This forces the database to connect when the database module is imported. 32 | */ 33 | async onModuleInit(): Promise { 34 | await this.connectWithRetry(this.config.retryCount || 8); 35 | } 36 | 37 | protected async connectWithRetry(retryCount: number): Promise { 38 | for (let i = 0; i < retryCount; i++) { 39 | try { 40 | await this.connect(); 41 | return; 42 | } catch (error) { 43 | Logger.log(`Retrying to connect database in 5 seconds...`); 44 | await sleepAsync(5000); 45 | } 46 | } 47 | } 48 | 49 | async connect(): Promise { 50 | this.connection = await this.connectionManager.connectDatabase( 51 | this.config.uri, 52 | ); 53 | } 54 | 55 | async close(): Promise { 56 | const db = mongoose.connection; 57 | if (db) await db.close(); 58 | } 59 | 60 | /** 61 | * Closes the database when the app is being closed. 62 | */ 63 | async onModuleDestroy(): Promise { 64 | await this.close(); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/database/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user-profile.db.model'; 2 | -------------------------------------------------------------------------------- /src/database/models/user-profile.db.model.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Model, Schema } from 'mongoose'; 2 | 3 | import { UserProfile } from '../../../shared/models'; 4 | 5 | export interface IUserProfileDbModel extends UserProfile, Document {} 6 | 7 | export const UserProfileSchema = new Schema({ 8 | email: { 9 | unique: true, 10 | type: String, 11 | required: true, 12 | trim: true, 13 | minlength: 4, 14 | }, 15 | firstName: { 16 | type: String, 17 | }, 18 | lastName: { 19 | type: String, 20 | }, 21 | password: { 22 | type: String, 23 | required: true, 24 | minlength: 6, 25 | }, 26 | roles: [String], 27 | }); 28 | 29 | UserProfileSchema.methods.toJSON = function() { 30 | const instance = (this as IUserProfileDbModel).toObject(); 31 | delete instance.password; // Remove the password field 32 | return instance; 33 | }; 34 | 35 | export const UserProfileDbModel: Model = model< 36 | IUserProfileDbModel 37 | >('user', UserProfileSchema); 38 | -------------------------------------------------------------------------------- /src/forms/index.ts: -------------------------------------------------------------------------------- 1 | export * from './register.form'; 2 | -------------------------------------------------------------------------------- /src/forms/register.form.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { UserProfile } from '../../shared/models'; 4 | import { generateMockUser } from '../../shared/testing/mock/user.mock'; 5 | import { RegisterForm } from './register.form'; 6 | 7 | describe('RegisterForm', () => { 8 | let user: UserProfile; 9 | let registerForm: RegisterForm; 10 | 11 | before(async () => { 12 | user = generateMockUser(); 13 | 14 | // Setup a static password for testing 15 | user.password = 'randompassword'; 16 | registerForm = new RegisterForm(); 17 | 18 | // Set the user properties in the object 19 | Object.assign(registerForm, user); 20 | }); 21 | 22 | it('should return a hashed password', async () => { 23 | const result = await registerForm.getHashedPassword(); 24 | expect(typeof result).to.eq('string'); 25 | expect(result.length).to.eq(60); 26 | }); 27 | }); 28 | -------------------------------------------------------------------------------- /src/forms/register.form.ts: -------------------------------------------------------------------------------- 1 | import { UserProfileModel } from '../../shared/models/user-profile.model'; 2 | import { getHashedPassword } from '../misc/utils'; 3 | 4 | export class RegisterForm extends UserProfileModel { 5 | getHashedPassword(): Promise { 6 | return getHashedPassword(this.password); 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/main.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | 3 | import { ValidationPipe } from '@nestjs/common'; 4 | import { NestFactory } from '@nestjs/core'; 5 | 6 | import { AppModule } from './app.module'; 7 | import config from './config'; 8 | import { getHttpsOptionsFromConfig } from './misc'; 9 | import { mountAngular, mountAngularSSR } from './misc/angular-mounter'; 10 | 11 | async function bootstrap() { 12 | // Create the app and allow cors and HTTPS support (if configured) 13 | const app = await NestFactory.create(AppModule, { 14 | cors: config.CORS_OPTIONS, 15 | // Will work only if SSH is configured on the related environment config, if not, normal HTTP will be used 16 | httpsOptions: getHttpsOptionsFromConfig(), 17 | }); 18 | 19 | // Use '/api' for general prefix 20 | app.setGlobalPrefix('api'); 21 | 22 | // Allow validation and transform of params 23 | app.useGlobalPipes( 24 | new ValidationPipe({ 25 | transform: true, 26 | }), 27 | ); 28 | 29 | // If we are running on production, mount angular 30 | if (config.ANGULAR.MOUNT) { 31 | // Get the express app 32 | const expressApp = app 33 | .getHttpAdapter() 34 | .getInstance() as express.Application; 35 | 36 | if (config.ANGULAR.USE_SSR) mountAngularSSR(expressApp); 37 | else mountAngular(expressApp); 38 | } 39 | 40 | // Start listening 41 | await app.listen(process.env.PORT || 3000); 42 | } 43 | 44 | bootstrap(); 45 | -------------------------------------------------------------------------------- /src/misc/angular-mounter.ts: -------------------------------------------------------------------------------- 1 | import * as express from 'express'; 2 | import * as path from 'path'; 3 | 4 | /** 5 | * Mounts angular using Server-Side-Rendering (Recommended for SEO) 6 | */ 7 | export function mountAngularSSR(expressApp: express.Application): void { 8 | // The dist folder of compiled angular 9 | const DIST_FOLDER = path.join(process.cwd(), 'dist/angular'); 10 | 11 | // The compiled server file (angular-src/server.ts) path 12 | // eslint-disable-next-line @typescript-eslint/no-var-requires 13 | const ngApp = require(path.join(DIST_FOLDER, 'server/main')); 14 | 15 | // Init the ng-app using SSR 16 | ngApp.init(expressApp, path.join(DIST_FOLDER, '/browser')); 17 | } 18 | 19 | /** 20 | * Mounts angular as is with no SSR. 21 | */ 22 | export function mountAngular(expressApp: express.Application): void { 23 | const DIST_FOLDER = path.join(process.cwd(), 'dist/angular/browser'); 24 | // Point static path to Angular 2 distribution 25 | expressApp.use(express.static(DIST_FOLDER)); 26 | 27 | // Deliver the Angular 2 distribution 28 | expressApp.get('*', function(req, res) { 29 | res.sendFile(path.join(DIST_FOLDER, 'index.html')); 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/misc/env-config-loader.spec.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | import { getEnvConfig } from './env-config-loader'; 4 | 5 | describe('EnvConfigLoader', () => { 6 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 7 | let originalEnv: any; 8 | 9 | before(() => { 10 | // Store the original environment variables of .env 11 | originalEnv = process.env; 12 | }); 13 | 14 | beforeEach(() => { 15 | process.env = {}; 16 | }); 17 | 18 | after(() => { 19 | // Restore the original environment variables of .env 20 | process.env = originalEnv; 21 | }); 22 | 23 | it('should return CLIENT_URL and INSTALL_FREEZE_TIME variables (primitive type)', () => { 24 | process.env['WEB_CONF_CLIENT_URL'] = 'http://custom-client.url'; 25 | process.env['WEB_CONF_DB_URI'] = 'my-custom-uri'; 26 | 27 | const extractedConfigs = getEnvConfig(); 28 | 29 | expect(extractedConfigs['CLIENT_URL']).eq('http://custom-client.url'); 30 | expect(extractedConfigs['DB_URI']).eq('my-custom-uri'); 31 | }); 32 | 33 | it('should return JWT environment variables (non-primitive types)', () => { 34 | const jwtObject = { 35 | SECRET: 'sdaddadas', 36 | CUSTOM_VALUE: '1000', 37 | }; 38 | 39 | // Load up query non-primitives 40 | process.env['WEB_CONF_JWT_SECRET'] = jwtObject.SECRET; 41 | process.env['WEB_CONF_JWT_CUSTOM_VALUE'] = jwtObject.CUSTOM_VALUE; 42 | 43 | const extractedConfigs = getEnvConfig(); 44 | 45 | expect(extractedConfigs['JWT']).to.deep.equal(jwtObject); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/misc/env-config-loader.ts: -------------------------------------------------------------------------------- 1 | import { AppConfig } from '../models'; 2 | 3 | /** 4 | * Returns all of the configurations provided from the system environment. 5 | */ 6 | export function getEnvConfig(): unknown { 7 | // Allows initializing configurations from environment, we initialize non-primitive types 8 | const webEnvConfigs = { 9 | SSL_CERTIFICATE: {}, 10 | JWT: {}, 11 | ANGULAR: {}, 12 | } as AppConfig; 13 | 14 | for (const envName in process.env) { 15 | const envValue = process.env[envName]; 16 | let envAdded = false; 17 | 18 | // Check if it's a non-primitive config 19 | for (const envConfName in webEnvConfigs) { 20 | // If it's a non-primitive type 21 | if (Object(webEnvConfigs[envConfName])) { 22 | const re = new RegExp(`WEB_CONF_${envConfName}_(\\w+)`, 'gi'); 23 | const groups = re.exec(envName); 24 | if (groups && groups.length > 1) { 25 | webEnvConfigs[envConfName][groups[1]] = envValue; 26 | envAdded = true; 27 | break; 28 | } 29 | } 30 | } 31 | 32 | // If it's a primitive type 33 | if (!envAdded) { 34 | const result = /WEB_CONF_(\w+)/g.exec(envName); 35 | if (result && result.length > 1) { 36 | webEnvConfigs[result[1]] = envValue; 37 | envAdded = true; 38 | } 39 | } 40 | } 41 | 42 | return webEnvConfigs; 43 | } 44 | -------------------------------------------------------------------------------- /src/misc/index.ts: -------------------------------------------------------------------------------- 1 | export * from './angular-mounter'; 2 | export * from './env-config-loader'; 3 | export * from './utils'; 4 | -------------------------------------------------------------------------------- /src/misc/utils.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import * as fs from 'fs'; 3 | import { promisify } from 'util'; 4 | 5 | import { HttpsOptions } from '@nestjs/common/interfaces/external/https-options.interface'; 6 | 7 | import { UserProfile } from '../../shared/models/user-profile'; 8 | import config from '../config'; 9 | import { IUserProfileDbModel, UserProfileDbModel } from '../database/models/user-profile.db.model'; 10 | import { AppRequest, AppResponse } from '../models'; 11 | 12 | export function getHashedPassword(password: string): Promise { 13 | return bcrypt.genSalt().then(salt => { 14 | return bcrypt.hash(password, salt).then(hash => { 15 | return hash; 16 | }); 17 | }); 18 | } 19 | 20 | export async function saveUser( 21 | user: UserProfile, 22 | ): Promise { 23 | return UserProfileDbModel.create({ 24 | ...user, 25 | password: await getHashedPassword(user.password), 26 | }); 27 | } 28 | 29 | /** 30 | * Converts a middleware into a promise, for easier usage. 31 | * @param middlewareFunc 32 | * @param req 33 | * @param res 34 | */ 35 | export function middlewareToPromise( 36 | middlewareFunc: (req, res, next) => void, 37 | req: AppRequest, 38 | res?: AppResponse, 39 | ): Promise { 40 | return new Promise(resolve => { 41 | middlewareFunc(req, res, err => { 42 | if (err) throw err; 43 | resolve(); 44 | }); 45 | }); 46 | } 47 | 48 | export async function sleepAsync(millis: number): Promise { 49 | await promisify(setTimeout)(millis); 50 | } 51 | 52 | /** 53 | * Returns the HTTPS options, if supported by the environment. 54 | */ 55 | export function getHttpsOptionsFromConfig(): HttpsOptions { 56 | const readFileIfExists = (filePath: string): Buffer => { 57 | if (filePath) return fs.readFileSync(filePath); 58 | }; 59 | 60 | const sslConfig = config.SSL_CERTIFICATE; 61 | 62 | return ( 63 | // If there are any SSL configurations 64 | sslConfig && 65 | Object.keys(sslConfig).length > 0 && { 66 | key: readFileIfExists(config.SSL_CERTIFICATE.KEY), 67 | cert: readFileIfExists(config.SSL_CERTIFICATE.CERT), 68 | ca: readFileIfExists(config.SSL_CERTIFICATE.CA), 69 | } 70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /src/models/app-config.ts: -------------------------------------------------------------------------------- 1 | import * as jwt from 'jsonwebtoken'; 2 | 3 | import { CorsOptions } from '@nestjs/common/interfaces/external/cors-options.interface'; 4 | 5 | export interface AppConfig { 6 | ENVIRONMENT: string; 7 | DB_URI: string; 8 | CLIENT_URL: string; 9 | JWT: { 10 | SECRET: string; 11 | OPTIONS: jwt.SignOptions; 12 | VERIFY_OPTIONS: jwt.VerifyOptions; 13 | }; 14 | SSL_CERTIFICATE: { 15 | KEY: string; 16 | CERT: string; 17 | CA: string; 18 | }; 19 | SOCIAL_CREDENTIALS: unknown; 20 | CORS_OPTIONS: CorsOptions; 21 | LOGS_DIR: string; 22 | LOG_LEVEL: 'debug' | 'info'; 23 | ANGULAR: { 24 | MOUNT: boolean; 25 | USE_SSR?: boolean; 26 | }; 27 | DEBUG_MODE: boolean; 28 | } 29 | -------------------------------------------------------------------------------- /src/models/app-req-res.ts: -------------------------------------------------------------------------------- 1 | import { Request, Response } from 'express'; 2 | 3 | import { UserProfile } from '../../shared/models'; 4 | 5 | export interface AppRequest extends Request { 6 | user: UserProfile 7 | decodedTokenUser: UserProfile; 8 | token: string; // Because express-bearer-token does not come with types, we include this type in the app request 9 | } 10 | 11 | export type AppResponse = Response 12 | -------------------------------------------------------------------------------- /src/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './app-config'; 2 | export * from './app-req-res'; 3 | -------------------------------------------------------------------------------- /src/social-auth/social-auth.controller.ts: -------------------------------------------------------------------------------- 1 | import * as passport from 'passport'; 2 | import { LoginResponse } from 'shared'; 3 | 4 | import { Controller, Get, Param, Request, Response } from '@nestjs/common'; 5 | 6 | import { UserProfile } from '../../shared/models/user-profile'; 7 | import { AuthService } from '../auth/auth.service'; 8 | import { middlewareToPromise } from '../misc/utils'; 9 | import { AppRequest, AppResponse } from '../models'; 10 | 11 | @Controller('social-login') 12 | export class SocialAuthController { 13 | constructor(private authService: AuthService) {} 14 | 15 | @Get(':provider') 16 | async socialLogin( 17 | @Param('provider') provider: string, 18 | @Request() req?: AppRequest, 19 | @Response() res?: AppResponse, 20 | ): Promise { 21 | let user: UserProfile; 22 | 23 | // If this is not unit testing and we have obtained a request 24 | if (req) { 25 | // Wait for the passport middleware to run 26 | await middlewareToPromise( 27 | passport.authenticate(`${provider}-token`, { session: false }), 28 | req, 29 | res, 30 | ); // Authenticate using the provider suitable (google-token, facebook-token) 31 | 32 | // Now handle the user this middleware obtained 33 | user = req.user; 34 | } 35 | 36 | const token = this.authService.generateToken(user); 37 | // Because we injected the request here, we must return the JSON because NestJS expects us to handle the request 38 | res.json({ token, user }); 39 | 40 | return { 41 | token, 42 | user, 43 | }; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/social-auth/social-auth.models.ts: -------------------------------------------------------------------------------- 1 | export interface SocialAuthServiceConfig { 2 | APP_ID: string; 3 | APP_SECRET: string; 4 | } 5 | 6 | export interface SocialAuthServices { 7 | [name: string]: SocialAuthServiceConfig; 8 | } 9 | 10 | export interface SocialAuthModuleConfig { 11 | socialAuthServices: { [name: string]: SocialAuthServiceConfig }; 12 | } 13 | -------------------------------------------------------------------------------- /src/social-auth/social-auth.module.ts: -------------------------------------------------------------------------------- 1 | import { DynamicModule, Module } from '@nestjs/common'; 2 | 3 | import { AuthModule } from '../auth/auth.module'; 4 | import { SocialAuthController } from './social-auth.controller'; 5 | import { SocialAuthModuleConfig } from './social-auth.models'; 6 | import { SocialAuthService } from './social-auth.service'; 7 | 8 | /** 9 | * Responsible of initializing 3rd party social authentication such as 10 | * Google and Facebook. Relies on passport for the authentication process. 11 | */ 12 | @Module({ 13 | imports: [AuthModule], 14 | controllers: [SocialAuthController], 15 | providers: [SocialAuthService], 16 | }) 17 | export class SocialAuthModule { 18 | static register(config: SocialAuthModuleConfig): DynamicModule { 19 | return { 20 | module: SocialAuthModule, 21 | providers: [{ provide: 'SOCIAL_AUTH_MODULE_CONFIG', useValue: config }], 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/social-auth/social-auth.service.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { Application } from 'express'; 3 | import * as passport from 'passport'; 4 | import * as FacebookTokenStrategy from 'passport-facebook-token'; 5 | import { Strategy as GoogleTokenStrategy } from 'passport-google-token'; 6 | import * as randomstring from 'randomstring'; 7 | 8 | import { Inject, Injectable, Logger } from '@nestjs/common'; 9 | import { HttpAdapterHost } from '@nestjs/core'; 10 | 11 | import { UserProfile } from '../../shared/models'; 12 | import appConfig from '../config'; 13 | import { IUserProfileDbModel, UserProfileDbModel } from '../database/models/user-profile.db.model'; 14 | import { SocialAuthModuleConfig } from './social-auth.models'; 15 | 16 | /** 17 | * Responsible of initializing social authentication with 3rd party providers 18 | * (such as Google and Facebook), by initializing passport facebook and google middlewares. 19 | */ 20 | @Injectable() 21 | export class SocialAuthService { 22 | constructor( 23 | @Inject('SOCIAL_AUTH_MODULE_CONFIG') private config: SocialAuthModuleConfig, 24 | adapterHost: HttpAdapterHost, 25 | ) { 26 | // Don't do anything on test as there is not instance of express 27 | if (appConfig.ENVIRONMENT === 'test') return; 28 | 29 | let expressApp: Application; 30 | 31 | try { 32 | // Get the express app in order to initialize social authentication 33 | expressApp = adapterHost.httpAdapter.getInstance() as Application; 34 | } catch (error) { 35 | // Fail with an error but continue 36 | Logger.error(error); 37 | } 38 | 39 | if (!expressApp) 40 | // TODO: This should throw an error instead 41 | Logger.warn( 42 | "Social authentication is not supported, couldn't get handle of express!", 43 | ); 44 | else { 45 | // Now initialize the social authentication 46 | this.init(expressApp); 47 | } 48 | } 49 | 50 | private init(express: Application): void { 51 | express.use(passport.initialize()); 52 | this.initFacebook(); 53 | this.initGoogle(); 54 | } 55 | 56 | private initFacebook(): void { 57 | const facebookCredentials = 58 | this.config.socialAuthServices && 59 | this.config.socialAuthServices['facebook']; 60 | 61 | if (!facebookCredentials) { 62 | Logger.log( 63 | `Facebook credentials are missing, skipping facebook authentication`, 64 | ); 65 | 66 | return; 67 | } 68 | 69 | passport.use( 70 | new FacebookTokenStrategy( 71 | { 72 | clientID: facebookCredentials.APP_ID, 73 | clientSecret: facebookCredentials.APP_SECRET, 74 | }, 75 | (accessToken, refreshToken, profile, done) => { 76 | const fbProfile = profile._json; 77 | const email = fbProfile.email as string; 78 | 79 | this.findOrCreateUser(email, fbProfile, { 80 | email: 'email', 81 | first_name: 'firstName', 82 | last_name: 'lastName', 83 | }) 84 | .then(user => { 85 | done(null, user.toJSON()); 86 | }) 87 | .catch(error => { 88 | done(error, null); 89 | }); 90 | }, 91 | ), 92 | ); 93 | } 94 | 95 | private initGoogle(): void { 96 | const googleCredentials = 97 | this.config.socialAuthServices && 98 | this.config.socialAuthServices['google']; 99 | 100 | if (!googleCredentials) { 101 | Logger.log( 102 | `Google auth credentials are missing, skipping google authentication`, 103 | ); 104 | 105 | return; 106 | } 107 | 108 | passport.use( 109 | new GoogleTokenStrategy( 110 | { 111 | clientID: googleCredentials.APP_ID, 112 | }, 113 | (accessToken, refreshToken, profile, done) => { 114 | const googleProfile = profile._json; 115 | const email = googleProfile.email; 116 | 117 | this.findOrCreateUser(email, googleProfile, { 118 | email: 'email', 119 | given_name: 'firstName', 120 | family_name: 'lastName', 121 | }) 122 | .then(user => { 123 | done(null, user.toJSON()); 124 | }) 125 | .catch(error => { 126 | done(error, null); 127 | }); 128 | }, 129 | ), 130 | ); 131 | } 132 | 133 | /** 134 | * Finds a user based on the provided email. If the email provided already exists, returns a token 135 | * for that user. If the user's email does not exist in the database, create the user according 136 | * to the profile fetched from the 3rd party and saves it. 137 | * @param email 138 | * @param socialProfile 139 | * @param map 140 | */ 141 | async findOrCreateUser( 142 | email: string, 143 | socialProfile: unknown, 144 | map: unknown, 145 | ): Promise { 146 | const user = await UserProfileDbModel.findOne({ email }); 147 | 148 | if (user) { 149 | return user; 150 | } 151 | 152 | const generatedProfile = await this.generateUserFromSocialProfile( 153 | socialProfile, 154 | map, 155 | ); 156 | 157 | return UserProfileDbModel.create(generatedProfile); 158 | } 159 | 160 | /** 161 | * Fills the user profile data from the provided social profile using the map specified 162 | * @param socialProfile The social profile containing the data we want to transfer to our own user profile 163 | * @param userProfile Our user profile To save the data into 164 | * @param map A dictionary that associates the social profile fiels to the user profile fields 165 | */ 166 | generateUserFromSocialProfile( 167 | socialProfile: unknown, 168 | map: unknown, 169 | ): Promise { 170 | const userProfile = {} as UserProfile; 171 | 172 | Object.keys(map).forEach(key => { 173 | const userKey = map[key]; 174 | userProfile[userKey] = socialProfile[key]; 175 | }); 176 | 177 | const password = randomstring.generate(); 178 | 179 | // Generate a random password for this user 180 | return bcrypt.genSalt().then(salt => { 181 | return bcrypt.hash(password, salt).then(hash => { 182 | userProfile.password = hash; 183 | return userProfile; 184 | }); 185 | }); 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/testing/services/database.test.service.ts: -------------------------------------------------------------------------------- 1 | // We use the @tsignore syntax, to skip compilation error when building for production using 'docker build' 2 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 3 | // @ts-ignore 4 | import { MongoMemoryServer } from 'mongodb-memory-server'; 5 | import * as mongoose from 'mongoose'; 6 | 7 | import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common'; 8 | 9 | import { IDatabaseService } from '../../database/database.service'; 10 | import { TestDBSetup } from '../test_db_setup'; 11 | 12 | /** 13 | * The database test service mocks the original database service, by creating a mongo-in-memory server 14 | * instead of using the real database. 15 | */ 16 | @Injectable() 17 | export class DatabaseTestService 18 | implements IDatabaseService, OnModuleInit, OnModuleDestroy { 19 | // A singletone approach to get the handle to the database on tests and easily close it 20 | static instance: DatabaseTestService; 21 | 22 | // Create an instance of the in-memory mongo database 23 | protected mongoServer = new MongoMemoryServer(); 24 | 25 | constructor() { 26 | // Set the instance 27 | DatabaseTestService.instance = this; 28 | } 29 | 30 | async onModuleInit(): Promise { 31 | await this.connect(); 32 | } 33 | 34 | /** 35 | * Sets up the in-memory mongo database, connects to the database and starts the server. 36 | */ 37 | async connect(): Promise { 38 | await this.mongoServer.start(); 39 | const uri = this.mongoServer.getUri(); 40 | await mongoose.connect(uri, { 41 | useNewUrlParser: true, 42 | useUnifiedTopology: true, 43 | useCreateIndex: true, 44 | }); 45 | await TestDBSetup.instance.setup(); 46 | } 47 | 48 | /** 49 | * Closes the database connection and it's related mongo server emulator. 50 | */ 51 | async close(): Promise { 52 | const db = mongoose.connection; 53 | if (db) await db.close(); 54 | await this.mongoServer.stop(); 55 | } 56 | 57 | async onModuleDestroy(): Promise { 58 | await this.close(); 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/testing/test_db_setup.ts: -------------------------------------------------------------------------------- 1 | import { generateMockRootUser, generateMockViewerUser } from '../../shared/testing/mock/user.mock'; 2 | import { UserProfileDbModel } from '../database/models'; 3 | import { saveUser } from '../misc/utils'; 4 | import { DatabaseTestService } from './services/database.test.service'; 5 | 6 | export class TestDBSetup { 7 | private static _instance: TestDBSetup; 8 | static get instance(): TestDBSetup { 9 | if (!this._instance) this._instance = new TestDBSetup(); 10 | 11 | return this._instance; 12 | } 13 | 14 | /** 15 | * Setup the database with mocks and required data. 16 | */ 17 | async setup(): Promise { 18 | await this.format(); 19 | await this.createUsers(); 20 | } 21 | 22 | /** 23 | * Cleans up the database from any data. 24 | */ 25 | async format(): Promise { 26 | await UserProfileDbModel.deleteMany({}); 27 | } 28 | 29 | /** 30 | * Create mock users required for the api tests to run. 31 | */ 32 | async createUsers(): Promise { 33 | // Create a root user which we can connect later to 34 | const rootUser = generateMockRootUser(); 35 | // Create a viewer user which we can connect later 36 | const viewerUser = generateMockViewerUser(); 37 | 38 | await Promise.all([saveUser(rootUser), saveUser(viewerUser)]); 39 | } 40 | 41 | /** 42 | * Cleans up the database with any 'unrelated' mock objects, which are not the required 43 | * mocks. This is required in order to perform clean api tests. 44 | */ 45 | // eslint-disable-next-line @typescript-eslint/no-empty-function 46 | async cleanup(): Promise {} 47 | } 48 | 49 | /** 50 | * Performs clean up of the test database before each run to clear it for tests. 51 | */ 52 | export async function cleanTestDB(): Promise { 53 | if (!TestDBSetup.instance) throw new Error(`Test database was not set up!`); 54 | await TestDBSetup.instance.cleanup(); 55 | } 56 | 57 | export async function closeTestDB(): Promise { 58 | await DatabaseTestService.instance?.close(); 59 | } 60 | -------------------------------------------------------------------------------- /src/testing/test_utils.ts: -------------------------------------------------------------------------------- 1 | import * as request from 'supertest'; 2 | import { SuperTest } from 'supertest'; 3 | 4 | import { INestApplication, ModuleMetadata, ValidationPipe } from '@nestjs/common'; 5 | import { Test, TestingModule } from '@nestjs/testing'; 6 | 7 | import { AppModule } from '../app.module'; 8 | import { DatabaseService } from '../database/database.service'; 9 | import { IDatabaseService } from '../database/database.service'; 10 | import { IUserProfileDbModel, UserProfileDbModel } from '../database/models/user-profile.db.model'; 11 | import { DatabaseTestService } from './services/database.test.service'; 12 | 13 | let nestApp: INestApplication; 14 | 15 | export async function createTestModuleWithDB( 16 | moduleMetadata: ModuleMetadata, 17 | ): Promise<{ module: TestingModule; dbService: IDatabaseService }> { 18 | const module: TestingModule = await Test.createTestingModule({ 19 | imports: moduleMetadata.imports, 20 | providers: [ 21 | { provide: DatabaseService, useClass: DatabaseTestService }, 22 | ...(moduleMetadata.providers || []), 23 | ], 24 | exports: moduleMetadata.exports, 25 | controllers: moduleMetadata.controllers, 26 | }).compile(); 27 | 28 | const dbService = module.get(DatabaseService); 29 | await dbService.connect(); 30 | 31 | return { module, dbService }; 32 | } 33 | 34 | /** 35 | * Returns a supertest request with an already open nest app. Use this 36 | * for your api testing\e2e. 37 | */ 38 | export async function getRequest(): Promise> { 39 | const app = await getNestApp(); 40 | return request(app.getHttpServer()); 41 | } 42 | 43 | /** 44 | * Returns an already existing instance of nest app, or creates a new one 45 | * which will be used for other tests as well. 46 | */ 47 | export async function getNestApp(): Promise { 48 | if (nestApp) return nestApp; 49 | 50 | const moduleFixture: TestingModule = await Test.createTestingModule({ 51 | imports: [AppModule], 52 | }).compile(); 53 | 54 | nestApp = moduleFixture.createNestApplication(); 55 | 56 | // Add validation and transform pipe 57 | nestApp.useGlobalPipes( 58 | new ValidationPipe({ 59 | transform: true, 60 | }), 61 | ); 62 | 63 | await nestApp.init(); 64 | return nestApp; 65 | } 66 | 67 | /** 68 | * Closes the nest app. 69 | */ 70 | export async function closeNestApp(): Promise { 71 | if (nestApp) await nestApp.close(); 72 | // Cleanup the nest app as we closed it 73 | nestApp = null; 74 | } 75 | 76 | /** 77 | * Returns the root mock user from the database. 78 | */ 79 | export function getMockRootUserFromDB(): Promise { 80 | return UserProfileDbModel.findOne({ email: 'root@mail.com' }).exec(); 81 | } 82 | 83 | /** 84 | * Set the required admin header for testing as root user. 85 | * @param request 86 | */ 87 | export function setAdminHeaders(request: request.Test): request.Test { 88 | return request.auth('admin', { type: 'bearer' }); 89 | } 90 | 91 | /** 92 | * Set the required viewer header for testing as viewer user. 93 | * @param request 94 | */ 95 | export function setViewerHeaders(request: request.Test): request.Test { 96 | return request.auth('viewer', { type: 'bearer' }); 97 | } 98 | -------------------------------------------------------------------------------- /tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "angular-src", 5 | "src/tests", 6 | "node_modules", 7 | "dist", 8 | "**/*spec.ts" 9 | ] 10 | } -------------------------------------------------------------------------------- /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 | "types": ["node", "mocha", "chai"], 12 | "typeRoots": ["./node_modules/@types"], 13 | "outDir": "./dist", 14 | "baseUrl": "./", 15 | "incremental": true 16 | }, 17 | "exclude": [ 18 | /* 19 | Because we want to allow intellisense on tests, we don't exclude any test files (*.spec.ts). 20 | Instead we only on build using tsconfig.prod.json in order exclude the test files. 21 | */ 22 | "angular-src", 23 | "src/tests", 24 | "node_modules", 25 | "dist" 26 | ], 27 | "include": ["src", "shared"] 28 | } 29 | --------------------------------------------------------------------------------