├── .angulardoc.json ├── .dockerignore ├── .env ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── .mocharc.js ├── .prettierignore ├── .prettierrc.js ├── .vscode ├── launch.json ├── settings.json ├── tasks.json └── typescript.code-snippets ├── Dockerfile ├── README.md ├── angular-src ├── .gitignore ├── README.md ├── angular.json ├── 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 ├── 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 │ ├── 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.base.json ├── tsconfig.json └── tslint.json ├── build.sh ├── docker-compose.yml ├── install_all.sh ├── package-lock.json ├── package.json ├── predebug.sh ├── rest.http ├── shared ├── index.ts ├── models │ ├── action-response.ts │ ├── index.ts │ ├── login-action-response.ts │ ├── user-profile.model.ts │ └── user-profile.ts ├── shared-utils.ts └── testing │ ├── mock │ └── user.mock.ts │ └── shared_test_utils.ts ├── src ├── config-parser.spec.ts ├── config-parser.ts ├── config.ts ├── config │ ├── default.json │ ├── development.json │ ├── production.json │ └── test.json ├── controllers │ ├── api.controller.spec.ts │ ├── api.controller.ts │ └── social-login.controller.ts ├── decorators │ ├── index.ts │ └── request-user.decorator.ts ├── forms │ ├── index.ts │ ├── register.form.spec.ts │ └── register.form.ts ├── index.ts ├── middlewares │ ├── auth.middleware.spec.ts │ ├── auth.middleware.ts │ ├── cors.middleware.ts │ └── error-handler.middleware.ts ├── misc │ ├── env-config-loader.ts │ ├── index.ts │ └── utils.ts ├── models │ ├── app-config.ts │ ├── app-req-res.ts │ ├── index.ts │ └── user-profile.db.model.ts ├── pipes │ ├── class-transformer.pipe.ts │ └── class-validation.pipe.ts ├── responses.ts ├── server-utils.ts ├── server.ts ├── services │ ├── auth.service.spec.ts │ ├── auth.service.ts │ └── db.service.ts ├── social-auth.ts └── testing │ ├── init_tests.ts │ ├── test_db_setup.ts │ └── test_utils.ts ├── test.sh ├── tsconfig.json ├── tsconfig.prod.json └── tsconfig.test.json /.angulardoc.json: -------------------------------------------------------------------------------- 1 | { 2 | "repoId": "85593139-a21c-4e1b-afcb-47fe03763029", 3 | "lastSync": 0 4 | } -------------------------------------------------------------------------------- /.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 | 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 | 8 | # We configure the DB to allow the access from the web interface using compose 9 | MONGO_INITDB_ROOT_USERNAME=root 10 | MONGO_INITDB_ROOT_PASSWORD=root 11 | 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 | root: true, 3 | parser: '@typescript-eslint/parser', 4 | plugins: ['@typescript-eslint'], 5 | extends: [ 6 | 'eslint:recommended', 7 | 'plugin:@typescript-eslint/recommended', 8 | 'prettier/@typescript-eslint', 9 | ], 10 | env: { 11 | node: true, 12 | }, 13 | rules: {}, 14 | }; 15 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | *.sh text eol=lf 3 | -------------------------------------------------------------------------------- /.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: [12.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 | - run: npm run install:all 36 | - run: npm test 37 | - run: npm run ng:test:ci 38 | - uses: ashley-taylor/junit-report-annotations-action@1.3 39 | if: always() 40 | with: 41 | access-token: ${{ secrets.GITHUB_TOKEN }} 42 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (http://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # Intelij directory 58 | .idea 59 | 60 | # Javascript output directory 61 | dist -------------------------------------------------------------------------------- /.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: 35000, 16 | ui: 'bdd', 17 | exit: true, 18 | require: ['ts-node/register', 'tsconfig-paths/register'], 19 | spec: 'src/**/*.spec.ts' 20 | }; 21 | -------------------------------------------------------------------------------- /.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.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true 3 | }; 4 | -------------------------------------------------------------------------------- /.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": "Launch NodeJS", 10 | "program": "${workspaceRoot}/src/index.ts", 11 | "outFiles": [ 12 | "${workspaceRoot}/dist/**/*.js" 13 | ], 14 | "args": [ 15 | "-debug" 16 | ], 17 | "preLaunchTask": "npm: predebug", 18 | "outputCapture": "std" 19 | }, 20 | { 21 | "type": "node", 22 | "request": "launch", 23 | "name": "Fast Launch NodeJS (run tsc --watch before in the background)", 24 | "program": "${workspaceRoot}/src/index.ts", 25 | "outFiles": [ 26 | "${workspaceRoot}/dist/**/*.js" 27 | ], 28 | "args": [ 29 | "-debug" 30 | ], 31 | "preLaunchTask": "npm: copy-config", 32 | "outputCapture": "std" 33 | }, 34 | { 35 | "name": "Launch Angular", 36 | "type": "chrome", 37 | "request": "launch", 38 | "url": "http://localhost:4200", 39 | "webRoot": "${workspaceRoot}/angular-src" 40 | }, 41 | { 42 | "type": "chrome", 43 | "request": "attach", 44 | "name": "Attach to Chrome", 45 | "port": 9222, 46 | "webRoot": "${workspaceFolder}/angular-src" 47 | }, 48 | { 49 | "type": "node", 50 | "request": "launch", 51 | "name": "Debug Server Mocha Tests", 52 | "program": "${workspaceRoot}/node_modules/mocha/bin/mocha", 53 | "args": [ 54 | "--inspect-brk" 55 | ], 56 | "env": { 57 | "NODE_ENV": "test" 58 | }, 59 | "port": 9229, 60 | "internalConsoleOptions": "openOnSessionStart", 61 | "outputCapture": "std" 62 | }, 63 | { 64 | "name": "Docker Node.js Launch and Attach", 65 | "type": "docker", 66 | "request": "launch", 67 | "preLaunchTask": "docker-run: debug", 68 | "platform": "node" 69 | } 70 | ] 71 | } -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "mochaExplorer.files": "src/**/*.spec.ts", 3 | "mochaExplorer.env": { 4 | "NODE_ENV": "test" 5 | }, 6 | "mochaExplorer.pruneFiles": true, 7 | "files.exclude": { 8 | "**/.git": true, 9 | "**/.svn": true, 10 | "**/.hg": true, 11 | "**/CVS": true, 12 | "**/.DS_Store": true, 13 | "**/dist**": true 14 | }, 15 | "files.watcherExclude": { 16 | "**/.git/objects/**": true, 17 | "**/.git/subtree-cache/**": true, 18 | "**/node_modules/*/**": true, 19 | "**/.hg/store/**": true, 20 | "**/dist/**": true 21 | }, 22 | "editor.codeActionsOnSave": { 23 | "source.fixAll.eslint": true, 24 | }, 25 | "eslint.validate": ["typescript", "javascript"] 26 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "tasks": [ 3 | { 4 | "type": "docker-build", 5 | "label": "docker-build", 6 | "platform": "node", 7 | "dockerBuild": { 8 | "dockerfile": "${workspaceFolder}/Dockerfile", 9 | "context": "${workspaceFolder}" 10 | } 11 | }, 12 | { 13 | "type": "docker-run", 14 | "label": "docker-run: release", 15 | "dependsOn": [ 16 | "docker-build" 17 | ], 18 | "platform": "node" 19 | }, 20 | { 21 | "type": "docker-run", 22 | "label": "docker-run: debug", 23 | "dependsOn": [ 24 | "docker-build" 25 | ], 26 | "dockerRun": { 27 | "env": { 28 | "DEBUG": "*", 29 | "NODE_ENV": "development" 30 | } 31 | }, 32 | "node": { 33 | "enableDebugging": true 34 | } 35 | } 36 | ] 37 | } -------------------------------------------------------------------------------- /.vscode/typescript.code-snippets: -------------------------------------------------------------------------------- 1 | { 2 | "Angular Component Spec": { 3 | "prefix": "ng-comp-spec", 4 | "body": [ 5 | "describe('${0:MyComponent}', () => {", 6 | " let component: ${0};", 7 | " let fixture: ComponentFixture<${0}>;", 8 | "", 9 | " let htmlElement: HTMLElement;", 10 | "", 11 | " beforeEach(async(() => {", 12 | " getCommonTestBed([${0}], []).compileComponents();", 13 | " }));", 14 | "", 15 | " beforeEach(async(() => {", 16 | " fixture = TestBed.createComponent(${0});", 17 | " component = fixture.componentInstance;", 18 | " fixture.detectChanges();", 19 | "", 20 | " htmlElement = fixture.debugElement.nativeElement;", 21 | " }));", 22 | "", 23 | " it('should create', () => {", 24 | " expect(component).toBeTruthy();", 25 | " });", 26 | "});", 27 | "" 28 | ] 29 | }, 30 | "Angular Service Spec": { 31 | "prefix": "ng-service-spec", 32 | "body": [ 33 | "describe('${0:MyService}', () => {", 34 | " let service: ${0};", 35 | " beforeEach(async(() => {", 36 | " getCommonTestBed([], [], [${0}]).compileComponents();", 37 | " service = TestBed.inject(${0});", 38 | " }));", 39 | "", 40 | " it('should create', () => {", 41 | " expect(service).toBeTruthy();", 42 | " });", 43 | "});" 44 | ], 45 | "description": "Angular service spec" 46 | }, 47 | "API tests (get)": { 48 | "prefix": "test-api-get", 49 | "body": [ 50 | "const response = await request.get('${1:/api}');", 51 | "expect(response).to.have.status(200);" 52 | ] 53 | }, 54 | "API tests (get-admin)": { 55 | "prefix": "test-api-admin-get", 56 | "body": [ 57 | "const response = await setAdminHeaders(request.get('${1:/api}'));", 58 | "expect(response).to.have.status(200);" 59 | ] 60 | }, 61 | "API tests (post)": { 62 | "prefix": "test-api-post", 63 | "body": [ 64 | "const response = await request.post('${1:/api}').send({});", 65 | "expect(response).to.have.status(200);" 66 | ] 67 | }, 68 | "API tests (post-admin)": { 69 | "prefix": "test-api-admin-post", 70 | "body": [ 71 | "const response = await setAdminHeaders(request.post('${1:/api}'));", 72 | "expect(response).to.have.status(200);" 73 | ] 74 | }, 75 | } -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Create global variables to be used later 2 | ARG workdir=/app 3 | ARG NODE_ENV=development 4 | 5 | FROM node:12.18.1-alpine3.11 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 22 | 23 | # Copy only package.json files for node_modules installationת 24 | # This allows caching take place 25 | COPY ./package.json ./package.json 26 | COPY ./angular-src/package.json ./angular-src/package.json 27 | 28 | # Copy the node_modules installation script 29 | COPY ./install_all.sh ./install_all.sh 30 | 31 | # Install all dependencies using the script 32 | RUN chmod +x ./install_all.sh && npm run install:all 33 | 34 | # Copy all of the required leftover-files 35 | COPY . . 36 | 37 | # Build web and create a distribution 38 | RUN chmod +x ./build.sh && npm run build \ 39 | # Copy the files required to run to the workdir 40 | && mkdir ${workdir} && cp -Rf ./dist ${workdir} \ 41 | # Before we copy node modules, remove all dev modules 42 | && npm prune --production \ 43 | && cd ./angular-src && npm prune --production && cd ../ \ 44 | # Copy required node modules 45 | && cp -Rf ./node_modules ${workdir} \ 46 | && cp -Rf ./angular-src/node_modules/* ${workdir}/node_modules/ \ 47 | && cp ./package.json ${workdir} \ 48 | # Remove source files, and delete bash 49 | && rm -Rf /compile \ 50 | && apk del bash --purge 51 | 52 | # Create clean image for distribution 53 | FROM base 54 | 55 | # Change the work dir to the directory we are actually running the code 56 | WORKDIR /app 57 | 58 | # Copy distribution files 59 | COPY --from=build /app . 60 | 61 | # Expose the port required for web (http and https) 62 | EXPOSE 80 443 3000 63 | 64 | # Start the built distribution 65 | CMD [ "npm", "start" ] 66 | -------------------------------------------------------------------------------- /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 | /.sass-cache 29 | /connect.lock 30 | /coverage 31 | /libpeerconnection.log 32 | npm-debug.log 33 | yarn-error.log 34 | testem.log 35 | /typings 36 | 37 | # System Files 38 | .DS_Store 39 | Thumbs.db -------------------------------------------------------------------------------- /angular-src/README.md: -------------------------------------------------------------------------------- 1 | # AngularSrc 2 | 3 | This project was generated with [Angular CLI](https://github.com/angular/angular-cli) version 6.0.8. 4 | 5 | ## Development server 6 | 7 | Run `ng serve` for a dev server. Navigate to `http://localhost:4200/`. The app will automatically reload if you change any of the source files. 8 | 9 | ## Code scaffolding 10 | 11 | Run `ng generate component component-name` to generate a new component. You can also use `ng generate directive|pipe|service|class|guard|interface|enum|module`. 12 | 13 | ## Build 14 | 15 | Run `ng build` to build the project. The build artifacts will be stored in the `dist/` directory. Use the `--prod` flag for a production build. 16 | 17 | ## Running unit tests 18 | 19 | Run `ng test` to execute the unit tests via [Karma](https://karma-runner.github.io). 20 | 21 | ## Running end-to-end tests 22 | 23 | Run `ng e2e` to execute the end-to-end tests via [Protractor](http://www.protractortest.org/). 24 | 25 | ## Further help 26 | 27 | To get more help on the Angular CLI use `ng help` or go check out the [Angular CLI README](https://github.com/angular/angular-cli/blob/master/README.md). 28 | -------------------------------------------------------------------------------- /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 | "aot": true, 17 | "outputPath": "dist/browser", 18 | "index": "src/index.html", 19 | "main": "src/main.ts", 20 | "polyfills": "src/polyfills.ts", 21 | "tsConfig": "src/tsconfig.app.json", 22 | "allowedCommonJsDependencies": [ 23 | "validator" 24 | ], 25 | "assets": [ 26 | "src/favicon.ico", 27 | "src/assets" 28 | ], 29 | "styles": [ 30 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 31 | "node_modules/roboto-fontface/css/roboto/roboto-fontface.css", 32 | "node_modules/bootstrap-social/bootstrap-social.css", 33 | "node_modules/ngx-toastr/toastr.css", 34 | "node_modules/@fortawesome/fontawesome-free/css/all.css", 35 | "src/styles.scss" 36 | ], 37 | "scripts": [ 38 | "node_modules/jquery/dist/jquery.min.js", 39 | "node_modules/tether/dist/js/tether.min.js", 40 | "node_modules/popper.js/dist/umd/popper.min.js", 41 | "node_modules/bootstrap/dist/js/bootstrap.min.js" 42 | ] 43 | }, 44 | "configurations": { 45 | "development": { 46 | "budgets": [{ 47 | "type": "anyComponentStyle", 48 | "maximumWarning": "6kb" 49 | }], 50 | "optimization": true, 51 | "outputHashing": "all", 52 | "sourceMap": false, 53 | "extractCss": true, 54 | "namedChunks": false, 55 | "aot": true, 56 | "extractLicenses": true, 57 | "vendorChunk": false, 58 | "buildOptimizer": true 59 | }, 60 | "production": { 61 | "budgets": [{ 62 | "type": "anyComponentStyle", 63 | "maximumWarning": "6kb" 64 | }], 65 | "fileReplacements": [{ 66 | "replace": "src/environments/environment.ts", 67 | "with": "src/environments/environment.prod.ts" 68 | }], 69 | "optimization": true, 70 | "outputHashing": "all", 71 | "sourceMap": false, 72 | "extractCss": true, 73 | "namedChunks": false, 74 | "aot": true, 75 | "extractLicenses": true, 76 | "vendorChunk": false, 77 | "buildOptimizer": true 78 | } 79 | } 80 | }, 81 | "serve": { 82 | "builder": "@angular-devkit/build-angular:dev-server", 83 | "options": { 84 | "browserTarget": "angular-src:build" 85 | }, 86 | "configurations": { 87 | "production": { 88 | "browserTarget": "angular-src:build:production" 89 | } 90 | } 91 | }, 92 | "extract-i18n": { 93 | "builder": "@angular-devkit/build-angular:extract-i18n", 94 | "options": { 95 | "browserTarget": "angular-src:build" 96 | } 97 | }, 98 | "test": { 99 | "builder": "@angular-devkit/build-angular:karma", 100 | "options": { 101 | "main": "src/test.ts", 102 | "polyfills": "src/polyfills.ts", 103 | "tsConfig": "src/tsconfig.spec.json", 104 | "karmaConfig": "src/karma.conf.js", 105 | "styles": [ 106 | "node_modules/bootstrap/dist/css/bootstrap.min.css", 107 | "node_modules/roboto-fontface/css/roboto/roboto-fontface.css", 108 | "node_modules/bootstrap-social/bootstrap-social.css", 109 | "node_modules/ngx-toastr/toastr.css", 110 | "node_modules/@fortawesome/fontawesome-free/css/all.css", 111 | "src/styles.scss" 112 | ], 113 | "scripts": [ 114 | "node_modules/jquery/dist/jquery.min.js", 115 | "node_modules/tether/dist/js/tether.min.js", 116 | "node_modules/popper.js/dist/umd/popper.min.js", 117 | "node_modules/bootstrap/dist/js/bootstrap.min.js" 118 | ], 119 | "assets": [ 120 | "src/favicon.ico", 121 | "src/assets" 122 | ] 123 | } 124 | }, 125 | "lint": { 126 | "builder": "@angular-devkit/build-angular:tslint", 127 | "options": { 128 | "tsConfig": [ 129 | "src/tsconfig.app.json", 130 | "src/tsconfig.spec.json" 131 | ], 132 | "exclude": [ 133 | "**/node_modules/**" 134 | ] 135 | } 136 | }, 137 | "server": { 138 | "builder": "@angular-devkit/build-angular:server", 139 | "options": { 140 | "outputPath": "dist/server", 141 | "main": "server.ts", 142 | "tsConfig": "src/tsconfig.server.json" 143 | }, 144 | "configurations": { 145 | "production": { 146 | "fileReplacements": [{ 147 | "replace": "src/environments/environment.ts", 148 | "with": "src/environments/environment.prod.ts" 149 | }], 150 | "optimization": true 151 | } 152 | } 153 | }, 154 | "serve-ssr": { 155 | "builder": "@nguniversal/builders:ssr-dev-server", 156 | "options": { 157 | "browserTarget": "angular-src:build", 158 | "serverTarget": "angular-src:server" 159 | }, 160 | "configurations": { 161 | "production": { 162 | "browserTarget": "angular-src:build:production", 163 | "serverTarget": "angular-src:server:production" 164 | } 165 | } 166 | }, 167 | "prerender": { 168 | "builder": "@nguniversal/builders:prerender", 169 | "options": { 170 | "browserTarget": "angular-src:build:production", 171 | "serverTarget": "angular-src:server:production", 172 | "routes": [ 173 | "/" 174 | ] 175 | }, 176 | "configurations": { 177 | "production": {} 178 | } 179 | } 180 | } 181 | }, 182 | "angular-src-e2e": { 183 | "root": "e2e/", 184 | "projectType": "application", 185 | "architect": { 186 | "e2e": { 187 | "builder": "@angular-devkit/build-angular:protractor", 188 | "options": { 189 | "protractorConfig": "e2e/protractor.conf.js", 190 | "devServerTarget": "angular-src:serve" 191 | }, 192 | "configurations": { 193 | "production": { 194 | "devServerTarget": "angular-src:serve:production" 195 | } 196 | } 197 | }, 198 | "lint": { 199 | "builder": "@angular-devkit/build-angular:tslint", 200 | "options": { 201 | "tsConfig": "e2e/tsconfig.e2e.json", 202 | "exclude": [ 203 | "**/node_modules/**" 204 | ] 205 | } 206 | } 207 | } 208 | } 209 | }, 210 | "defaultProject": "angular-src", 211 | "schematics": { 212 | "@schematics/angular:component": { 213 | "style": "scss" 214 | } 215 | }, 216 | "cli": { 217 | "analytics": false 218 | } 219 | } -------------------------------------------------------------------------------- /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.base.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.0", 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 --prod", 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 --prod && ng run angular-src:server:production", 18 | "prerender": "ng run angular-src:prerender", 19 | "postinstall": "./node_modules/.bin/ngcc --tsconfig=src/tsconfig.app.json" 20 | }, 21 | "private": true, 22 | "dependencies": { 23 | "@angular/animations": "^10.0.8", 24 | "@angular/common": "^10.0.8", 25 | "@angular/compiler": "^10.0.8", 26 | "@angular/core": "^10.0.8", 27 | "@angular/forms": "^10.0.8", 28 | "@angular/platform-browser": "^10.0.8", 29 | "@angular/platform-browser-dynamic": "^10.0.8", 30 | "@angular/platform-server": "^10.0.8", 31 | "@angular/router": "^10.0.8", 32 | "@fortawesome/fontawesome-free": "^5.14.0", 33 | "@ng-toolkit/universal": "^8.1.0", 34 | "@nguniversal/express-engine": "^9.1.1", 35 | "@ngx-loading-bar/core": "^4.2.0", 36 | "angularx-social-login": "^2.3.1", 37 | "bootstrap": "^4.5.2", 38 | "bootstrap-social": "^5.1.1", 39 | "class-transformer": "^0.2.3", 40 | "class-transformer-validator": "^0.8.0", 41 | "class-validator": "^0.12.2", 42 | "core-js": "^3.6.5", 43 | "cors": "~2.8.4", 44 | "express": "^4.15.2", 45 | "jquery": "^3.5.1", 46 | "ngx-cookie": "^4.0.2", 47 | "ngx-toastr": "^11.3.3", 48 | "popper.js": "^1.16.1", 49 | "roboto-fontface": "^0.10.0", 50 | "rxjs": "^6.6.2", 51 | "tether": "^1.4.7", 52 | "ts-loader": "6.2.1", 53 | "tslib": "^2.0.0", 54 | "zone.js": "~0.10.3" 55 | }, 56 | "devDependencies": { 57 | "@angular-devkit/build-angular": "^0.1000.5", 58 | "@angular/cli": "^10.0.5", 59 | "@angular/compiler-cli": "^10.0.8", 60 | "@angular/language-service": "^10.0.8", 61 | "@nguniversal/builders": "^9.1.1", 62 | "@types/express": "^4.17.7", 63 | "@types/faker": "^4.1.12", 64 | "@types/jasmine": "^3.5.12", 65 | "@types/jasminewd2": "~2.0.8", 66 | "@types/node": "^13.13.15", 67 | "@types/bootstrap": "^4.5.0", 68 | "@types/jquery": "3.3.29", 69 | "codelyzer": "^6.0.0", 70 | "faker": "^4.1.0", 71 | "jasmine-core": "~3.5.0", 72 | "jasmine-spec-reporter": "~5.0.0", 73 | "karma": "~5.0.0", 74 | "karma-chrome-launcher": "~3.1.0", 75 | "karma-coverage-istanbul-reporter": "~3.0.2", 76 | "karma-jasmine": "~3.3.0", 77 | "karma-jasmine-html-reporter": "^1.5.0", 78 | "karma-junit-reporter": "^2.0.1", 79 | "karma-mocha-reporter": "^2.2.5", 80 | "node-sass": "^4.14.1", 81 | "protractor": "~7.0.0", 82 | "ts-node": "~8.6.2", 83 | "tslint": "~6.1.0", 84 | "typescript": "~3.9.7", 85 | "webpack-cli": "^3.3.12" 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /angular-src/server.ts: -------------------------------------------------------------------------------- 1 | import 'reflect-metadata'; 2 | import 'zone.js/dist/zone-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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/angular-src/src/app/app.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/app.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } 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(async(() => { 12 | getCommonTestBed([AppComponent], [RouterTestingModule]).compileComponents(); 13 | })); 14 | 15 | beforeEach(async(() => { 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 'bootstrap'; 2 | 3 | import $ from 'jquery'; 4 | 5 | import { isPlatformBrowser } from '@angular/common'; 6 | import { APP_ID, Component, Inject, PLATFORM_ID } from '@angular/core'; 7 | import { 8 | Event as RouterEvent, NavigationCancel, NavigationEnd, NavigationError, NavigationStart, Router 9 | } from '@angular/router'; 10 | import { LoadingBarService } from '@ngx-loading-bar/core'; 11 | import { AppService } from '@services'; 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 } from '@angular/platform-browser'; 5 | import { Route, RouterModule } from '@angular/router'; 6 | import { NgtUniversalModule } from '@ng-toolkit/universal'; 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 | NgtUniversalModule, 63 | SharedModule, 64 | CoreModule, 65 | RouterModule.forRoot(routes, { enableTracing: false, initialNavigation: 'enabled' }), 66 | FormsModule, 67 | ], 68 | providers: [], 69 | bootstrap: [AppComponent], 70 | }) 71 | export class AppModule {} 72 | -------------------------------------------------------------------------------- /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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/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 { async, ComponentFixture, TestBed } 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(async(() => { 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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/angular-src/src/app/components/home/home.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/home/home.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } 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(async(() => { 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 { async, ComponentFixture, TestBed } 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(async(() => { 25 | getCommonTestBed([LoginComponent], [FormsModule]).compileComponents(); 26 | })); 27 | 28 | beforeEach(async(() => { 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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/angular-src/src/app/components/register/register.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/components/register/register.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, fakeAsync, TestBed } 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(async(() => { 38 | getCommonTestBed([RegisterComponent], [FormsModule]).compileComponents(); 39 | })); 40 | 41 | beforeEach(async(() => { 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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/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 { async, ComponentFixture, fakeAsync, TestBed } 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(async(() => { 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 { async, ComponentFixture, TestBed } 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(async(() => { 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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/angular-src/src/app/core/components/header/header.component.scss -------------------------------------------------------------------------------- /angular-src/src/app/core/components/header/header.component.spec.ts: -------------------------------------------------------------------------------- 1 | import { async, ComponentFixture, TestBed } 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(async(() => { 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 { HTTP_INTERCEPTORS, HttpClientModule } from '@angular/common/http'; 6 | import { NgModule } from '@angular/core'; 7 | import { BrowserModule } from '@angular/platform-browser'; 8 | import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; 9 | import { RouterModule } from '@angular/router'; 10 | import { LoadingBarModule } from '@ngx-loading-bar/core'; 11 | import { ApiService, AppService, AuthGuardService, AuthService, RequestsService } from '@services'; 12 | 13 | import { SharedModule } from '../shared/shared.module'; 14 | import { SocialLoginModule } from '../social-login/social-login.module'; 15 | import { AppHttpInterceptor } from './app-http.interceptor'; 16 | import { FooterComponent } from './components/footer/footer.component'; 17 | import { HeaderComponent } from './components/header/header.component'; 18 | 19 | @NgModule({ 20 | imports: [ 21 | CommonModule, 22 | HttpClientModule, 23 | BrowserModule, 24 | BrowserAnimationsModule, 25 | CookieModule.forRoot(), 26 | LoadingBarModule, 27 | ToastrModule.forRoot({ 28 | timeOut: 5000, 29 | positionClass: 'toast-bottom-right', 30 | }), 31 | RouterModule, 32 | SharedModule, 33 | SocialLoginModule, 34 | ], 35 | declarations: [HeaderComponent, FooterComponent], 36 | providers: [ 37 | ApiService, 38 | AuthService, 39 | AuthGuardService, 40 | AppService, 41 | { 42 | provide: HTTP_INTERCEPTORS, 43 | useClass: AppHttpInterceptor, 44 | multi: true, 45 | }, 46 | RequestsService, 47 | ], 48 | exports: [ 49 | HeaderComponent, 50 | FooterComponent, 51 | LoadingBarModule, 52 | ToastrModule, 53 | SocialLoginModule, 54 | ], 55 | }) 56 | export class CoreModule {} 57 | -------------------------------------------------------------------------------- /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 { ActionResponse, LoginActionResponse, UserProfile } from '../../../../../shared/models'; 7 | import { environment } from '../../../environments/environment'; 8 | 9 | @Injectable() 10 | export class ApiService { 11 | constructor(private httpService: HttpClient) {} 12 | 13 | get serverUrl(): string { 14 | return environment.apiServer; 15 | } 16 | 17 | get apiUrl(): string { 18 | return `${this.serverUrl}/api`; 19 | } 20 | 21 | getApiEndpoint(endpoint: string): string { 22 | return `${this.apiUrl}/${endpoint}`; 23 | } 24 | 25 | login(username: string, password: string): Observable { 26 | const url = this.getApiEndpoint(`login`); 27 | 28 | return this.httpService.post(url, { 29 | username, 30 | password 31 | }); 32 | } 33 | 34 | socialLogin(provider: string, authToken: string): Observable { 35 | const url = this.getApiEndpoint(`social-login/${provider}`); 36 | return this.httpService.get(url, { 37 | headers: { 38 | Authorization: `Bearer ${authToken}`, 39 | access_token: `${authToken}` 40 | }, 41 | withCredentials: true 42 | }); 43 | } 44 | 45 | register(user: UserProfile): Observable { 46 | const url = this.getApiEndpoint('register/'); 47 | return this.httpService.post(url, user); 48 | } 49 | 50 | logout(): Observable> { 51 | const url = this.getApiEndpoint('logout/'); 52 | return this.httpService.get>(url); 53 | } 54 | 55 | getProfile(): Observable { 56 | const url = this.getApiEndpoint(`profile/`); 57 | return this.httpService.get(url); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /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 { async, TestBed } 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(async(() => { 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 { UserProfile } from '../../../../../shared/models'; 8 | import { LoginActionResponse } from '../../../../../shared/models/login-action-response'; 9 | import { ApiService } from './api.service'; 10 | 11 | @Injectable() 12 | export class AuthService { 13 | _user: UserProfile; 14 | userChanged: BehaviorSubject = new BehaviorSubject( 15 | null 16 | ); 17 | private _loginChecked: boolean; 18 | 19 | get user(): UserProfile { 20 | return this._user; 21 | } 22 | 23 | set user(user: UserProfile) { 24 | if (user !== this._user) { 25 | if (user !== null) { 26 | this.loginChecked = true; 27 | } 28 | 29 | this._user = user; 30 | this.userChanged.next(user); 31 | } 32 | } 33 | 34 | get loginChecked(): boolean { 35 | return this._loginChecked; 36 | } 37 | 38 | set loginChecked(loginChecked: boolean) { 39 | this._loginChecked = loginChecked; 40 | } 41 | 42 | get hasCredentials(): boolean { 43 | return !!this.savedToken; 44 | } 45 | 46 | get savedToken(): string { 47 | return this.cookieService.get('auth_token'); 48 | } 49 | 50 | get isLoggedIn(): boolean { 51 | return this.hasCredentials && !!this._user; 52 | } 53 | 54 | get isLoggedInAsync(): Observable | boolean { 55 | if (!this.hasCredentials) { 56 | return false; 57 | } 58 | 59 | if (!this.loginChecked) { 60 | return this.checkLogin().pipe(map((user) => !!user)); 61 | } 62 | 63 | return this.isLoggedIn; 64 | } 65 | 66 | /** 67 | * Checks if a user has a specific role. 68 | * @param roleName 69 | * @param user If not specified, will use the local authenticated user. 70 | */ 71 | hasRole(roleName: string, user?: UserProfile): string { 72 | if (!user) user = this._user; 73 | return user.roles.find((role) => roleName === role); 74 | } 75 | 76 | /** 77 | * Checks if a user has specific roles. 78 | * @param roles The roles to check if exists 79 | * @param user If not specified, will use the local authenticated user. 80 | */ 81 | hasRoles(roles: string[], user?: UserProfile): boolean { 82 | for (const role of roles) { 83 | if (!this.hasRole(role, user)) return false; 84 | } 85 | 86 | return true; 87 | } 88 | 89 | hasRolesAsync(roles: string[]): boolean | Observable { 90 | if (this.isLoggedIn) return this.hasRoles(roles); 91 | 92 | if (!this.loginChecked) { 93 | return this.checkLogin().pipe(map((user) => this.hasRoles(roles, user))); 94 | } 95 | 96 | return false; 97 | } 98 | 99 | constructor( 100 | private apiService: ApiService, 101 | private cookieService: CookieService 102 | ) {} 103 | 104 | checkLogin(): Observable { 105 | if (!this.hasCredentials) { 106 | this.loginChecked = true; 107 | return; 108 | } 109 | 110 | this.loginChecked = false; 111 | return this.apiService.getProfile().pipe( 112 | tap( 113 | (response) => { 114 | this.loginChecked = true; 115 | this.user = response; 116 | }, 117 | () => { 118 | this.loginChecked = true; 119 | } 120 | ) 121 | ); 122 | } 123 | 124 | login(email: string, password: string): Observable { 125 | return this.apiService.login(email, password).pipe( 126 | tap( 127 | (result) => { 128 | this.cookieService.put(`auth_token`, result.data.token); 129 | this.user = result.data.profile; 130 | }, 131 | (error) => { 132 | this.userChanged.error(error); 133 | console.error(error); 134 | } 135 | ) 136 | ); 137 | } 138 | 139 | /** 140 | * Signs into using the social authentication credentails provided. 141 | * @param provider 142 | * @param authToken 143 | */ 144 | socialLogin(provider: string, authToken: string): Promise { 145 | return this.apiService 146 | .socialLogin(provider, authToken) 147 | .toPromise() 148 | .then((result) => { 149 | this.cookieService.put(`auth_token`, result.data.token); 150 | this.user = result.data.profile; 151 | return this.user; 152 | }) 153 | .catch((error) => { 154 | this.userChanged.error(error); 155 | return error; 156 | }); 157 | } 158 | 159 | logout(): Promise { 160 | this.user = null; 161 | this.cookieService.remove('auth_token'); 162 | this.loginChecked = true; 163 | 164 | // We return a promise so we can notify that everything went well (add your own logout logic if required) 165 | return Promise.resolve(null); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /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 | import { ActionResponse } from '../../../../../shared/models'; 9 | 10 | export enum RequestState { 11 | started, 12 | ended, 13 | } 14 | 15 | /** 16 | * Handles all of the onging requests state, which allows us to detect wheter a request is currently happening in the background or not. 17 | * You can use this service to create an app request loading bar (in the header for example). 18 | */ 19 | @Injectable() 20 | export class RequestsService { 21 | private requestsCount = 0; 22 | 23 | @Input() 24 | disableErrorToast = false; 25 | onRequestStateChanged: Subject = new Subject(); 26 | 27 | get isRequestLoading(): boolean { 28 | return this.requestsCount > 0; 29 | } 30 | 31 | constructor(private toastService: ToastrService) {} 32 | 33 | onRequestStarted( 34 | request: Observable> 35 | ): Observable> { 36 | // If we have detected that no previous request is running, emit and event that a request is ongoing now 37 | if (!this.isRequestLoading) { 38 | this.onRequestStateChanged.next(RequestState.started); 39 | } 40 | 41 | // Add the request to the count 42 | ++this.requestsCount; 43 | 44 | // Handle the request data obtained and show an error toast if nessecary 45 | return request.pipe( 46 | tap( 47 | (event: HttpEvent) => { 48 | if (event instanceof HttpResponse) { 49 | this.onRequestEnded(); 50 | } 51 | }, 52 | (errorResponse) => { 53 | if (errorResponse instanceof HttpErrorResponse) { 54 | if (!this.disableErrorToast) { 55 | const errorBody = errorResponse.error as ActionResponse; 56 | this.toastService.error(`An error had occured`, errorBody.error); 57 | } 58 | 59 | this.onRequestEnded(); 60 | } 61 | } 62 | ) 63 | ); 64 | } 65 | 66 | private onRequestEnded(): void { 67 | if (--this.requestsCount === 0) { 68 | this.onRequestStateChanged.next(RequestState.ended); 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /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 '@nguniversal/express-engine/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 { async, ComponentFixture, TestBed } 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(async(() => { 12 | getCommonTestBed([SocialLoginButtonComponent], [SocialLoginModule]).compileComponents(); 13 | })); 14 | 15 | beforeEach(async(() => { 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 | AuthServiceConfig, FacebookLoginProvider, GoogleLoginProvider, 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 = new AuthServiceConfig([ 14 | { 15 | id: GoogleLoginProvider.PROVIDER_ID, 16 | provider: new GoogleLoginProvider(environment.socialLogin.google), 17 | }, 18 | { 19 | id: FacebookLoginProvider.PROVIDER_ID, 20 | provider: new FacebookLoginProvider(environment.socialLogin.facebook), 21 | }, 22 | ]); 23 | 24 | export function provideConfig(): AuthServiceConfig { 25 | return config; 26 | } 27 | 28 | @NgModule({ 29 | imports: [CommonModule, NgxSocialLogin], 30 | declarations: [SocialLoginButtonComponent], 31 | providers: [ 32 | { 33 | provide: AuthServiceConfig, 34 | useFactory: provideConfig, 35 | }, 36 | SocialLoginService, 37 | ], 38 | exports: [NgxSocialLogin, SocialLoginButtonComponent], 39 | }) 40 | export class SocialLoginModule {} 41 | -------------------------------------------------------------------------------- /angular-src/src/app/social-login/social-login.service.ts: -------------------------------------------------------------------------------- 1 | import { 2 | AuthService as SocialAuthService, FacebookLoginProvider, GoogleLoginProvider, 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 { LoginActionResponse, UserProfile } from '../../../../../shared/models'; 6 | import { generateMockRootUser } from '../../../../../shared/testing/mock/user.mock'; 7 | 8 | @Injectable() 9 | export class MockApiService { 10 | // The root user 11 | rootUser: UserProfile = generateMockRootUser(); 12 | 13 | // The list of users registered 14 | registeredUsers: UserProfile[] = []; 15 | 16 | login(username: string, password: string): Observable { 17 | if (username === 'admin' && password === 'admin') { 18 | return of({ 19 | status: 'ok', 20 | data: { 21 | token: 'randomtoken', 22 | profile: this.rootUser, 23 | }, 24 | }); 25 | } 26 | 27 | // All other requests should return an error 28 | return throwError({ 29 | status: 'error', 30 | error: 'Invalid username\\password entered!', 31 | }); 32 | } 33 | 34 | socialLogin(): Observable { 35 | return throwError('Not implemented!'); 36 | } 37 | 38 | register(user: UserProfile): Observable { 39 | this.registeredUsers.push(user); 40 | return of(user); 41 | } 42 | 43 | getProfile(): Observable { 44 | return of(this.rootUser); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /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.forRoot(), 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: [HeaderComponent, FooterComponent, LoadingBarModule, ToastrModule, SocialLoginModule], 53 | }) 54 | export class MockCoreModule {} 55 | -------------------------------------------------------------------------------- /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 | ): TestBedStatic { 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/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/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/dist/zone-error'; // Included with Angular CLI. 21 | -------------------------------------------------------------------------------- /angular-src/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/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 | import { enableProdMode } from '@angular/core'; 2 | 3 | import { environment } from './environments/environment'; 4 | 5 | if (environment.production) { 6 | enableProdMode(); 7 | } 8 | 9 | export { AppServerModule } from './app/app.server.module'; 10 | 11 | export { renderModule, renderModuleFactory } from '@angular/platform-server'; 12 | -------------------------------------------------------------------------------- /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 NgClass support on SVG elements */ 35 | // import 'classlist.js'; // Run `npm install --save classlist.js`. 36 | /** IE10 and IE11 requires the following for the Reflect API. */ 37 | // import 'core-js/es6/reflect'; 38 | /** Evergreen browsers require these. **/ 39 | // Used for reflect-metadata in JIT. If you use AOT (and only Angular decorators), you can remove. 40 | import 'core-js/es7/reflect'; 41 | /** 42 | * Web Animations `@angular/platform-browser/animations` 43 | * Only required if AnimationBuilder is used within the application and using IE/Edge or Safari. 44 | * Standard animation support in Angular DOES NOT require any polyfills (as of Angular 6.0). 45 | **/ 46 | // import 'web-animations-js'; // Run `npm install --save web-animations-js`. 47 | /** 48 | * By default, zone.js will patch all possible macroTask and DomEvents 49 | * user can disable parts of macroTask/DomEvents patch by setting following flags 50 | */ 51 | // (window as any).__Zone_disable_requestAnimationFrame = true; // disable patch requestAnimationFrame 52 | // (window as any).__Zone_disable_on_property = true; // disable patch onProperty such as onclick 53 | // (window as any).__zone_symbol__BLACK_LISTED_EVENTS = ['scroll', 'mousemove']; // disable patch specified eventNames 54 | /* 55 | * in IE/Edge developer tools, the addEventListener will also be wrapped by zone.js 56 | * with the following flag, it will bypass `zone.js` patch for IE/Edge 57 | */ 58 | // (window as any).__Zone_enable_cross_context_check = true; 59 | /*************************************************************************************************** 60 | * Zone JS is required by default for Angular itself. 61 | */ 62 | import 'zone.js/dist/zone'; // Included with Angular CLI. 63 | 64 | /*************************************************************************************************** 65 | * APPLICATION IMPORTS 66 | */ 67 | -------------------------------------------------------------------------------- /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/dist/zone-testing'; 3 | 4 | import { getTestBed } from '@angular/core/testing'; 5 | import { 6 | BrowserDynamicTestingModule, platformBrowserDynamicTesting 7 | } from '@angular/platform-browser-dynamic/testing'; 8 | 9 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 10 | declare const require: any; 11 | 12 | // First, initialize the Angular testing environment. 13 | getTestBed().initTestEnvironment( 14 | BrowserDynamicTestingModule, 15 | platformBrowserDynamicTesting() 16 | ); 17 | // Then we find all the tests. 18 | const context = require.context('./', true, /\.spec\.ts$/); 19 | // And load the modules. 20 | context.keys().map(context); 21 | -------------------------------------------------------------------------------- /angular-src/src/tsconfig.app.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig.base.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 | ], "target": "es2016" 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.base.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.base.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 | "emitDecoratorMetadata": true, 12 | "experimentalDecorators": true, 13 | "target": "es5", 14 | "typeRoots": [ 15 | "node_modules/@types" 16 | ], 17 | "lib": [ 18 | "es2017", 19 | "dom" 20 | ], 21 | "paths": { 22 | "@services": [ 23 | "src/app/core/services/index.ts" 24 | ], 25 | "@core/*": [ 26 | "src/app/core/*" 27 | ], 28 | "@shared/*": [ 29 | "src/app/shared/*" 30 | ] 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /angular-src/tsconfig.json: -------------------------------------------------------------------------------- 1 | /* 2 | This is a "Solution Style" tsconfig.json file, and is used by editors and TypeScript’s language server to improve development experience. 3 | It is not intended to be used to perform a compilation. 4 | 5 | To learn more about this file see: https://angular.io/config/solution-tsconfig. 6 | */ 7 | { 8 | "files": [], 9 | "references": [ 10 | { 11 | "path": "./src/tsconfig.app.json" 12 | }, 13 | { 14 | "path": "./src/tsconfig.spec.json" 15 | }, 16 | { 17 | "path": "./src/tsconfig.server.json" 18 | }, 19 | { 20 | "path": "./e2e/tsconfig.e2e.json" 21 | } 22 | ] 23 | } -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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.prod.json 31 | check_errcode "Failed to compile typescript! aborting script!" 32 | 33 | echo "Copying configuration files..." 34 | cp -Rf src/config dist/src/config 35 | check_errcode "Failed to copy configuration files! aborting script!" 36 | 37 | echo "Starting to configure Angular app..." 38 | pushd angular-src 39 | 40 | echo "Building Angular app for $ENV..." 41 | ./node_modules/.bin/ng build --aot --prod --configuration $ENV 42 | check_errcode "Failed to build angular! stopping script!" 43 | 44 | # TODO: Remove this 'if' statment until the 'fi' if you don't want SSR at all 45 | if [ $ENV == "production" ]; then 46 | echo "Building Angular app for SSR..." 47 | ./node_modules/.bin/ng run angular-src:server:production 48 | check_errcode "Failed to build Angular app for SSR! aborting script!" 49 | else 50 | echo "Skipping build for SSR as environment is NOT production" 51 | fi 52 | 53 | echo "Copying angular dist into dist directory..." 54 | mkdir ../dist/src/dist 55 | cp -Rf dist ../dist/src 56 | check_errcode "Failed to copy anuglar dist files! aborting script!" 57 | 58 | echo "Removing angular-src dist directory..." 59 | rm -rf dist 60 | 61 | # Go back to the current directory 62 | popd 63 | 64 | echo "-- Finished building Angular & NodeJS, check dist directory --" 65 | -------------------------------------------------------------------------------- /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 | 27 | # Test database used for running tests only 28 | test-db: 29 | image: mongo:latest 30 | environment: 31 | MONGO_INITDB_ROOT_USERNAME: test 32 | MONGO_INITDB_ROOT_PASSWORD: test 33 | MONGO_INITDB_DATABASE: admin 34 | ports: 35 | - 27019:27017 36 | 37 | volumes: 38 | mongodata: 39 | -------------------------------------------------------------------------------- /install_all.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Install all of the dependencies, including the development and productin 4 | function install_deps() { 5 | # Install dev depdendencies but ignore postinstall script 6 | npm install --only=dev --ignore-scripts 7 | # Install prod dependencies and run postinstall script if exists 8 | npm install --only=prod 9 | } 10 | 11 | echo "Installing all dependencies for NodeJS & Angular..." 12 | install_deps 13 | 14 | # Install the angular deps 15 | pushd angular-src 16 | install_deps 17 | popd 18 | echo "Finished installing dependencies!" 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nemex", 3 | "version": "0.7.1", 4 | "description": "", 5 | "scripts": { 6 | "install:all": "bash ./install_all.sh", 7 | "build": "bash ./build.sh", 8 | "angular": "cd angular-src && npm start", 9 | "start": "node dist/src/index.js", 10 | "build:node": "rm -rf dist && tsc -p .", 11 | "build:nodelive": "./node_modules/.bin/nodemon --ignore angular-src --exec ./node_modules/.bin/ts-node -- ./src/index.ts -debug", 12 | "build:angular": "cd angular-src && ng build --aot --prod", 13 | "node": "npm run predebug && npm run build:nodelive", 14 | "ng:test": "cd ./angular-src && ./node_modules/.bin/ng test", 15 | "ng:test:ci": "export IS_CI=true; cd ./angular-src && ./node_modules/.bin/ng test --no-watch --no-progress --browsers=Headless_Chrome", 16 | "ng:e2e": "cd ./angular-src && ./node_modules/.bin/ng e2e", 17 | "test": "bash ./test.sh", 18 | "predebug": "bash ./predebug.sh", 19 | "lint": "eslint . --ext .js,.jsx,.ts" 20 | }, 21 | "engines": { 22 | "node": "12.14.0", 23 | "npm": "6.13.4" 24 | }, 25 | "keywords": [], 26 | "author": "", 27 | "license": "ISC", 28 | "devDependencies": { 29 | "@types/bcryptjs": "^2.4.2", 30 | "@types/chai": "^4.2.12", 31 | "@types/chai-as-promised": "^7.1.3", 32 | "@types/chai-spies": "^1.0.1", 33 | "@types/compression": "0.0.36", 34 | "@types/cors": "^2.8.7", 35 | "@types/express": "^4.17.7", 36 | "@types/faker": "^4.1.12", 37 | "@types/jsonwebtoken": "^7.2.7", 38 | "@types/lodash": "^4.14.159", 39 | "@types/mocha": "^7.0.2", 40 | "@types/mongodb": "^3.5.25", 41 | "@types/mongoose": "^5.7.36", 42 | "@types/mongoose-paginate": "^5.0.8", 43 | "@types/node": "^8.10.62", 44 | "@types/passport": "^1.0.4", 45 | "@types/passport-facebook-token": "^0.4.34", 46 | "@types/passport-local": "^1.0.33", 47 | "@types/randomstring": "^1.1.6", 48 | "@types/request": "^2.48.5", 49 | "@types/superagent": "^3.8.7", 50 | "@typescript-eslint/eslint-plugin": "^3.9.0", 51 | "@typescript-eslint/parser": "^3.9.0", 52 | "chai": "^4.2.0", 53 | "chai-as-promised": "^7.1.1", 54 | "chai-exclude": "^2.0.2", 55 | "chai-http": "^4.3.0", 56 | "chai-spies": "^1.0.0", 57 | "eslint": "^7.7.0", 58 | "eslint-config-prettier": "^6.11.0", 59 | "faker": "^4.1.0", 60 | "mocha": "^7.2.0", 61 | "nodemon": "^1.19.4", 62 | "prettier": "2.0.5", 63 | "reflect-metadata": "^0.1.13", 64 | "request": "^2.88.2", 65 | "ts-loader": "^6.2.2", 66 | "ts-node": "^3.3.0", 67 | "tsconfig-paths": "^3.9.0", 68 | "typescript": "^3.9.7" 69 | }, 70 | "dependencies": { 71 | "@tsed/common": "^5.62.0", 72 | "@tsed/core": "^5.62.0", 73 | "@tsed/di": "^5.62.0", 74 | "@tsed/exceptions": "^5.62.0", 75 | "@tsed/testing": "^5.62.0", 76 | "bcryptjs": "^2.4.3", 77 | "body-parser": "^1.18.2", 78 | "class-transformer": "^0.2.3", 79 | "class-transformer-validator": "^0.8.0", 80 | "class-validator": "^0.12.2", 81 | "compression": "^1.7.4", 82 | "config": "^1.30.0", 83 | "cors": "^2.8.4", 84 | "email-validator": "^2.0.4", 85 | "express": "^4.16.2", 86 | "express-bearer-token": "^2.1.1", 87 | "express-https-redirect": "^1.0.0", 88 | "express-session": "^1.17.1", 89 | "jsonwebtoken": "^8.3.0", 90 | "lodash": "^4.17.19", 91 | "mongoose": "^5.9.28", 92 | "passport": "^0.4.1", 93 | "passport-facebook-token": "^3.3.0", 94 | "passport-google-token": "^0.1.2", 95 | "passport-local": "^1.0.0", 96 | "randomstring": "^1.1.5", 97 | "tedious": "^2.3.1", 98 | "uuid": "^3.4.0" 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /predebug.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | echo "Building typescript..." 4 | 5 | rm -rf ./dist 6 | ./node_modules/.bin/tsc -p ./tsconfig.prod.json 7 | 8 | echo "Copying configuration files..." 9 | rm -rf ./dist/src/config 10 | mkdir ./dist/src/config 11 | cp -Rf ./src/config/* ./dist/src/config 12 | echo "Configuration files succesfully copied!" 13 | echo "Ready for debugging!" 14 | -------------------------------------------------------------------------------- /rest.http: -------------------------------------------------------------------------------- 1 | @api = http://localhost:3000/api 2 | @token = eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlcyI6WyJhZG1pbiJdLCJfaWQiOiI1YjQ5ZDczYmEzZjQxYzFiY2U5MGNhZTciLCJlbWFpbCI6InNoeW5ldEBnbWFpbC5jb20iLCJmaXJzdE5hbWUiOiJTaGF5IiwibGFzdE5hbWUiOiJZYWlzaCIsIl9fdiI6MCwiaWF0IjoxNTYwMjg0NTE5fQ.kDKZNR0TeVLlOXcQk9HdtAMAP4I0o9EKr84n3iM2drs 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": "shynet@gmail.com", 13 | "password": "Aa123456" 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":"johnnybravo.com", 26 | "password": "mypass", 27 | "firstName": "Johnny", 28 | "lastName": "Bravo" 29 | } -------------------------------------------------------------------------------- /shared/index.ts: -------------------------------------------------------------------------------- 1 | export * from './models'; 2 | export * from './shared-utils'; 3 | -------------------------------------------------------------------------------- /shared/models/action-response.ts: -------------------------------------------------------------------------------- 1 | export interface ActionResponse { 2 | status: string; 3 | error?: string; 4 | data?: T; 5 | } 6 | -------------------------------------------------------------------------------- /shared/models/index.ts: -------------------------------------------------------------------------------- 1 | export { ActionResponse } from './action-response'; 2 | export { LoginActionResponse } from './login-action-response'; 3 | export { UserProfile } from './user-profile'; 4 | export { UserProfileModel } from './user-profile.model'; 5 | -------------------------------------------------------------------------------- /shared/models/login-action-response.ts: -------------------------------------------------------------------------------- 1 | import { ActionResponse } from './action-response'; 2 | import { UserProfile } from './user-profile'; 3 | 4 | export type LoginActionResponse = ActionResponse<{ token: string, profile: UserProfile}> 5 | -------------------------------------------------------------------------------- /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 | /** 18 | * Generates a mock user for the tests. 19 | * @param roles 20 | */ 21 | export function generateMockUser(roles: string[] = []): UserProfile { 22 | return { 23 | email: faker.internet.email(), 24 | firstName: faker.name.firstName(), 25 | lastName: faker.name.lastName(), 26 | password: faker.internet.password(), 27 | roles: roles, 28 | }; 29 | } 30 | -------------------------------------------------------------------------------- /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/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.to.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.to.contain('./').and.to.equal(expectedValue); 19 | }); 20 | 21 | it('should return boolean values', () => { 22 | const trueValue = 'true'; 23 | const falseValue = 'false'; 24 | 25 | expect(parseEnvString(trueValue)).to.be.true; 26 | expect(parseEnvString(falseValue)).to.be.false; 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/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.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is responsible for the configurations, it is generated according to the 3 | * environment specified in the NODE_ENV environment variable. 4 | */ 5 | 6 | import * as cors from 'cors'; 7 | import * as _ from 'lodash'; 8 | import * as path from 'path'; 9 | 10 | import { $log } from '@tsed/common'; 11 | 12 | import { parseConfig } from './config-parser'; 13 | import { getEnvConfig } from './misc/env-config-loader'; 14 | import { AppConfig } from './models'; 15 | 16 | process.env['NODE_CONFIG_DIR'] = path.join(__dirname, '/config'); 17 | // eslint-disable-next-line @typescript-eslint/no-var-requires 18 | const config = require('config'); 19 | let exportedConfig = config as AppConfig; 20 | 21 | /* 22 | This file is responsible for the entire configuration of the server. 23 | */ 24 | let isDebugging = false; 25 | 26 | // Get the environment configurations 27 | const webEnvConfigs = getEnvConfig(); 28 | 29 | // Read the supplied arguments 30 | process.argv.forEach(function (val) { 31 | if (val != null && typeof val === 'string') { 32 | if (val === '-debug') isDebugging = true; 33 | } 34 | }); 35 | 36 | const DEBUG_MODE = isDebugging; 37 | 38 | const CORS_OPTIONS: cors.CorsOptions = { 39 | origin: exportedConfig.CLIENT_URL, 40 | optionsSuccessStatus: 200, // some legacy browsers (IE11, various SmartTVs) choke on 204 41 | allowedHeaders: [ 42 | 'Origin', 43 | 'X-Requested-With', 44 | 'Content-Type', 45 | 'Accept', 46 | 'Authentication', 47 | 'Authorization', 48 | 'x-auth', 49 | 'access_token', 50 | ], 51 | methods: 'GET,HEAD,POST,OPTIONS,PUT,PATCH,DELETE', 52 | credentials: true, 53 | preflightContinue: true, 54 | }; 55 | 56 | const ENVIRONMENT = process.env['NODE_ENV'] || 'development'; 57 | 58 | exportedConfig = { 59 | ...exportedConfig, 60 | ENVIRONMENT, 61 | CORS_OPTIONS, 62 | DEBUG_MODE, 63 | }; 64 | 65 | /* 66 | Merge the web env configs with the exported configs (so we won't delete any existing values), 67 | environment configurations always have higher priority. 68 | */ 69 | exportedConfig = _.merge(exportedConfig, webEnvConfigs); 70 | 71 | // Parse all config values to replace special chars such as '~' 72 | parseConfig(exportedConfig); 73 | 74 | // Print out the configurations we are loading 75 | $log.info(`Loaded config: ${JSON.stringify(exportedConfig, null, 2)}`); 76 | 77 | export default exportedConfig as AppConfig; 78 | -------------------------------------------------------------------------------- /src/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "LOG_LEVEL": "info", 3 | "USE_SSR": false 4 | } -------------------------------------------------------------------------------- /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 | } -------------------------------------------------------------------------------- /src/config/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "DB_URI": "mongodb://test:test@localhost:27019/?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 | } -------------------------------------------------------------------------------- /src/controllers/api.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import '../testing/init_tests'; 2 | 3 | import { expect } from 'chai'; 4 | 5 | import { ActionResponse, LoginActionResponse, UserProfile } from '@shared'; 6 | 7 | import { generateMockUser } from '../../shared/testing/mock/user.mock'; 8 | import { initChaiHttp, setAdminHeaders } from '../testing/test_utils'; 9 | 10 | describe('API Controller', async () => { 11 | let request: ChaiHttp.Agent; 12 | 13 | beforeEach(async () => { 14 | request = await initChaiHttp(); 15 | }); 16 | 17 | it('should return ok action response', async () => { 18 | const response = await request.get('/api/test'); 19 | 20 | // Check if the status is ok 21 | expect(response).to.have.status(200); 22 | 23 | // Read the result 24 | const result: ActionResponse = response.body; 25 | expect(result).to.be.an('object').and.to.have.property('status').which.equals('ok'); 26 | }); 27 | 28 | it('should return error', async () => { 29 | const response = await request.get('/api/error-test'); 30 | 31 | // Check if the status is an error 32 | expect(response).to.have.status(400); 33 | 34 | // Read the result 35 | const result: ActionResponse = response.body; 36 | expect(result).to.be.an('object').and.to.have.property('status').which.equals('error'); 37 | }); 38 | 39 | it('should say Hello world!', async () => { 40 | const response = await request.get('/api/say-something').query({ whatToSay: 'Hello world!' }); 41 | 42 | // Check if the status is ok 43 | expect(response).to.have.status(200); 44 | 45 | // Read the result 46 | const result: ActionResponse = response.body; 47 | expect(result).to.be.an('object').and.to.have.property('status').which.equals('ok'); 48 | expect(result).to.have.property('data').which.equals('Hello world!'); 49 | }); 50 | 51 | it('should login as root and obtain a token', async () => { 52 | const response = await setAdminHeaders( 53 | request.post('/api/login').send({ username: 'root@mail.com', password: 'root' }) 54 | ); 55 | 56 | expect(response).to.have.status(200); 57 | 58 | const result: LoginActionResponse = response.body; 59 | 60 | expect(result).to.be.an('object').and.have.property('status').which.eq('ok'); 61 | expect(result).to.have.property('data').which.is.an('object').and.have.keys('token', 'profile'); 62 | 63 | expect(result.data.token).to.be.a('string').which.have.length.greaterThan(0); 64 | expect(result.data.profile).to.be.an('object').which.has.property('email').that.eq('root@mail.com'); 65 | }); 66 | 67 | it('should fail to login as root with an invalid password', async () => { 68 | const response = await setAdminHeaders( 69 | request.post('/api/login').send({ username: 'root@mail.com', password: 'wrongpassword' }) 70 | ); 71 | 72 | expect(response).to.have.status(400); 73 | 74 | const result: LoginActionResponse = response.body; 75 | 76 | expect(result).to.be.an('object').and.have.property('status').which.eq('error'); 77 | expect(result).not.to.have.property('data'); 78 | }); 79 | 80 | it('should return the root user profile', async () => { 81 | const response = await setAdminHeaders(request.get('/api/profile')); 82 | 83 | expect(response).to.have.status(200); 84 | 85 | const result: UserProfile = response.body; 86 | 87 | expect(result).to.be.an('object').which.have.property('email').that.eq('root@mail.com'); 88 | }); 89 | 90 | it('should fail to return a user profile', async () => { 91 | const response = await request.get('/api/profile'); 92 | 93 | expect(response).to.have.status(401); 94 | 95 | const result: ActionResponse = response.body; 96 | 97 | expect(result).to.be.an('object').and.have.property('status').which.eq('error'); 98 | expect(result).not.to.have.property('data'); 99 | }); 100 | 101 | it('should access route /admin successfully', async () => { 102 | const response = await setAdminHeaders(request.get('/api/admin')); 103 | 104 | expect(response).to.have.status(200); 105 | 106 | const result: ActionResponse = response.body; 107 | 108 | expect(result).to.be.an('object').which.have.property('status').that.eq('ok'); 109 | }); 110 | 111 | it('should fail to access route /admin', async () => { 112 | const response = await request.get('/api/admin'); 113 | 114 | expect(response).to.have.status(401); 115 | 116 | const result: ActionResponse = response.body; 117 | 118 | expect(result).to.be.an('object').and.have.property('status').which.eq('error'); 119 | }); 120 | 121 | it('should register a new user successfully', async () => { 122 | const testUser = generateMockUser(); 123 | const response = await request.post('/api/register').send(testUser); 124 | 125 | expect(response).to.have.status(200); 126 | 127 | const result: UserProfile = response.body; 128 | 129 | // Check if the created user is identical (remove fields that are not returned) 130 | expect(result).to.be.an('object').excluding(['__v', '_id', 'password']).deep.eq(testUser); 131 | }); 132 | 133 | it('should fail to register a new user because one field is not valid', async () => { 134 | const testUser: UserProfile = generateMockUser(); 135 | testUser.email = 'notanemail'; 136 | 137 | const response = await request.post('/api/register').send(testUser); 138 | 139 | expect(response).to.have.status(400); 140 | 141 | const result: ActionResponse = response.body; 142 | expect(result).to.be.an('object').and.have.property('status').which.eq('error'); 143 | }); 144 | }); 145 | -------------------------------------------------------------------------------- /src/controllers/api.controller.ts: -------------------------------------------------------------------------------- 1 | import { ActionResponse, LoginActionResponse, UserProfile } from '@shared'; 2 | import { BodyParams, Controller, Get, Post, QueryParams, UseBefore } from '@tsed/common'; 3 | import { BadRequest } from '@tsed/exceptions'; 4 | 5 | import { RequestUser } from '../decorators/request-user.decorator'; 6 | import { RegisterForm } from '../forms'; 7 | import { AuthMiddleware } from '../middlewares/auth.middleware'; 8 | import { UserProfileDbModel } from '../models'; 9 | import * as responses from '../responses'; 10 | import { AuthService } from '../services/auth.service'; 11 | 12 | @Controller('/') 13 | export class ApiController { 14 | constructor(private authService: AuthService) {} 15 | 16 | @Get('/test') 17 | test(): ActionResponse { 18 | return responses.getOkayResponse(); 19 | } 20 | 21 | @Get('/error-test') 22 | errorTest(): ActionResponse { 23 | throw new BadRequest('This is an error!'); 24 | } 25 | 26 | @Get('/say-something') 27 | saySomething( 28 | @QueryParams('whatToSay') whatToSay: string 29 | ): ActionResponse { 30 | return responses.getOkayResponse(whatToSay); 31 | } 32 | 33 | @Post('/login') 34 | login( 35 | @BodyParams('username') username: string, 36 | @BodyParams('password') password: string 37 | ): Promise { 38 | return this.authService.authenticate(username, password).then((user) => { 39 | if (!user) throw new BadRequest(`Username or password are invalid!`); 40 | 41 | const token = this.authService.generateToken(user.toJSON()); 42 | const response = responses.getOkayResponse(); 43 | 44 | return { 45 | ...response, 46 | data: { 47 | token: token, 48 | profile: user, 49 | }, 50 | }; 51 | }); 52 | } 53 | 54 | @Get('/profile') 55 | @UseBefore(AuthMiddleware) 56 | getProfile(@RequestUser() user: UserProfile): UserProfile { 57 | return user; 58 | } 59 | 60 | @Get('/admin') 61 | @UseBefore(AuthMiddleware) 62 | adminTest(): ActionResponse { 63 | return this.test(); 64 | } 65 | 66 | @Get('/logout') 67 | @UseBefore(AuthMiddleware) 68 | logout(): Promise> { 69 | // TODO: Implement your own logout mechanisem (JWT token blacklists, etc...) 70 | return Promise.reject(`Logout has not been implemented!`); 71 | } 72 | 73 | // TODO: Maybe move to model validations of Ts.ED? http://v4.tsed.io/docs/model.html#example 74 | @Post('/register') 75 | register( 76 | // Don't validate using the built in models 77 | @BodyParams() registerForm: RegisterForm 78 | ): Promise { 79 | // Hash the user password and create it afterwards 80 | return registerForm.getHashedPassword().then((hashedPassword) => { 81 | return UserProfileDbModel.create({ 82 | ...registerForm, 83 | password: hashedPassword, 84 | }); 85 | }); 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /src/controllers/social-login.controller.ts: -------------------------------------------------------------------------------- 1 | import * as passport from 'passport'; 2 | 3 | import { AppRequest, AppResponse } from '@models'; 4 | import { BodyParams, Controller, Get, PathParams, Req, Res } from '@tsed/common'; 5 | 6 | import { LoginActionResponse, UserProfile } from '../../shared/models'; 7 | import * as responses from '../responses'; 8 | import { middlewareToPromise } from '../server-utils'; 9 | import { AuthService } from '../services/auth.service'; 10 | 11 | @Controller('/social-login') 12 | export class SocialLoginController { 13 | constructor(private authService: AuthService) {} 14 | 15 | @Get('/:provider') 16 | async socialLogin( 17 | @PathParams('provider') provider: string, 18 | @BodyParams() user: UserProfile, 19 | @Req() req?: AppRequest, 20 | @Res() res?: AppResponse 21 | ): Promise { 22 | // If this is not unit testing and we have obtained a request 23 | if (req) { 24 | // Wait for the passport middleware to run 25 | await middlewareToPromise( 26 | passport.authenticate(`${provider}-token`, { session: false }), 27 | req, 28 | res 29 | ); // Authenticate using the provider suitable (google-token, facebook-token) 30 | user = req.user; 31 | } 32 | 33 | const token = this.authService.generateToken(user); 34 | return responses.getOkayResponse({ 35 | token, 36 | profile: user, 37 | }); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './request-user.decorator'; 2 | -------------------------------------------------------------------------------- /src/decorators/request-user.decorator.ts: -------------------------------------------------------------------------------- 1 | import { Filter, IFilter, ParseService, UseFilter } from '@tsed/common'; 2 | 3 | @Filter() 4 | export class RequestUserFilter implements IFilter { 5 | constructor(private parseService: ParseService) {} 6 | 7 | transform(expression: string, request: unknown): unknown { 8 | return this.parseService.eval(expression, request['user']); 9 | } 10 | } 11 | 12 | /** 13 | * Returns the authenticated user (extracted from the request.user object). 14 | */ 15 | export function RequestUser(): ParameterDecorator { 16 | return UseFilter(RequestUserFilter); 17 | } 18 | -------------------------------------------------------------------------------- /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 | registerForm = new RegisterForm(); 14 | 15 | // Set the user properties in the object 16 | Object.assign(registerForm, user); 17 | }); 18 | 19 | it('should return a hashed password', async () => { 20 | const result = await registerForm.getHashedPassword(); 21 | expect(result).to.be.a('string').and.have.length.greaterThan(0); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /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/index.ts: -------------------------------------------------------------------------------- 1 | import { $log } from '@tsed/common'; 2 | 3 | import { Server } from './server'; 4 | 5 | // Initialize server 6 | const server = new Server(); 7 | 8 | // Start the server 9 | server 10 | .start() 11 | .then(() => { 12 | $log.info(`Server is now listening!`); 13 | }) 14 | .catch((err) => { 15 | $log.error(err); 16 | }); 17 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import '../testing/init_tests'; 3 | 4 | import { expect, spy } from 'chai'; 5 | import { AuthService } from 'src/services/auth.service'; 6 | 7 | import { AppRequest } from '@models'; 8 | import { UserProfile } from '@shared'; 9 | import { InternalServerError, Unauthorized } from '@tsed/exceptions'; 10 | import { TestContext } from '@tsed/testing'; 11 | 12 | import { AuthMiddleware } from './auth.middleware'; 13 | 14 | describe('AuthMiddleware', () => { 15 | let authMiddleware: AuthMiddleware; 16 | let authService: AuthService; 17 | const sandbox: ChaiSpies.Sandbox = spy.sandbox(); 18 | const validToken = 'thisisavalidtoken'; 19 | const mockedUser = { email: 'validuser' } as UserProfile; 20 | 21 | before(() => { 22 | authMiddleware = TestContext.injector.get(AuthMiddleware); 23 | authService = TestContext.injector.get(AuthService); 24 | }); 25 | 26 | beforeEach(() => { 27 | sandbox.restore(); 28 | 29 | // Mock the getUserFromToken to allow passing the database and JWT authentication 30 | sandbox.on(authService, 'getUserFromToken', async (token: string) => { 31 | if (token === validToken) { 32 | return mockedUser; 33 | } else throw new Error(`Invalid token was provided!`); 34 | }); 35 | }); 36 | 37 | it('should pass on "OPTIONS" request method', async () => { 38 | const mockedRequest = { method: 'OPTIONS' } as AppRequest; 39 | await expect(authMiddleware.use(mockedRequest, null)).not.rejectedWith(); 40 | }); 41 | 42 | it('should throw an UnauthorizedException because of a missing token', async () => { 43 | const mockedRequest = {} as AppRequest; 44 | await expect(authMiddleware.use(mockedRequest, null)).rejectedWith(Unauthorized); 45 | }); 46 | 47 | it('should throw an InternalServerError because token is invalid', async () => { 48 | const mockedRequest = { token: 'dsadsadasd' } as AppRequest; 49 | await expect(authMiddleware.use(mockedRequest, null)).rejectedWith(InternalServerError); 50 | }); 51 | 52 | it('should pass with request.user.username set to "validuser"', async () => { 53 | const mockedRequest = { token: validToken } as AppRequest; 54 | await authMiddleware.use(mockedRequest, null); 55 | expect(mockedRequest.user).to.eq(mockedUser); 56 | }); 57 | 58 | it('should pass with request.user set to the mocked user', async () => { 59 | const mockedRequest = { token: validToken } as AppRequest; 60 | await authMiddleware.use(mockedRequest, null); 61 | expect(mockedRequest.user).to.eq(mockedUser); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /src/middlewares/auth.middleware.ts: -------------------------------------------------------------------------------- 1 | import { DocumentQuery } from 'mongoose'; 2 | 3 | import { EndpointInfo, IMiddleware, Middleware, Req } from '@tsed/common'; 4 | import { Forbidden, InternalServerError, Unauthorized } from '@tsed/exceptions'; 5 | 6 | import config from '../config'; 7 | import { AppRequest } from '../models/app-req-res'; 8 | import { IUserProfileDbModel } from '../models/user-profile.db.model'; 9 | // import { AppRequest, IUserProfileDbModel, UserProfileDbModel } from '@models'; 10 | import { AuthService } from '../services/auth.service'; 11 | 12 | /** 13 | * This authentication middleware validates the user token, makes sure if it is still valid in the database 14 | * and checks if the user has the specified (if specified) role. 15 | */ 16 | @Middleware() 17 | export class AuthMiddleware implements IMiddleware { 18 | constructor(private auth: AuthService) {} 19 | 20 | public async use( 21 | @Req() request: AppRequest, 22 | @EndpointInfo() endpoint: EndpointInfo 23 | ): Promise { 24 | // Always allow OPTIONS requests to pass 25 | if (request.method === 'OPTIONS') return; 26 | 27 | // retrieve options given to the @UseAuth decorator 28 | const options = (endpoint && endpoint.get(AuthMiddleware)) || {}; 29 | 30 | const handleUserPromise = ( 31 | promise: DocumentQuery 32 | ) => { 33 | return promise 34 | .then((user) => { 35 | request.user = user; 36 | 37 | // If any roles were specified, check if the user has it 38 | if (options && options.role) { 39 | if (user.roles.findIndex((role) => role === options.role) === -1) 40 | throw new Forbidden(`You don't have the permissions required!`); 41 | } 42 | }) 43 | .catch((err: unknown) => { 44 | throw new InternalServerError( 45 | `An error had occurred while authenticating user`, 46 | err 47 | ); 48 | }); 49 | }; 50 | 51 | // Check if we have a token 52 | if (request.token) { 53 | // If we are working on test, allow special cases instead of using full tokens 54 | if (config.ENVIRONMENT === 'test') { 55 | switch (request.token) { 56 | case 'admin': 57 | return handleUserPromise(this.auth.getUserFromDB('root@mail.com')); 58 | } 59 | } 60 | 61 | // if it's a normal authentication scenario, allow it 62 | return handleUserPromise(this.auth.getUserFromToken(request.token)); 63 | } 64 | 65 | throw new Unauthorized(`No credentials were provided!`); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/middlewares/cors.middleware.ts: -------------------------------------------------------------------------------- 1 | import * as cors from 'cors'; 2 | 3 | import { IMiddleware, Middleware, Req, Res } from '@tsed/common'; 4 | 5 | import config from '../config'; 6 | import { AppRequest, AppResponse } from '../models'; 7 | 8 | /** 9 | * This middleware provides CORS middleware according to the cors library (https://www.npmjs.com/package/cors). 10 | */ 11 | @Middleware() 12 | export class CorsMiddleware implements IMiddleware { 13 | public use( 14 | @Req() request: AppRequest, 15 | @Res() response: AppResponse 16 | ): Promise { 17 | return new Promise((resolve, reject) => { 18 | cors(config.CORS_OPTIONS)(request, response, (err) => { 19 | if (err) return reject(err); 20 | resolve(); 21 | }); 22 | }); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/middlewares/error-handler.middleware.ts: -------------------------------------------------------------------------------- 1 | import { ValidationError } from 'class-validator'; 2 | 3 | import { $log, Err, GlobalErrorHandlerMiddleware, OverrideProvider, Req, Res } from '@tsed/common'; 4 | import { BadRequest, Exception } from '@tsed/exceptions'; 5 | 6 | import { getFormValidationErrorText } from '../../shared/shared-utils'; 7 | 8 | /** 9 | * Overriding the global error handling allows us to throw invalid form errors and handle them correctly. 10 | */ 11 | @OverrideProvider(GlobalErrorHandlerMiddleware) 12 | export class ErrorHandlerMiddleware extends GlobalErrorHandlerMiddleware { 13 | use( 14 | @Err() error: unknown, 15 | @Req() request: Req, 16 | @Res() response: Res 17 | ): unknown { 18 | // Check if the error is a form validation error 19 | if (error instanceof Array) { 20 | if (error.length > 0 && error[0] instanceof ValidationError) 21 | // Check if the first error in the array is a validation error 22 | // If so, print a bad-request with all of the form validation errors 23 | error = new BadRequest(getFormValidationErrorText(error)); 24 | } 25 | 26 | // If it's an HTTP exception, return it to the client correctly for the client to handle 27 | if (error instanceof Exception) { 28 | // Because we overrided Ts.ED default error handler, we should log all of the errors 29 | $log.error(error); 30 | 31 | return response.status(error.status || 500).json({ 32 | status: 'error', 33 | error: error.message, 34 | }); 35 | } 36 | 37 | // We don't know of this error, return it 38 | return super.use(error, request, response); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /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 = {} as AppConfig; 9 | 10 | for (const envName in process.env) { 11 | const envValue = process.env[envName]; 12 | let envAdded = false; 13 | 14 | // Check if it's a non-primitive config 15 | for (const envConfName in webEnvConfigs) { 16 | // If it's a non-primitive type 17 | if (Object(webEnvConfigs[envConfName])) { 18 | const re = new RegExp(`WEB_CONF_${envConfName}_(\\w+)`, 'gi'); 19 | const groups = re.exec(envName); 20 | if (groups && groups.length > 1) { 21 | webEnvConfigs[envConfName][groups[1]] = envValue; 22 | envAdded = true; 23 | break; 24 | } 25 | } 26 | } 27 | 28 | // If it's a primitive type 29 | if (!envAdded) { 30 | const result = /WEB_CONF_(\w+)/g.exec(envName); 31 | if (result && result.length > 1) { 32 | webEnvConfigs[result[1]] = envValue; 33 | envAdded = true; 34 | } 35 | } 36 | } 37 | 38 | return webEnvConfigs; 39 | } 40 | -------------------------------------------------------------------------------- /src/misc/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shy2net/nodejs-angular-starter/43f7aa5928f3f0d518e40ce0314fcc9f5a0bd84f/src/misc/index.ts -------------------------------------------------------------------------------- /src/misc/utils.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | 3 | import { UserProfile } from '../../shared/models/user-profile'; 4 | import { IUserProfileDbModel, UserProfileDbModel } from '../models/user-profile.db.model'; 5 | 6 | export function getHashedPassword(password: string): Promise { 7 | return bcrypt.genSalt().then((salt) => { 8 | return bcrypt.hash(password, salt).then((hash) => { 9 | return hash; 10 | }); 11 | }); 12 | } 13 | 14 | export async function saveUser( 15 | user: UserProfile 16 | ): Promise { 17 | return UserProfileDbModel.create({ 18 | ...user, 19 | password: await getHashedPassword(user.password), 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/models/app-config.ts: -------------------------------------------------------------------------------- 1 | import * as cors from 'cors'; 2 | import * as jwt from 'jsonwebtoken'; 3 | 4 | export interface AppConfig { 5 | ENVIRONMENT: string; 6 | DB_URI: string; 7 | CLIENT_URL: string; 8 | JWT: { 9 | SECRET: string; 10 | OPTIONS: jwt.SignOptions; 11 | VERIFY_OPTIONS: jwt.VerifyOptions; 12 | }; 13 | SSL_CERTIFICATE: { 14 | KEY: string; 15 | CERT: string; 16 | CA: string; 17 | }; 18 | SOCIAL_CREDENTIALS: unknown; 19 | CORS_OPTIONS: cors.CorsOptions; 20 | LOGS_DIR: string; 21 | LOG_LEVEL: 'debug' | 'info'; 22 | USE_SSR: boolean; 23 | DEBUG_MODE: boolean; 24 | } 25 | -------------------------------------------------------------------------------- /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 | export * from './user-profile.db.model'; 4 | -------------------------------------------------------------------------------- /src/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/pipes/class-transformer.pipe.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | 3 | import { DeserializerPipe, IPipe, ParamMetadata } from '@tsed/common'; 4 | import { OverrideProvider } from '@tsed/di'; 5 | 6 | @OverrideProvider(DeserializerPipe) 7 | export class ClassTransformerPipe implements IPipe { 8 | transform(value: unknown, metadata: ParamMetadata): unknown { 9 | return plainToClass(metadata.type, value); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/pipes/class-validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { plainToClass } from 'class-transformer'; 2 | import { validate } from 'class-validator'; 3 | 4 | import { IPipe, OverrideProvider, ParamMetadata, ValidationPipe } from '@tsed/common'; 5 | 6 | // Based on: https://tsed.io/docs/validation.html#custom-validation 7 | 8 | /** 9 | * A pipe which validates using built in ts.Ed validation using class-validator. 10 | */ 11 | @OverrideProvider(ValidationPipe) 12 | export class ClassValidationPipe extends ValidationPipe 13 | implements IPipe { 14 | async transform(value: unknown, metadata: ParamMetadata): Promise { 15 | // Apply super validations if required 16 | value = super.transform(value, metadata); 17 | 18 | if (!this.shouldValidate(metadata)) { 19 | // there is no type and collectionType 20 | return value; 21 | } 22 | 23 | const object = plainToClass(metadata.type, value); 24 | const errors = await validate(object); 25 | 26 | // We handle this errors array on the global error handling middleware 27 | if (errors.length > 0) throw errors; 28 | 29 | return value; 30 | } 31 | 32 | protected shouldValidate(metadata: ParamMetadata): boolean { 33 | const types: unknown[] = [String, Boolean, Number, Array, Object]; 34 | 35 | return !super.shouldValidate(metadata) || !types.includes(metadata.type); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/responses.ts: -------------------------------------------------------------------------------- 1 | import { ActionResponse } from '../shared/models'; 2 | 3 | export function getOkayResponse(data?: unknown): ActionResponse { 4 | return { 5 | status: 'ok', 6 | data: data, 7 | } as ActionResponse; 8 | } 9 | 10 | export function getErrorResponse(error: unknown): ActionResponse { 11 | return { 12 | status: 'error', 13 | error: error, 14 | } as ActionResponse; 15 | } 16 | 17 | // FIXME: Remove this file as it is not required! 18 | -------------------------------------------------------------------------------- /src/server-utils.ts: -------------------------------------------------------------------------------- 1 | import { AppRequest, AppResponse } from './models/app-req-res'; 2 | 3 | /** 4 | * Converts a middleware into a promise, for easier usage with tS.ED framework. 5 | * @param middlewareFunc 6 | * @param req 7 | * @param res 8 | */ 9 | export function middlewareToPromise( 10 | middlewareFunc: (req, res, next) => void, 11 | req: AppRequest, 12 | res?: AppResponse 13 | ): Promise { 14 | return new Promise((resolve) => { 15 | middlewareFunc(req, res, (err) => { 16 | if (err) throw err; 17 | resolve(); 18 | }); 19 | }); 20 | } 21 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import './middlewares/error-handler.middleware'; 2 | import './pipes/class-transformer.pipe'; 3 | import './pipes/class-validation.pipe'; 4 | 5 | import * as bodyParser from 'body-parser'; 6 | import * as compress from 'compression'; 7 | import * as cors from 'cors'; 8 | import * as express from 'express'; 9 | import * as httpsRedirect from 'express-https-redirect'; 10 | import * as fs from 'fs'; 11 | import { ServerOptions } from 'https'; 12 | import * as path from 'path'; 13 | 14 | import { 15 | $log, GlobalAcceptMimesMiddleware, IRoute, ServerLoader, ServerSettings 16 | } from '@tsed/common'; 17 | 18 | import config from './config'; 19 | import { AuthService } from './services/auth.service'; 20 | import socialAuth from './social-auth'; 21 | 22 | const rootDir = __dirname; 23 | 24 | // Configurations we want to load 25 | const httpPort = process.env.PORT || 3000; 26 | const httpsPort = process.env.PORT || 443; 27 | 28 | let httpsOptions: ServerOptions = null; 29 | 30 | if (config.SSL_CERTIFICATE) { 31 | $log.info(`SSL Configurations detected, loading HTTPS certificate...`); 32 | try { 33 | httpsOptions = { 34 | key: fs.readFileSync(config.SSL_CERTIFICATE.KEY, 'utf8'), 35 | cert: fs.readFileSync(config.SSL_CERTIFICATE.CERT, 'utf8'), 36 | ca: fs.readFileSync(config.SSL_CERTIFICATE.CA, 'utf8'), 37 | }; 38 | } catch (e) { 39 | httpsOptions = null; 40 | $log.error(`Failed to load SSL certificate!`, e); 41 | } 42 | } 43 | 44 | @ServerSettings({ 45 | rootDir, 46 | acceptMimes: ['application/json'], 47 | mount: { 48 | '/api': `${rootDir}/controllers/**/*.ts`, 49 | }, 50 | httpPort, 51 | httpsPort: httpsOptions ? httpsPort : false, 52 | httpsOptions, 53 | }) 54 | export class Server extends ServerLoader { 55 | /** 56 | * This method let you configure the express middleware required by your application to works. 57 | * @returns {Server} 58 | */ 59 | $beforeRoutesInit(): void | Promise { 60 | this.use(GlobalAcceptMimesMiddleware) 61 | .use(cors(config.CORS_OPTIONS)) // Enable CORS (for angular) 62 | .use(compress({})) // Compress all data sent to the client 63 | .use(bodyParser.json()) // Use body parser for easier JSON parsing 64 | .use( 65 | bodyParser.urlencoded({ 66 | extended: true, 67 | }) 68 | ); 69 | 70 | // If SSL certificate is configured, enable redirect to HTTPS 71 | if (config.SSL_CERTIFICATE) { 72 | $log.info( 73 | `SSL certificate config detected, port 80 is now being listened and redirected automatically to https!` 74 | ); 75 | this.settings.httpPort = 80; // Use port '80' (usual HTTP port) to redirect all requests 76 | this.use('/', httpsRedirect(true)); 77 | } 78 | 79 | AuthService.initMiddleware(this.expressApp); 80 | socialAuth.init(this.expressApp); 81 | 82 | return null; 83 | } 84 | 85 | /** 86 | * Called after routes has been mounted. 87 | */ 88 | $afterRoutesInit(): void { 89 | // If we are not on debug mode, we need to deliver angular as well 90 | if (!config.DEBUG_MODE) { 91 | // If we want to use SSR, mount it 92 | if (config.USE_SSR) this.mountAngularSSR(); 93 | else this.mountAngular(); // Just use angular normally if no ssr was defined 94 | } 95 | } 96 | 97 | /** 98 | * Mounts angular using Server-Side-Rendering (Recommended for SEO) 99 | */ 100 | private mountAngularSSR(): void { 101 | // The dist folder of compiled angular 102 | const DIST_FOLDER = path.join(__dirname, 'dist'); 103 | 104 | // The compiled server file (angular-src/server.ts) path 105 | // eslint-disable-next-line @typescript-eslint/no-var-requires 106 | const ngApp = require(path.join(DIST_FOLDER, 'server/main')); 107 | 108 | // Init the ng-app using SSR 109 | ngApp.init(this.expressApp, path.join(DIST_FOLDER, '/browser')); 110 | } 111 | 112 | /** 113 | * Mounts angular as is with no SSR. 114 | */ 115 | private mountAngular(): void { 116 | // Point static path to Angular 2 distribution 117 | this.expressApp.use(express.static(path.join(__dirname, 'dist/browser'))); 118 | 119 | // Deliever the Angular 2 distribution 120 | this.expressApp.get('*', function (req, res) { 121 | res.sendFile(path.join(__dirname, 'dist/browser/index.html')); 122 | }); 123 | } 124 | 125 | /** 126 | * Returns the logger configurations. 127 | */ 128 | private loadLoggerConfigurations() { 129 | // All logs are saved to the logs directory by default, you can specify custom directory in the associated configuration file ('LOGS_DIR') 130 | const logsDir = config.LOGS_DIR || path.join(__dirname, 'logs'); 131 | 132 | // Add file appenders (app.log for all logs, error.log only for errors) 133 | $log.appenders.set('file-error-log', { 134 | type: 'file', 135 | filename: path.join(logsDir, `error.log`), 136 | levels: ['error'], 137 | }); 138 | 139 | // --> Uncomment this line if you want to log all data 140 | // .set('file-log', { 141 | // type: 'file', 142 | // filename: path.join(logsDir, `app.log`) 143 | // }); 144 | 145 | const loggerConfig = { 146 | debug: config.DEBUG_MODE, 147 | level: config.LOG_LEVEL || 'info', 148 | /* --> Uncomment to add request logging 149 | requestFields: ['reqId', 'method', 'url', 'headers', 'body', 'query', 'params', 'duration'], 150 | logRequest: true 151 | */ 152 | }; 153 | 154 | this.settings.set('logger', loggerConfig); 155 | } 156 | 157 | /** 158 | * Override set settings by configuring custom settings. 159 | * @param settings 160 | */ 161 | protected async loadSettingsAndInjector(): Promise { 162 | // Apply the logger configurations 163 | this.loadLoggerConfigurations(); 164 | 165 | return super.loadSettingsAndInjector(); 166 | } 167 | 168 | start(): Promise { 169 | if (config.DEBUG_MODE) $log.info(`Debug mode is ON`); 170 | $log.info( 171 | `** Loaded configurations for environment: ${config.ENVIRONMENT} **` 172 | ); 173 | return super.start(); 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /src/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import 'mocha'; 2 | import '../testing/init_tests'; 3 | 4 | import { expect, spy } from 'chai'; 5 | 6 | import { Unauthorized } from '@tsed/exceptions'; 7 | import { TestContext } from '@tsed/testing'; 8 | 9 | import { getMockRootUserFromDB } from '../testing/test_utils'; 10 | import { AuthService } from './auth.service'; 11 | 12 | describe('AuthService', () => { 13 | let authService: AuthService; 14 | const sandbox: ChaiSpies.Sandbox = spy.sandbox(); 15 | 16 | before(() => { 17 | authService = TestContext.injector.get(AuthService); 18 | }); 19 | 20 | beforeEach(() => { 21 | sandbox.restore(); 22 | }); 23 | 24 | it('should authenticate successfully', async () => { 25 | const user = await authService.authenticate('root@mail.com', 'root'); 26 | expect(user).to.be.an('object').and.have.property('email').which.eq('root@mail.com'); 27 | }); 28 | 29 | it('should fail to authenticate and return Unauthorized exception', async () => { 30 | await expect(authService.authenticate('random@mail.com', 'randompassword')).to.be.rejectedWith( 31 | Unauthorized 32 | ); 33 | }); 34 | 35 | it('should generate a token for the provided user and decode it back', async () => { 36 | const rootUser = (await getMockRootUserFromDB()).toJSON(); 37 | const token = authService.generateToken(rootUser); 38 | expect(token).to.be.a('string').and.have.length.greaterThan(0); 39 | 40 | // Expect the root user to be equal to the decoded user from the token 41 | const decodedUser = authService.decodeToken(token); 42 | expect(decodedUser).excluding(['iat', '_id']).to.be.deep.eq(rootUser); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /src/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import * as bcrypt from 'bcryptjs'; 2 | import { Application } from 'express'; 3 | import * as bearerToken from 'express-bearer-token'; 4 | import * as jwt from 'jsonwebtoken'; 5 | import { DocumentQuery } from 'mongoose'; 6 | 7 | import { Service } from '@tsed/di'; 8 | import { Unauthorized } from '@tsed/exceptions'; 9 | 10 | import { UserProfile } from '../../shared/models'; 11 | import config from '../config'; 12 | import { UserProfileDbModel } from '../models'; 13 | import { IUserProfileDbModel } from '../models/user-profile.db.model'; 14 | 15 | @Service() 16 | export class AuthService { 17 | static initMiddleware(express: Application): void { 18 | // Allow parsing bearer tokens easily 19 | express.use(bearerToken()); 20 | } 21 | 22 | /** 23 | * Checks if the provided username and password valid, if so, returns the user match. If not, returns null. 24 | * @param email 25 | * @param password 26 | */ 27 | authenticate(email: string, password: string): Promise { 28 | return UserProfileDbModel.findOne({ email }).then((user) => { 29 | if (!user) throw new Unauthorized('Email or password are invalid!'); 30 | 31 | return bcrypt.compare(password, user.password).then((match) => { 32 | return match && 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 = jwt.verify( 48 | token, 49 | config.JWT.SECRET 50 | ) as IUserProfileDbModel; 51 | 52 | if (decodedUser) { 53 | // If the user has been decoded successfully, check it against the database 54 | return UserProfileDbModel.findById(decodedUser._id); 55 | } 56 | } 57 | 58 | /** 59 | * Generates a JWT token with the specified user data. 60 | * @param user 61 | */ 62 | generateToken(user: UserProfile): string { 63 | return jwt.sign(user, config.JWT.SECRET, config.JWT.OPTIONS); 64 | } 65 | 66 | /** 67 | * Decodes a JWT token and returns the user found. 68 | * @param token 69 | */ 70 | decodeToken(token: string): UserProfile { 71 | return jwt.verify( 72 | token, 73 | config.JWT.SECRET, 74 | config.JWT.VERIFY_OPTIONS 75 | ) as UserProfile; 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/services/db.service.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | import { $log } from '@tsed/common'; 4 | import { Service } from '@tsed/di'; 5 | 6 | import config from '../config'; 7 | 8 | @Service() 9 | export class DatabaseService { 10 | db: mongoose.Connection; 11 | 12 | protected async $onInit(): Promise { 13 | return new Promise((resolve, reject) => { 14 | $log.info(`Connecting to database...`); 15 | mongoose.connect(config.DB_URI); 16 | const db = mongoose.connection; 17 | this.db = db; 18 | 19 | db.on('error', (error) => { 20 | $log.error('Unable to connect to MongoDB server: ${error}'); 21 | reject(error); 22 | }); 23 | 24 | db.once('open', function () { 25 | $log.info('Connected to MongoDB server'); 26 | this.mongoose = mongoose; 27 | resolve(); 28 | }); 29 | }); 30 | } 31 | 32 | $onDestroy(): void { 33 | const db = mongoose.connection; 34 | if (db) db.close(); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /src/social-auth.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 { UserProfile } from '../shared/models'; 9 | import config from './config'; 10 | import { IUserProfileDbModel, UserProfileDbModel } from './models/user-profile.db.model'; 11 | 12 | export class SocialAuthentication { 13 | init(express: Application): void { 14 | express.use(passport.initialize()); 15 | this.initFacebook(); 16 | this.initGoogle(); 17 | } 18 | 19 | initFacebook(): void { 20 | const facebookCredentails = config.SOCIAL_CREDENTIALS['facebook'] as { 21 | APP_ID: string; 22 | APP_SECRET: string; 23 | }; 24 | 25 | passport.use( 26 | new FacebookTokenStrategy( 27 | { 28 | clientID: facebookCredentails.APP_ID, 29 | clientSecret: facebookCredentails.APP_SECRET, 30 | }, 31 | (accessToken, refreshToken, profile, done) => { 32 | const fbProfile = profile._json; 33 | const email = fbProfile.email as string; 34 | 35 | this.findOrCreateUser(email, fbProfile, { 36 | email: 'email', 37 | first_name: 'firstName', 38 | last_name: 'lastName', 39 | }) 40 | .then((user) => { 41 | done(null, user.toJSON()); 42 | }) 43 | .catch((error) => { 44 | done(error, null); 45 | }); 46 | } 47 | ) 48 | ); 49 | } 50 | 51 | initGoogle(): void { 52 | const googleCredentails = config.SOCIAL_CREDENTIALS['google'] as { 53 | APP_ID: string; 54 | APP_SECRET: string; 55 | }; 56 | 57 | passport.use( 58 | new GoogleTokenStrategy( 59 | { 60 | clientID: googleCredentails.APP_ID, 61 | }, 62 | (accessToken, refreshToken, profile, done) => { 63 | const googleProfile = profile._json; 64 | const email = googleProfile.email; 65 | 66 | this.findOrCreateUser(email, googleProfile, { 67 | email: 'email', 68 | given_name: 'firstName', 69 | family_name: 'lastName', 70 | }) 71 | .then((user) => { 72 | done(null, user.toJSON()); 73 | }) 74 | .catch((error) => { 75 | done(error, null); 76 | }); 77 | } 78 | ) 79 | ); 80 | } 81 | 82 | /** 83 | * Finds a user based on the provided email. If the email provided already exists, returns a token 84 | * for that user. If the user's email does not exist in the database, create the user according 85 | * to the profile fetched from the 3rd party and saves it. 86 | * @param email 87 | * @param socialProfile 88 | * @param map 89 | */ 90 | async findOrCreateUser( 91 | email: string, 92 | socialProfile: unknown, 93 | map: unknown 94 | ): Promise { 95 | const user = await UserProfileDbModel.findOne({ email }); 96 | 97 | if (user) { 98 | return user; 99 | } 100 | 101 | const generatedProfile = await this.generateUserFromSocialProfile( 102 | socialProfile, 103 | map 104 | ); 105 | 106 | return UserProfileDbModel.create(generatedProfile); 107 | } 108 | 109 | /** 110 | * Fills the user profile data from the provided social profile using the map specified 111 | * @param socialProfile The social profile containing the data we want to transfer to our own user profile 112 | * @param userProfile Our user profile To save the data into 113 | * @param map A dictionary that associates the social profile fiels to the user profile fields 114 | */ 115 | generateUserFromSocialProfile( 116 | socialProfile: unknown, 117 | map: unknown 118 | ): Promise { 119 | const userProfile = {} as UserProfile; 120 | 121 | Object.keys(map).forEach((key) => { 122 | const userKey = map[key]; 123 | userProfile[userKey] = socialProfile[key]; 124 | }); 125 | 126 | const password = randomstring.generate(); 127 | 128 | // Generate a random password for this user 129 | return bcrypt.genSalt().then((salt) => { 130 | return bcrypt.hash(password, salt).then((hash) => { 131 | userProfile.password = hash; 132 | return userProfile; 133 | }); 134 | }); 135 | } 136 | } 137 | 138 | export default new SocialAuthentication(); 139 | -------------------------------------------------------------------------------- /src/testing/init_tests.ts: -------------------------------------------------------------------------------- 1 | import 'assert'; 2 | import 'chai-http'; 3 | import 'mocha'; 4 | 5 | import * as chai from 'chai'; 6 | import * as promisedChai from 'chai-as-promised'; 7 | import chaiExclude from 'chai-exclude'; 8 | import * as spies from 'chai-spies'; 9 | 10 | import { TestContext } from '@tsed/testing'; 11 | 12 | import { initTestDB, TestDBSetup } from './test_db_setup'; 13 | 14 | // Add chai plugins 15 | chai.use(spies); 16 | chai.use(promisedChai); 17 | chai.use(chaiExclude); 18 | // eslint-disable-next-line @typescript-eslint/no-var-requires 19 | chai.use(require('chai-http')); 20 | 21 | let mockSetup: TestDBSetup; 22 | 23 | /* 24 | Create global hooks and create a global TextContext, this 25 | allows us to use the same db instance instead of opening it again and again 26 | */ 27 | before(TestContext.create); 28 | 29 | before(async function () { 30 | mockSetup = await initTestDB(TestContext.injector); 31 | }); 32 | 33 | beforeEach(async function () { 34 | /* 35 | Because API tests require database to be clean, we clean it up before tests start to run. 36 | You can call 'disableMockCleanup' in order to disable this feature. 37 | */ 38 | if (this.currentTest.parent.title.endsWith('Controller')) 39 | await mockSetup.cleanup(); 40 | }); 41 | 42 | after(TestContext.reset); 43 | -------------------------------------------------------------------------------- /src/testing/test_db_setup.ts: -------------------------------------------------------------------------------- 1 | import { InjectorService } from '@tsed/di'; 2 | 3 | import { generateMockRootUser } from '../../shared/testing/mock/user.mock'; 4 | import { saveUser } from '../misc/utils'; 5 | import { UserProfileDbModel } from '../models/user-profile.db.model'; 6 | import { DatabaseService } from '../services/db.service'; 7 | 8 | export class TestDBSetup { 9 | private dbService: DatabaseService; 10 | static instance: TestDBSetup; 11 | 12 | constructor(private injector: InjectorService) { 13 | this.dbService = injector.get(DatabaseService); 14 | TestDBSetup.instance = this; 15 | } 16 | 17 | /** 18 | * Setup the database with mocks and required data. 19 | */ 20 | async setup(): Promise { 21 | await this.format(); 22 | await this.createUsers(); 23 | } 24 | 25 | /** 26 | * Cleans up the database from any data. 27 | */ 28 | async format(): Promise { 29 | await UserProfileDbModel.deleteMany({}); 30 | } 31 | 32 | /** 33 | * Create mock users required for the api tests to run. 34 | */ 35 | async createUsers(): Promise { 36 | // Create a root user which we can connect later to 37 | const rootUser = generateMockRootUser(); 38 | 39 | await saveUser(rootUser); 40 | } 41 | 42 | /** 43 | * Cleans up the database with any 'unrelated' mock objects, which are not the required 44 | * mocks. This is required in order to perform clean api tests. 45 | */ 46 | // eslint-disable-next-line @typescript-eslint/no-empty-function 47 | async cleanup(): Promise {} 48 | } 49 | 50 | export async function initTestDB( 51 | injector: InjectorService 52 | ): Promise { 53 | const mockSetup = new TestDBSetup(injector); 54 | await mockSetup.setup(); 55 | return mockSetup; 56 | } 57 | -------------------------------------------------------------------------------- /src/testing/test_utils.ts: -------------------------------------------------------------------------------- 1 | import 'chai-http'; 2 | import 'superagent'; 3 | 4 | import * as chai from 'chai'; 5 | import superagent from 'superagent'; 6 | 7 | import { ExpressApplication } from '@tsed/common'; 8 | import { TestContext } from '@tsed/testing'; 9 | 10 | import { IUserProfileDbModel, UserProfileDbModel } from '../models/user-profile.db.model'; 11 | import { Server } from '../server'; 12 | 13 | let expressApp: ExpressApplication; 14 | 15 | /** 16 | * Initializes the supertest and return the request object. 17 | */ 18 | export async function initChaiHttp(): Promise { 19 | const express = await getExpressApp(); 20 | return chai.request(express); 21 | } 22 | 23 | export async function getExpressApp(): Promise { 24 | if (expressApp) return expressApp; 25 | 26 | await TestContext.bootstrap(Server)(); 27 | expressApp = TestContext.injector.get( 28 | ExpressApplication 29 | ) as ExpressApplication; 30 | return expressApp; 31 | } 32 | 33 | export async function getMockRootUserFromDB(): Promise { 34 | return UserProfileDbModel.findOne({ email: 'root@mail.com' }); 35 | } 36 | 37 | export function setAdminHeaders( 38 | request: superagent.SuperAgentRequest 39 | ): superagent.SuperAgentRequest { 40 | return request.auth('admin', { type: 'bearer' }); 41 | } 42 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # If we don't want too setup the database, detect it from ENV 4 | setup_db=${SETUP_DB} 5 | db_setup_time=${DB_SETUP_TIME:-30} 6 | 7 | setupDatabase() { 8 | echo "Setting up database for tests..." 9 | 10 | # Start the test database 11 | docker-compose up -d test-db 12 | 13 | echo "Waiting for database ${db_setup_time} seconds before continuing to tests..." 14 | 15 | # Wait for the datbase to finish loading (30 seconds) 16 | sleep $db_setup_time 17 | 18 | echo "Database setup finished!" 19 | } 20 | 21 | closeDatabase() { 22 | echo "Closing database for tests..." 23 | 24 | # Shutdown the test database 25 | docker-compose down 26 | } 27 | 28 | if [ ${setup_db} ]; then 29 | echo "SETUP_DB was set, preparing database..." 30 | setupDatabase 31 | fi 32 | 33 | # Set the node environment to test 34 | export NODE_ENV=test 35 | 36 | # Run mocha using the provided mocharc file 37 | ./node_modules/.bin/mocha 38 | 39 | if [ ${setup_db} ]; then closeDatabase; fi 40 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2016", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "lib": ["es2016", "DOM", "ESNext.AsyncIterable"], 7 | "baseUrl": "./", 8 | "declaration": true, 9 | "types": ["reflect-metadata", "node", "mocha", "chai"], 10 | "typeRoots": ["./node_modules/@types"], 11 | "outDir": "dist", 12 | "sourceMap": true, 13 | "experimentalDecorators": true, 14 | "emitDecoratorMetadata": true, 15 | "allowSyntheticDefaultImports": true, 16 | "strict": false, 17 | "paths": { 18 | "@shared": ["shared/index.ts"], 19 | "@shared/*": ["shared/*"], 20 | "@models": ["src/models/index.ts"], 21 | "@models/*": ["src/models/*"], 22 | "@config": ["src/config.ts"] 23 | } 24 | }, 25 | "exclude": [ 26 | /* 27 | Because we want to allow intellisense on tests, we don't exclude any test files (*.spec.ts). 28 | Instead we only on build using tsconfig.prod.json in order exclude the test files. 29 | */ 30 | "angular-src", 31 | "src/tests", 32 | "node_modules", 33 | "dist" 34 | ] 35 | } -------------------------------------------------------------------------------- /tsconfig.prod.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "**/*.spec.ts", 5 | "angular-src", 6 | "src/tests", 7 | "node_modules", 8 | "dist" 9 | ] 10 | } -------------------------------------------------------------------------------- /tsconfig.test.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": [ 4 | "angular-src", // <-- We removed the spec files in order to compile them as well 5 | "src/tests", 6 | "node_modules", 7 | "dist" 8 | ] 9 | } --------------------------------------------------------------------------------