├── AureliaNest.code-workspace ├── LICENSE ├── README.md ├── client ├── .editorconfig ├── .gitignore ├── .vscode │ ├── extensions.json │ ├── launch.json │ ├── settings.json │ └── tasks.json ├── README.md ├── assets │ ├── favicon.ico │ ├── loader.css │ ├── loader.min.css │ └── styles.css ├── aurelia_project │ ├── aurelia.json │ ├── generators │ │ ├── attribute.json │ │ ├── attribute.ts │ │ ├── binding-behavior.json │ │ ├── binding-behavior.ts │ │ ├── component.json │ │ ├── component.ts │ │ ├── element.json │ │ ├── element.ts │ │ ├── generator.json │ │ ├── generator.ts │ │ ├── task.json │ │ ├── task.ts │ │ ├── value-converter.json │ │ └── value-converter.ts │ └── tasks │ │ ├── build.json │ │ ├── build.ts │ │ ├── jest.json │ │ ├── jest.ts │ │ ├── run.json │ │ ├── run.ts │ │ ├── test.json │ │ └── test.ts ├── config.js ├── config │ ├── environment.json │ └── environment.production.json ├── custom_typings │ ├── fetch.d.ts │ └── system.d.ts ├── index.ejs ├── package.json ├── src │ ├── app-router-config.ts │ ├── app.html │ ├── app.ts │ ├── auth-interceptor.ts │ ├── auth │ │ ├── auth-config.development.ts │ │ ├── auth-data.service.ts │ │ ├── auth-utility.service.ts │ │ ├── login.html │ │ ├── login.ts │ │ ├── logout.html │ │ ├── logout.ts │ │ ├── profile.html │ │ ├── profile.ts │ │ ├── signup.html │ │ └── signup.ts │ ├── cats │ │ ├── cat.interface.ts │ │ ├── cats-data.service.ts │ │ ├── cats-list.html │ │ └── cats-list.ts │ ├── contacts │ │ ├── contact-detail.html │ │ ├── contact-detail.ts │ │ ├── contact-list.html │ │ ├── contact-list.ts │ │ ├── contact.ts │ │ ├── index.html │ │ ├── index.ts │ │ ├── messages.ts │ │ ├── no-selection.html │ │ ├── no-selection.ts │ │ ├── style.css │ │ ├── utility.ts │ │ └── web-api.ts │ ├── custom-http-client.ts │ ├── environment.ts │ ├── favicon.ico │ ├── main.ts │ ├── nav-bar.html │ ├── nav-bar.ts │ ├── resources │ │ ├── elements │ │ │ ├── abp-modal.html │ │ │ ├── abp-modal.ts │ │ │ ├── bootstrap-tooltip.ts │ │ │ └── loading-indicator.ts │ │ ├── index.ts │ │ └── value-converters │ │ │ ├── admin-filter.ts │ │ │ ├── auth-filter.ts │ │ │ ├── date-format.ts │ │ │ ├── number.ts │ │ │ └── stringify.ts │ ├── shared │ │ ├── bootstrap-form-renderer.ts │ │ ├── globals.ts │ │ └── services │ │ │ ├── auth.service.ts │ │ │ ├── authentication.service.ts │ │ │ ├── authorize-step.ts │ │ │ ├── data.service.ts │ │ │ ├── fetch-config.service.ts │ │ │ ├── popup.service.ts │ │ │ └── storage.service.ts │ ├── styles │ │ ├── bootstrap-social.scss │ │ ├── bootstrap.scss │ │ └── styles.scss │ ├── users │ │ ├── user.interface.ts │ │ ├── users-data.service.ts │ │ ├── users-list.html │ │ ├── users-list.scss │ │ └── users-list.ts │ ├── welcome.html │ └── welcome.ts ├── test │ ├── e2e │ │ ├── demo.e2e.ts │ │ ├── skeleton.po.ts │ │ └── welcome.po.ts │ ├── jest-pretest.ts │ ├── protractor.conf.js │ └── unit │ │ └── app.spec.ts ├── tsconfig.json ├── tslint.json ├── webpack.config.js └── yarn.lock ├── server ├── .editorconfig ├── .env.dev ├── .env.prod ├── .eslintrc ├── .gitignore ├── .prettierrc ├── .vscode │ ├── launch.json │ └── tasks.json ├── README.md ├── nest-cli.json ├── package.json ├── schema.gql ├── src │ ├── app.controller.ts │ ├── app.module.ts │ ├── app.service.ts │ ├── auth │ │ ├── auth-config.development.template.ts │ │ ├── auth.controller.spec.ts │ │ ├── auth.controller.ts │ │ ├── auth.module.ts │ │ ├── dto │ │ │ ├── index.ts │ │ │ ├── token.dto.ts │ │ │ ├── user.dto.ts │ │ │ ├── userLogin.dto.ts │ │ │ ├── userSignup.dto.ts │ │ │ └── username.dto.ts │ │ ├── models │ │ │ ├── index.ts │ │ │ ├── provider.interface.ts │ │ │ └── user.interface.ts │ │ ├── services │ │ │ ├── auth.service.spec.ts │ │ │ ├── auth.service.ts │ │ │ ├── facebook.service.ts │ │ │ ├── user.service.ts │ │ │ └── windowslive.service.ts │ │ └── strategies │ │ │ ├── facebook.strategy.ts │ │ │ ├── github.strategy.ts │ │ │ ├── google.strategy.ts │ │ │ ├── jwt.strategy.ts │ │ │ ├── linkedin.strategy.ts │ │ │ ├── local.strategy.ts │ │ │ ├── microsoft.strategy.ts │ │ │ ├── twitter.strategy.ts │ │ │ └── windowslive.strategy.ts │ ├── cats │ │ ├── cats.module.ts │ │ ├── cats.resolver.ts │ │ ├── graphql │ │ │ ├── enums │ │ │ │ └── catFields.enum.ts │ │ │ ├── inputs │ │ │ │ ├── cat-input.ts │ │ │ │ └── catQueryArgs.input.ts │ │ │ └── types │ │ │ │ ├── cat.type.ts │ │ │ │ └── owner.type.ts │ │ ├── models │ │ │ ├── cat.interface.ts │ │ │ └── index.ts │ │ ├── schemas │ │ │ └── cats.schema.ts │ │ └── services │ │ │ ├── cats.service.ts │ │ │ └── owners.service.ts │ ├── main.hmr.ts │ ├── main.ts │ ├── shared │ │ ├── common.module.ts │ │ ├── decorators │ │ │ ├── currentUser.decorator.ts │ │ │ ├── index.ts │ │ │ └── roles.decorator.ts │ │ ├── filters │ │ │ └── http-exception.filter.ts │ │ ├── graphql │ │ │ ├── enums │ │ │ │ ├── direction.enum.ts │ │ │ │ ├── index.ts │ │ │ │ └── operator.enum.ts │ │ │ ├── inputs │ │ │ │ ├── index.ts │ │ │ │ └── stringQueryArgs.input.ts │ │ │ ├── scalars │ │ │ │ └── date.scalar.ts │ │ │ ├── types │ │ │ │ ├── filterByGeneric.type.ts │ │ │ │ ├── filterByString.type.ts │ │ │ │ ├── index.ts │ │ │ │ ├── orderByGeneric.type.ts │ │ │ │ ├── orderByString.type.ts │ │ │ │ ├── pageInfo.type.ts │ │ │ │ └── paginatedResponse.type.ts │ │ │ └── utils │ │ │ │ └── utilities.ts │ │ ├── guards │ │ │ ├── graphql-passport-auth.guard.ts │ │ │ └── index.ts │ │ ├── interceptors │ │ │ ├── exception.interceptor.ts │ │ │ ├── logging.interceptor.ts │ │ │ ├── timeout.interceptor.ts │ │ │ └── transform.interceptor.ts │ │ ├── middleware │ │ │ └── logger.middleware.ts │ │ ├── models │ │ │ ├── direction.enum.ts │ │ │ ├── filterBy.interface.ts │ │ │ ├── index.ts │ │ │ ├── operator.enum.ts │ │ │ ├── provider.interface.ts │ │ │ └── user.interface.ts │ │ ├── pipes │ │ │ ├── parse-int.pipe.ts │ │ │ └── validation.pipe.ts │ │ └── schemas │ │ │ └── user.schema.ts │ ├── swagger.ts │ └── users │ │ ├── graphql │ │ ├── inputs │ │ │ └── pagination.input.ts │ │ └── types │ │ │ ├── provider.type.ts │ │ │ └── user.type.ts │ │ ├── models │ │ ├── index.ts │ │ ├── provider.interface.ts │ │ └── user.interface.ts │ │ ├── users.module.ts │ │ ├── users.resolver.ts │ │ └── users.service.ts ├── test │ ├── app.e2e-spec.ts │ └── jest-e2e.json ├── tsconfig.build.json ├── tsconfig.json └── yarn.lock └── tsconfig.json /AureliaNest.code-workspace: -------------------------------------------------------------------------------- 1 | { 2 | "folders": [ 3 | { 4 | "path": "server" 5 | }, 6 | { 7 | "path": "client" 8 | } 9 | ], 10 | "settings": { 11 | "typescript.tsdk": "server\\node_modules\\typescript\\lib" 12 | } 13 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-2022 Ghislain B. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | end_of_line = lf 9 | insert_final_newline = true 10 | 11 | # 2 space indentation 12 | [**.*] 13 | indent_style = space 14 | indent_size = 2 15 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # You may want to customise this file depending on your Operating System 2 | # and the editor that you use. 3 | # 4 | # We recommend that you use a Global Gitignore for files that are not related 5 | # to the project. (https://help.github.com/articles/ignoring-files/#create-a-global-gitignore) 6 | 7 | # OS 8 | # 9 | # Ref: https://github.com/github/gitignore/blob/master/Global/macOS.gitignore 10 | # Ref: https://github.com/github/gitignore/blob/master/Global/Windows.gitignore 11 | # Ref: https://github.com/github/gitignore/blob/master/Global/Linux.gitignore 12 | .DS_STORE 13 | Thumbs.db 14 | 15 | # Editors 16 | # 17 | # Ref: https://github.com/github/gitignore/blob/master/Global 18 | # Ref: https://github.com/github/gitignore/blob/master/Global/JetBrains.gitignore 19 | # Ref: https://github.com/github/gitignore/blob/master/Global/VisualStudioCode.gitignore 20 | .idea 21 | .vscode/* 22 | !.vscode/settings.json 23 | !.vscode/tasks.json 24 | !.vscode/launch.json 25 | !.vscode/extensions.json 26 | 27 | # Dependencies 28 | node_modules 29 | 30 | # Compiled files 31 | scripts 32 | 33 | ## NPM / Yarn / Lock / Log / Debugger / Tests 34 | npm-debug.log* 35 | yarn-error.log 36 | /dist 37 | /build 38 | /test/*coverage 39 | /test/coverage* 40 | /.chrome 41 | 42 | ## Remove NPM/Yarn logs and lock files 43 | npm-debug.log 44 | yarn-error.log 45 | -------------------------------------------------------------------------------- /client/.vscode/extensions.json: -------------------------------------------------------------------------------- 1 | { 2 | "recommendations": [ 3 | "AureliaEffect.aurelia", 4 | "msjsdiag.debugger-for-chrome", 5 | "EditorConfig.EditorConfig", 6 | "behzad88.Aurelia" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /client/.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to find out which attributes exist for node debugging 3 | // Use hover for the description of the existing attributes 4 | // For further information visit https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Chrome Debugger", 9 | "type": "chrome", 10 | "request": "launch", 11 | "url": "http://localhost:9000", 12 | "webRoot": "${workspaceRoot}/src", 13 | "userDataDir": "${workspaceRoot}/.chrome", 14 | "sourceMapPathOverrides": { 15 | "webpack:///./src/*": "${webRoot}/*" 16 | } 17 | } 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /client/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | // Place your settings in this file to overwrite default and user settings. 2 | { 3 | "typescript.tsdk": "node_modules/typescript/lib", 4 | "html.suggest.angular1": false, 5 | "html.suggest.ionic": false 6 | } 7 | -------------------------------------------------------------------------------- /client/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "Aurelia (client)", 6 | "command": "yarn", 7 | "type": "shell", 8 | "args": [ 9 | "start" 10 | ], 11 | "problemMatcher": [] 12 | } 13 | ] 14 | } 15 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | ## Client TypeScript Webpack 2 | 3 | ### Client installation 4 | Start by cloning the repo and then install it (with `npm` or [yarn](https://yarnpkg.com/)) 5 | ```bash 6 | cd aurelia-nest-auth-mongodb/client 7 | npm install # or: yarn install 8 | ``` 9 | 10 | ### VScode Workspaces 11 | If you use VSCode (Visual Studio Code) as your main editor, you can load the VSCode workspace. Once the workspace is loaded, you will then have access to multiple tasks (defined in `client/tasks.json`) which makes it easy to execute the code without even typing any command in the shell (you still have to make sure to `npm install` in both `client` and `server` folders). 12 | 13 | ### Running the App 14 | The simplest way of running the App is to use the VSCode tasks there were created **Aurelia (client)** and **NestJS Dev (server)** (or _NestJS Debug (server)_ if you wish to debug your code with NestJS) 15 | 16 | The second way would be to type the shell command `yarn start` in both `client` and `server` folders. 17 | ```bash 18 | npm start # or: yarn start 19 | ``` 20 | 21 | ### Web UI 22 | If everything goes well, your application should now run locally on port `9000`. So, in your browser just go to the URL [http://localhost:9000](http://localhost:9000). 23 | 24 | ## License 25 | MIT 26 | 27 | ## Getting started 28 | 29 | Before you start, make sure you have a recent version of [NodeJS](http://nodejs.org/) environment *>=10.0* with NPM 6 or Yarn. 30 | 31 | From the project folder, execute the following commands: 32 | 33 | ```shell 34 | npm install # or: yarn install 35 | ``` 36 | 37 | This will install all required dependencies, including a local version of Webpack that is going to 38 | build and bundle the app. There is no need to install Webpack globally. 39 | 40 | To run the app execute the following command: 41 | 42 | ```shell 43 | npm start # or: yarn start 44 | ``` 45 | -------------------------------------------------------------------------------- /client/assets/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ghiscoding/aurelia-nest-auth-mongodb/56ec8b9c178c5b7bbb60dd361ef3f421c7eb5449/client/assets/favicon.ico -------------------------------------------------------------------------------- /client/assets/loader.min.css: -------------------------------------------------------------------------------- 1 | .splash{text-align:center;margin:-10% 0 0 0;box-sizing:border-box}.splash .message{color:#3275b3;font-size:52px;line-height:52px;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.splash .fa-spinner{color:#213679;text-align:center;display:inline-block;font-size:52px;margin-top:50px}.splash{position:absolute;display:flex;height:100vh;width:100vw;align-items:center;justify-content:center;flex-direction:column}.splash .spinner{text-align:center}.splash .spinner .fa-spinner{display:inline-block;font-size:48px;color:#3275b3} 2 | .loader{font-size:10px;margin:30px auto;width:1em;height:1em;border-radius:50%;position:relative;text-indent:-9999em;-webkit-animation:load4 1s infinite linear;animation:load4 1s infinite linear;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0)}@-webkit-keyframes load4{0%,100%{box-shadow:0 -3em 0 .2em #213679,2em -2em 0 0 #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 0 #213679} 3 | 12.5%{box-shadow:0 -3em 0 0 #213679,2em -2em 0 .2em #213679,3em 0 0 0 #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679}25%{box-shadow:0 -3em 0 -0.5em #213679,2em -2em 0 0 #213679,3em 0 0 .2em #213679,2em 2em 0 0 #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679}37.5%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 0 #213679,2em 2em 0 .2em #213679,0 3em 0 0 #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679} 4 | 50%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 0 #213679,0 3em 0 .2em #213679,-2em 2em 0 0 #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679}62.5%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 0 #213679,-2em 2em 0 .2em #213679,-3em 0 0 0 #213679,-2em -2em 0 -1em #213679}75%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 0 #213679,-3em 0 0 .2em #213679,-2em -2em 0 0 #213679} 5 | 87.5%{box-shadow:0 -3em 0 0 #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 0 #213679,-3em 0 0 0 #213679,-2em -2em 0 .2em #213679}}@keyframes load4{0%,100%{box-shadow:0 -3em 0 .2em #213679,2em -2em 0 0 #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 0 #213679}12.5%{box-shadow:0 -3em 0 0 #213679,2em -2em 0 .2em #213679,3em 0 0 0 #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679} 6 | 25%{box-shadow:0 -3em 0 -0.5em #213679,2em -2em 0 0 #213679,3em 0 0 .2em #213679,2em 2em 0 0 #213679,0 3em 0 -1em #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679}37.5%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 0 #213679,2em 2em 0 .2em #213679,0 3em 0 0 #213679,-2em 2em 0 -1em #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679}50%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 0 #213679,0 3em 0 .2em #213679,-2em 2em 0 0 #213679,-3em 0 0 -1em #213679,-2em -2em 0 -1em #213679} 7 | 62.5%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 0 #213679,-2em 2em 0 .2em #213679,-3em 0 0 0 #213679,-2em -2em 0 -1em #213679}75%{box-shadow:0 -3em 0 -1em #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 0 #213679,-3em 0 0 .2em #213679,-2em -2em 0 0 #213679}87.5%{box-shadow:0 -3em 0 0 #213679,2em -2em 0 -1em #213679,3em 0 0 -1em #213679,2em 2em 0 -1em #213679,0 3em 0 -1em #213679,-2em 2em 0 0 #213679,-3em 0 0 0 #213679,-2em -2em 0 .2em #213679} 8 | } -------------------------------------------------------------------------------- /client/assets/styles.css: -------------------------------------------------------------------------------- 1 | .splash { 2 | text-align: center; 3 | margin: 10% 0 0 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | .splash .message { 8 | font-size: 72px; 9 | line-height: 72px; 10 | text-shadow: rgba(0, 0, 0, 0.5) 0 0 15px; 11 | text-transform: uppercase; 12 | font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; 13 | } 14 | 15 | .splash .fa-spinner { 16 | text-align: center; 17 | display: inline-block; 18 | font-size: 72px; 19 | margin-top: 50px; 20 | } 21 | 22 | .page-host { 23 | position: absolute; 24 | left: 0; 25 | right: 0; 26 | top: 56px; 27 | bottom: 0; 28 | overflow-x: hidden; 29 | overflow-y: auto; 30 | } 31 | 32 | section { 33 | margin: 0 20px; 34 | } 35 | 36 | .navbar-nav li.loader { 37 | margin: 12px 24px 0 6px; 38 | } 39 | 40 | .pictureDetail { 41 | max-width: 425px; 42 | } 43 | 44 | /* animate page transitions */ 45 | section.au-enter-active { 46 | animation: fadeInRight 1s; 47 | } 48 | 49 | div.au-stagger { 50 | /* 50ms will be applied between each successive enter operation */ 51 | animation-delay: 50ms; 52 | } 53 | 54 | .card-container.au-enter { 55 | opacity: 0 !important; 56 | } 57 | 58 | .card-container.au-enter-active { 59 | animation: fadeIn 2s; 60 | } 61 | 62 | .card { 63 | overflow: hidden; 64 | position: relative; 65 | border: 1px solid #CCC; 66 | border-radius: 8px; 67 | text-align: center; 68 | padding: 0; 69 | background-color: #337ab7; 70 | color: rgb(136, 172, 217); 71 | margin-bottom: 32px; 72 | box-shadow: 0 0 5px rgba(0, 0, 0, .5); 73 | } 74 | 75 | .card .content { 76 | margin-top: 10px; 77 | } 78 | 79 | .card .content .name { 80 | color: white; 81 | text-shadow: 0 0 6px rgba(0, 0, 0, .5); 82 | font-size: 18px; 83 | } 84 | 85 | .card .header-bg { 86 | /* This stretches the canvas across the entire hero unit */ 87 | position: absolute; 88 | top: 0; 89 | left: 0; 90 | width: 100%; 91 | height: 70px; 92 | border-bottom: 1px #FFF solid; 93 | border-radius: 6px 6px 0 0; 94 | } 95 | 96 | .card .avatar { 97 | position: relative; 98 | margin-top: 15px; 99 | z-index: 100; 100 | } 101 | 102 | .card .avatar img { 103 | width: 100px; 104 | height: 100px; 105 | border-radius: 50%; 106 | border: 2px #FFF solid; 107 | } 108 | 109 | /* animation definitions */ 110 | @-webkit-keyframes fadeInRight { 111 | 0% { 112 | opacity: 0; 113 | transform: translate3d(100%, 0, 0) 114 | } 115 | 100% { 116 | opacity: 1; 117 | transform: none 118 | } 119 | } 120 | 121 | @keyframes fadeInRight { 122 | 0% { 123 | opacity: 0; 124 | transform: translate3d(100%, 0, 0) 125 | } 126 | 100% { 127 | opacity: 1; 128 | transform: none 129 | } 130 | } 131 | 132 | @-webkit-keyframes fadeIn { 133 | 0% { 134 | opacity: 0; 135 | } 136 | 100% { 137 | opacity: 1; 138 | } 139 | } 140 | 141 | @keyframes fadeIn { 142 | 0% { 143 | opacity: 0; 144 | } 145 | 100% { 146 | opacity: 1; 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /client/aurelia_project/aurelia.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "aurelia-nest-mongo-graphql", 3 | "type": "project:application", 4 | "paths": { 5 | "root": "src", 6 | "resources": "resources", 7 | "elements": "resources/elements", 8 | "attributes": "resources/attributes", 9 | "valueConverters": "resources/value-converters", 10 | "bindingBehaviors": "resources/binding-behaviors" 11 | }, 12 | "transpiler": { 13 | "id": "typescript", 14 | "fileExtension": ".ts" 15 | }, 16 | "build": { 17 | "options": { 18 | "server": "dev", 19 | "extractCss": "prod", 20 | "coverage": false 21 | } 22 | }, 23 | "platform": { 24 | "compress": true, 25 | "liveReload": true, 26 | "historyApiFallback": true, 27 | "hmr": false, 28 | "open": true, 29 | "port": 9000, 30 | "host": "localhost", 31 | "output": "dist", 32 | "outputProd": "dist" 33 | }, 34 | "packageManager": "yarn" 35 | } 36 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/attribute.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "attribute", 3 | "description": "Creates a custom attribute class and places it in the project resources." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/attribute.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class AttributeGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the custom attribute?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let className = this.project.makeClassName(name); 16 | 17 | this.project.attributes.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateSource(className)) 19 | ); 20 | 21 | await this.project.commitChanges(); 22 | await this.ui.log(`Created ${fileName}.`); 23 | } 24 | 25 | generateSource(className) { 26 | return `import {autoinject} from 'aurelia-framework'; 27 | 28 | @autoinject() 29 | export class ${className}CustomAttribute { 30 | constructor(private element: Element) { } 31 | 32 | valueChanged(newValue, oldValue) { 33 | // 34 | } 35 | } 36 | `; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/binding-behavior.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "binding-behavior", 3 | "description": "Creates a binding behavior class and places it in the project resources." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/binding-behavior.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class BindingBehaviorGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the binding behavior?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let className = this.project.makeClassName(name); 16 | 17 | this.project.bindingBehaviors.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateSource(className)) 19 | ); 20 | 21 | await this.project.commitChanges(); 22 | await this.ui.log(`Created ${fileName}.`); 23 | } 24 | 25 | generateSource(className) { 26 | return `export class ${className}BindingBehavior { 27 | bind(binding, source) { 28 | // 29 | } 30 | 31 | unbind(binding, source) { 32 | // 33 | } 34 | } 35 | ` 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/component.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "component", 3 | "description": "Creates a custom component class and template (view model and view), placing them in the project source folder (or optionally in sub folders)." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/component.ts: -------------------------------------------------------------------------------- 1 | import { inject } from 'aurelia-dependency-injection'; 2 | import { Project, ProjectItem, CLIOptions, UI } from 'aurelia-cli'; 3 | 4 | var path = require('path'); 5 | 6 | @inject(Project, CLIOptions, UI) 7 | export default class ElementGenerator { 8 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 9 | 10 | async execute() { 11 | const name = await this.ui.ensureAnswer( 12 | this.options.args[0], 13 | 'What would you like to call the component?' 14 | ); 15 | 16 | const subFolders = await this.ui.ensureAnswer( 17 | this.options.args[1], 18 | 'What sub-folder would you like to add it to?\nIf it doesn\'t exist it will be created for you.\n\nDefault folder is the source folder (src).', "." 19 | ); 20 | 21 | let fileName = this.project.makeFileName(name); 22 | let className = this.project.makeClassName(name); 23 | 24 | this.project.root.add( 25 | ProjectItem.text(path.join(subFolders, fileName + '.ts'), this.generateJSSource(className)), 26 | ProjectItem.text(path.join(subFolders, fileName + '.html'), this.generateHTMLSource(className)) 27 | ); 28 | 29 | await this.project.commitChanges(); 30 | await this.ui.log(`Created ${name} in the '${path.join(this.project.root.name, subFolders)}' folder`); 31 | } 32 | 33 | generateJSSource(className) { 34 | return `export class ${className} { 35 | message: string; 36 | 37 | constructor() { 38 | this.message = 'Hello world'; 39 | } 40 | } 41 | ` 42 | } 43 | 44 | generateHTMLSource(className) { 45 | return ` 48 | ` 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/element.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "element", 3 | "description": "Creates a custom element class and template, placing them in the project resources." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/element.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class ElementGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the custom element?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let className = this.project.makeClassName(name); 16 | 17 | this.project.elements.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateJSSource(className)), 19 | ProjectItem.text(`${fileName}.html`, this.generateHTMLSource(className)) 20 | ); 21 | 22 | await this.project.commitChanges(); 23 | await this.ui.log(`Created ${fileName}.`); 24 | } 25 | 26 | generateJSSource(className) { 27 | return `import {bindable} from 'aurelia-framework'; 28 | 29 | export class ${className} { 30 | @bindable value; 31 | 32 | valueChanged(newValue, oldValue) { 33 | // 34 | } 35 | } 36 | `; 37 | } 38 | 39 | generateHTMLSource(className) { 40 | return ` 43 | `; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/generator.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "generator", 3 | "description": "Creates a generator class and places it in the project generators folder." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/generator.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class GeneratorGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the generator?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let className = this.project.makeClassName(name); 16 | 17 | this.project.generators.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateSource(className)) 19 | ); 20 | 21 | await this.project.commitChanges() 22 | await this.ui.log(`Created ${fileName}.`); 23 | } 24 | 25 | generateSource(className) { 26 | return `import {inject} from 'aurelia-dependency-injection'; 27 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 28 | 29 | @inject(Project, CLIOptions, UI) 30 | export default class ${className}Generator { 31 | constructor(project, options, ui) { 32 | this.project = project; 33 | this.options = options; 34 | this.ui = ui; 35 | } 36 | 37 | execute() { 38 | return this.ui 39 | .ensureAnswer(this.options.args[0], 'What would you like to call the new item?') 40 | .then(name => { 41 | let fileName = this.project.makeFileName(name); 42 | let className = this.project.makeClassName(name); 43 | 44 | this.project.elements.add( 45 | ProjectItem.text(\`\${fileName}.ts\`, this.generateSource(className)) 46 | ); 47 | 48 | return this.project.commitChanges() 49 | .then(() => this.ui.log(\`Created \${fileName}.\`)); 50 | }); 51 | } 52 | 53 | generateSource(className) { 54 | return \`import {bindable} from 'aurelia-framework'; 55 | 56 | export class \${className} { 57 | @bindable value; 58 | 59 | valueChanged(newValue, oldValue) { 60 | // 61 | } 62 | } 63 | \` 64 | } 65 | } 66 | `; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/task.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "task", 3 | "description": "Creates a task and places it in the project tasks folder." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/task.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class TaskGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the task?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let functionName = this.project.makeFunctionName(name); 16 | 17 | this.project.tasks.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateSource(functionName)) 19 | ); 20 | 21 | await this.project.commitChanges(); 22 | await this.ui.log(`Created ${fileName}.`); 23 | } 24 | 25 | generateSource(functionName) { 26 | return `import * as gulp from 'gulp'; 27 | import * as project from '../aurelia.json'; 28 | 29 | export default function ${functionName}() { 30 | return gulp.src(project.paths.???) 31 | .pipe(gulp.dest(project.paths.output)); 32 | } 33 | `; 34 | 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/aurelia_project/generators/value-converter.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "value-converter", 3 | "description": "Creates a value converter class and places it in the project resources." 4 | } -------------------------------------------------------------------------------- /client/aurelia_project/generators/value-converter.ts: -------------------------------------------------------------------------------- 1 | import {inject} from 'aurelia-dependency-injection'; 2 | import {Project, ProjectItem, CLIOptions, UI} from 'aurelia-cli'; 3 | 4 | @inject(Project, CLIOptions, UI) 5 | export default class ValueConverterGenerator { 6 | constructor(private project: Project, private options: CLIOptions, private ui: UI) { } 7 | 8 | async execute() { 9 | const name = await this.ui.ensureAnswer( 10 | this.options.args[0], 11 | 'What would you like to call the value converter?' 12 | ); 13 | 14 | let fileName = this.project.makeFileName(name); 15 | let className = this.project.makeClassName(name); 16 | 17 | this.project.valueConverters.add( 18 | ProjectItem.text(`${fileName}.ts`, this.generateSource(className)) 19 | ); 20 | 21 | await this.project.commitChanges(); 22 | await this.ui.log(`Created ${fileName}.`); 23 | } 24 | 25 | generateSource(className) { 26 | return `export class ${className}ValueConverter { 27 | toView(value) { 28 | // 29 | } 30 | 31 | fromView(value) { 32 | // 33 | } 34 | } 35 | `; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /client/aurelia_project/tasks/build.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "build", 3 | "description": "Builds and processes all application assets. It is an alias of the `npm run build:dev`, you may use either of those; see README for more details.", 4 | "flags": [ 5 | { 6 | "name": "analyze", 7 | "description": "Enable Webpack Bundle Analyzer. Typically paired with --env prod", 8 | "type": "boolean" 9 | }, 10 | { 11 | "name": "env", 12 | "description": "Sets the build environment.", 13 | "type": "string" 14 | }, 15 | { 16 | "name": "watch", 17 | "description": "Watches source files for changes and refreshes the bundles automatically.", 18 | "type": "boolean" 19 | } 20 | ] 21 | } -------------------------------------------------------------------------------- /client/aurelia_project/tasks/build.ts: -------------------------------------------------------------------------------- 1 | import { NPM } from 'aurelia-cli'; 2 | 3 | export default function() { 4 | console.log('`au build` is an alias of the `npm run build:dev`, you may use either of those; see README for more details.'); 5 | const args = process.argv.slice(3); 6 | return (new NPM()).run('run', ['build:dev', '--', ... cleanArgs(args)]); 7 | } 8 | 9 | // Cleanup --env prod to --env.production 10 | // for backwards compatibility 11 | function cleanArgs(args) { 12 | const cleaned = []; 13 | for (let i = 0, ii = args.length; i < ii; i++) { 14 | if (args[i] === '--env' && i < ii - 1) { 15 | const env = args[++i].toLowerCase(); 16 | if (env.startsWith('prod')) { 17 | cleaned.push('--env.production'); 18 | } else if (env.startsWith('test')) { 19 | cleaned.push('--tests'); 20 | } 21 | } else { 22 | cleaned.push(args[i]); 23 | } 24 | } 25 | return cleaned; 26 | } 27 | -------------------------------------------------------------------------------- /client/aurelia_project/tasks/jest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "jest", 3 | "description": "Runs Jest and reports the results.", 4 | "flags": [ 5 | { 6 | "name": "watch", 7 | "description": "Watches test files for changes and re-runs the tests automatically.", 8 | "type": "boolean" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /client/aurelia_project/tasks/jest.ts: -------------------------------------------------------------------------------- 1 | export {default} from './test'; 2 | -------------------------------------------------------------------------------- /client/aurelia_project/tasks/run.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "run", 3 | "description": "Builds the application and serves up the assets via a local web server, watching files for changes as you work. It is an alias of the `npm start`, you may use either of those; see README for more details.", 4 | "flags": [ 5 | { 6 | "name": "analyze", 7 | "description": "Enable Webpack Bundle Analyzer. Typically paired with --env prod", 8 | "type": "boolean" 9 | }, 10 | { 11 | "name": "env", 12 | "description": "Sets the build environment.", 13 | "type": "string" 14 | }, 15 | { 16 | "name": "hmr", 17 | "description": "Enable Hot Module Reload", 18 | "type": "boolean" 19 | }, 20 | { 21 | "name": "port", 22 | "description": "Set port number of the dev server", 23 | "type": "string" 24 | }, 25 | { 26 | "name": "host", 27 | "description": "Set host address of the dev server, the accessible URL", 28 | "type": "string" 29 | }, 30 | { 31 | "name": "open", 32 | "description": "Open the default browser at the application location.", 33 | "type": "boolean" 34 | } 35 | ] 36 | } -------------------------------------------------------------------------------- /client/aurelia_project/tasks/run.ts: -------------------------------------------------------------------------------- 1 | import { NPM } from 'aurelia-cli'; 2 | import * as kill from 'tree-kill'; 3 | 4 | const npm = new NPM(); 5 | 6 | function run() { 7 | console.log('`au run` is an alias of the `npm start`, you may use either of those; see README for more details.'); 8 | const args = process.argv.slice(3); 9 | return npm.run('start', ['--', ... cleanArgs(args)]); 10 | } 11 | 12 | // Cleanup --env prod to --env.production 13 | // for backwards compatibility 14 | function cleanArgs(args) { 15 | const cleaned = []; 16 | for (let i = 0, ii = args.length; i < ii; i++) { 17 | if (args[i] === '--env' && i < ii - 1) { 18 | const env = args[++i].toLowerCase(); 19 | if (env.startsWith('prod')) { 20 | cleaned.push('--env.production'); 21 | } else if (env.startsWith('test')) { 22 | cleaned.push('--tests'); 23 | } 24 | } else { 25 | cleaned.push(args[i]); 26 | } 27 | } 28 | return cleaned; 29 | } 30 | 31 | const shutdownAppServer = () => { 32 | if (npm && npm.proc) { 33 | kill(npm.proc.pid); 34 | } 35 | }; 36 | 37 | export { run as default, shutdownAppServer }; 38 | -------------------------------------------------------------------------------- /client/aurelia_project/tasks/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test", 3 | "description": "Runs Jest and reports the results.", 4 | "flags": [ 5 | { 6 | "name": "watch", 7 | "description": "Watches test files for changes and re-runs the tests automatically.", 8 | "type": "boolean" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /client/aurelia_project/tasks/test.ts: -------------------------------------------------------------------------------- 1 | import * as jest from 'jest-cli'; 2 | import * as path from 'path'; 3 | import * as packageJson from '../../package.json'; 4 | 5 | import { CLIOptions } from 'aurelia-cli'; 6 | 7 | export default (cb) => { 8 | let options = packageJson.jest; 9 | 10 | if (CLIOptions.hasFlag('watch')) { 11 | Object.assign(options, { watchAll: true}); 12 | } 13 | 14 | 15 | jest.runCLI(options, [path.resolve(__dirname, '../../')]).then(({ results }) => { 16 | if (results.numFailedTests || results.numFailedTestSuites) { 17 | cb('Tests Failed'); 18 | } else { 19 | cb(); 20 | } 21 | }); 22 | }; 23 | -------------------------------------------------------------------------------- /client/config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | baseUrl: 'http://localhost:9000/', 3 | webUiPort: 9000, 4 | webApiPort: 3000 5 | } 6 | -------------------------------------------------------------------------------- /client/config/environment.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": true, 3 | "testing": true 4 | } -------------------------------------------------------------------------------- /client/config/environment.production.json: -------------------------------------------------------------------------------- 1 | { 2 | "debug": false, 3 | "testing": false 4 | } -------------------------------------------------------------------------------- /client/custom_typings/fetch.d.ts: -------------------------------------------------------------------------------- 1 | declare module "isomorphic-fetch" { 2 | export = fetch; 3 | } 4 | -------------------------------------------------------------------------------- /client/custom_typings/system.d.ts: -------------------------------------------------------------------------------- 1 | declare module 'system' { 2 | import fetch = require('isomorphic-fetch'); 3 | import * as Aurelia from 'aurelia-framework'; 4 | 5 | /* 6 | * List your dynamically imported modules to get typing support 7 | */ 8 | interface System { 9 | import(name: string): Promise; 10 | import(name: 'aurelia-framework'): Promise; 11 | import(name: 'isomorphic-fetch'): Promise; 12 | } 13 | 14 | global { 15 | var System: System; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/index.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | <%- htmlWebpackPlugin.options.metadata.title %> 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 |
18 |
19 | <%- htmlWebpackPlugin.options.metadata.title %> 20 |
21 | 22 |
23 | <% if (htmlWebpackPlugin.options.metadata.server) { %> 24 | 25 | 26 | <% } %> 27 | 28 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /client/src/app-router-config.ts: -------------------------------------------------------------------------------- 1 | import { autoinject, PLATFORM } from 'aurelia-framework'; 2 | import { Router, RouterConfiguration } from 'aurelia-router'; 3 | import { AuthorizeStep } from 'shared/services/authorize-step'; 4 | 5 | @autoinject() 6 | export default class { 7 | 8 | constructor(private router: Router) { } 9 | 10 | configure() { 11 | let appRouterConfig: any = function (config: RouterConfiguration): void { 12 | config.title = 'Aurelia'; 13 | config.options.pushState = true; 14 | config.addPipelineStep('authorize', AuthorizeStep); // Add a route filter to the authorize extensibility point. 15 | 16 | config.map([ 17 | { route: ['', 'welcome'], name: 'welcome', moduleId: PLATFORM.moduleName('./welcome'), nav: true, title: 'Welcome' }, 18 | { route: 'login/success/:token', name: 'loginSuccess', moduleId: PLATFORM.moduleName('./auth/login'), nav: false, title: 'Login' }, 19 | { route: 'login', name: 'login', moduleId: PLATFORM.moduleName('./auth/login'), nav: false, title: 'Login' }, 20 | { route: 'logout', name: 'logout', moduleId: PLATFORM.moduleName('./auth/logout'), nav: false, title: 'Logout' }, 21 | { route: 'profile', name: 'profile', moduleId: PLATFORM.moduleName('./auth/profile'), nav: false, title: 'Profile' }, 22 | { route: 'signup', name: 'signup', moduleId: PLATFORM.moduleName('./auth/signup'), nav: false, title: 'Signup' }, 23 | { route: 'contacts', name: 'contacts', moduleId: PLATFORM.moduleName('./contacts/index'), nav: true, title: 'Contacts', auth: true }, 24 | { route: 'cats', name: 'cats', moduleId: PLATFORM.moduleName('./cats/cats-list'), nav: true, title: 'Cats', auth: true }, 25 | { route: 'users', name: 'users', moduleId: PLATFORM.moduleName('./users/users-list'), nav: true, title: 'Users', auth: true, admin: true }, 26 | ]); 27 | }; 28 | 29 | this.router.configure(appRouterConfig); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/app.html: -------------------------------------------------------------------------------- 1 | 10 | -------------------------------------------------------------------------------- /client/src/app.ts: -------------------------------------------------------------------------------- 1 | import { EventAggregator } from 'aurelia-event-aggregator'; 2 | import { HttpClient } from 'aurelia-fetch-client'; 3 | import { autoinject } from 'aurelia-framework'; 4 | import { Router } from 'aurelia-router'; 5 | 6 | import { AuthInterceptor } from 'auth-interceptor'; 7 | import AppRouterConfig from './app-router-config'; 8 | import { FetchConfigService } from 'shared/services/fetch-config.service'; 9 | 10 | @autoinject() 11 | export class App { 12 | httpProcessing = false; 13 | router: Router; 14 | 15 | constructor( 16 | private appRouterConfig: AppRouterConfig, 17 | private authInterceptor: AuthInterceptor, 18 | private ea: EventAggregator, 19 | private http: HttpClient, 20 | private fetchConfig: FetchConfigService, 21 | router: Router, // required by configure sub-services 22 | ) { 23 | this.ea.subscribe('http:started', () => this.httpProcessing = true); 24 | this.ea.subscribe('http:stopped', () => this.httpProcessing = false); 25 | this.http.configure(config => config.withInterceptor(this.authInterceptor)); 26 | this.router = router; 27 | } 28 | 29 | activate() { 30 | this.appRouterConfig.configure(); 31 | this.fetchConfig.configure(); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /client/src/auth-interceptor.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { Interceptor } from 'aurelia-fetch-client'; 3 | import { Router } from 'aurelia-router'; 4 | import authConfig from './auth/auth-config.development'; 5 | 6 | @autoinject() 7 | export class AuthInterceptor implements Interceptor { 8 | constructor(private router: Router) { } 9 | 10 | request(message: Request) { 11 | let token = window.localStorage.getItem(authConfig.tokenName) || null; 12 | message?.headers?.append('Authorization', `Bearer ${token}`); 13 | return message; 14 | } 15 | 16 | requestError(error): Request | Response | Promise { 17 | throw error; 18 | } 19 | 20 | response(message: Response) { 21 | if (message.status === 401) { 22 | this.router.navigateToRoute(authConfig.loginRoute); 23 | } 24 | return message; 25 | } 26 | 27 | responseError(error): Response | Promise { 28 | throw error; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /client/src/auth/auth-config.development.ts: -------------------------------------------------------------------------------- 1 | export interface AuthConfig { 2 | baseUrl: string; 3 | loginRedirect: string; 4 | loginRoute: string; 5 | platform?: 'desktop' | 'mobile'; 6 | profileEndpoint: string; 7 | providers: { 8 | [provider: string]: { 9 | authorizationEndpoint: string; 10 | popupSize?: { 11 | height: number; 12 | width: number; 13 | } 14 | }; 15 | } 16 | storage: 'cookie' | 'localStorage' | 'sessionStorage'; 17 | tokenName: string; 18 | } 19 | 20 | let configForDevelopment = { 21 | baseUrl: 'http://localhost:3000/auth', 22 | loginRedirect: 'profile', 23 | loginRoute: 'login', 24 | platform: 'desktop', 25 | storage: 'localStorage', 26 | tokenName: 'token', 27 | profileEndpoint: 'http://localhost:3000/auth/me', 28 | providers: { 29 | facebook: { 30 | authorizationEndpoint: 'http://localhost:3000/auth/facebook', 31 | popupSize: { width: 580, height: 400 } 32 | }, 33 | github: { 34 | authorizationEndpoint: 'http://localhost:3000/auth/github', 35 | popupSize: { width: 1020, height: 618 } 36 | }, 37 | google: { 38 | authorizationEndpoint: 'http://localhost:3000/auth/google', 39 | popupSize: { width: 452, height: 633 } 40 | }, 41 | linkedin: { 42 | authorizationEndpoint: 'http://localhost:3000/auth/linkedin', 43 | popupSize: { width: 527, height: 582 } 44 | }, 45 | twitter: { 46 | authorizationEndpoint: 'http://localhost:3000/auth/twitter', 47 | popupSize: { width: 495, height: 645 } 48 | }, 49 | microsoft: { 50 | authorizationEndpoint: 'http://localhost:3000/auth/microsoft', 51 | popupSize: { width: 500, height: 560 } 52 | }, 53 | windowslive: { 54 | authorizationEndpoint: 'http://localhost:3000/auth/windowslive', 55 | popupSize: { width: 500, height: 560 } 56 | }, 57 | } 58 | } as AuthConfig; 59 | 60 | let configForProduction = { 61 | loginRedirect: 'profile', 62 | providers: { 63 | facebook: { 64 | authorizationEndpoint: 'http://example.com/auth/facebook', 65 | }, 66 | } 67 | } as Partial; 68 | 69 | let config: AuthConfig; 70 | if (window.location.hostname === 'localhost') { 71 | config = configForDevelopment; 72 | } 73 | else { 74 | config = { ...configForProduction, ...configForDevelopment }; 75 | } 76 | 77 | export default config; 78 | -------------------------------------------------------------------------------- /client/src/auth/auth-data.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { HttpClient, json } from 'aurelia-fetch-client'; 3 | import { Globals } from '../shared/globals'; 4 | 5 | @autoinject() 6 | export class AuthDataService { 7 | constructor(protected http: HttpClient) { 8 | this.http = http; 9 | } 10 | 11 | login(username: string, password: string) { 12 | return this.http.fetch(`${Globals.baseUrl}/auth/login`, { 13 | method: 'post', 14 | body: json({ username, password }) 15 | }).then(response => response.json()); 16 | } 17 | 18 | signup(email: string, password: string, displayName: string) { 19 | return this.http.fetch(`${Globals.baseUrl}/auth/signup`, { 20 | method: 'post', 21 | body: json({ email, password, displayName }) 22 | }).then(response => response.json()); 23 | } 24 | 25 | usernameAvailable(email: string): Promise { 26 | return this.http.fetch(`${Globals.baseUrl}/auth/username-available`, { 27 | method: 'post', 28 | body: json({ email }) 29 | }).then(response => response.json()); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/auth/auth-utility.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { Router } from 'aurelia-router'; 3 | 4 | import authConfig from './auth-config.development'; 5 | 6 | @autoinject() 7 | export class AuthUtilityService { 8 | constructor(private router: Router) { 9 | } 10 | 11 | saveTokenAndRedirect(token: string) { 12 | window.localStorage.setItem(authConfig.tokenName, token); 13 | this.router.navigateToRoute(authConfig.loginRedirect); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/auth/login.html: -------------------------------------------------------------------------------- 1 | 79 | -------------------------------------------------------------------------------- /client/src/auth/login.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { Router } from 'aurelia-router'; 3 | import { ValidationController, ValidationControllerFactory, ValidationRules } from 'aurelia-validation'; 4 | import Swal from 'sweetalert2'; 5 | 6 | import authConfig from './auth-config.development'; 7 | import { PopupService } from 'shared/services/popup.service'; 8 | import { AuthDataService } from './auth-data.service'; 9 | import { AuthUtilityService } from './auth-utility.service'; 10 | import { BootstrapFormRenderer } from 'shared/bootstrap-form-renderer'; 11 | 12 | @autoinject() 13 | export class Login { 14 | heading: string = 'Login'; 15 | email: string = ''; 16 | password: string = ''; 17 | controller: ValidationController = null; 18 | 19 | constructor( 20 | private authUtilityService: AuthUtilityService, 21 | private authDataService: AuthDataService, 22 | private controllerFactory: ValidationControllerFactory, 23 | private popupService: PopupService, 24 | private router: Router, 25 | ) { 26 | this.controller = this.controllerFactory.createForCurrentScope(); 27 | this.controller.addRenderer(new BootstrapFormRenderer()); 28 | this.addFormValidations(); 29 | } 30 | 31 | activate(params: any) { 32 | console.log('params', params) 33 | if (params.token || params.code) { 34 | this.authUtilityService.saveTokenAndRedirect(params.token || params.code); 35 | } 36 | } 37 | 38 | async login(): Promise { 39 | const result = await this.controller.validate(); 40 | if (result.valid) { 41 | console.log('login', this.email, this.password) 42 | try { 43 | const resp = await this.authDataService.login(this.email.toLowerCase(), this.password); 44 | if (resp.statusCode === 401) { 45 | Swal.fire('Login', 'Invalid username and/or password', 'error'); 46 | } 47 | console.log('resp', resp) 48 | this.activate(resp); 49 | } catch (e) { 50 | Swal.fire('Oops...', e.message, 'error'); 51 | } 52 | } 53 | } 54 | 55 | async authOpen(provider) { 56 | try { 57 | const providerConfig = authConfig.providers[provider]; 58 | const endpointUrl = providerConfig.authorizationEndpoint; 59 | const target = authConfig.platform === 'mobile' ? '_self' : provider; // you can perhaps look at the screen size to know it's a mobile? 60 | const popupSizes = { 61 | height: providerConfig.popupSize.height || 800, 62 | width: providerConfig.popupSize.width || 800, 63 | } 64 | 65 | this.popupService.open(endpointUrl, target, popupSizes); 66 | 67 | // Desktop 68 | const token = await this.popupService.pollPopup(); 69 | this.authUtilityService.saveTokenAndRedirect(token); 70 | } catch (error) { 71 | console.log(error); 72 | } 73 | } 74 | 75 | addFormValidations() { 76 | ValidationRules 77 | .ensure('email').required().email() 78 | .ensure('password').required() 79 | .on(this); 80 | } 81 | 82 | forgotPassword() { 83 | Swal.fire('Forgot Password', `No problem, we'll send you an email with a temporary password`, 'info'); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /client/src/auth/logout.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/auth/logout.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { AuthService } from '../shared/services/auth.service'; 3 | 4 | @autoinject() 5 | export class Logout { 6 | constructor(private authService: AuthService) { } 7 | 8 | activate(): void { 9 | window.localStorage.removeItem('userProfile'); 10 | this.authService.logout('login') 11 | .then(response => { 12 | console.log(`ok logged out on logout.js`); 13 | }) 14 | .catch(err => { 15 | console.log(`error logged out logout.js => ${err}`); 16 | }); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/auth/profile.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { AuthService as LocalAuthService } from '../shared/services/auth.service'; 3 | import authConfig from './auth-config.development'; 4 | import { PopupService } from 'shared/services/popup.service'; 5 | 6 | @autoinject() 7 | export class Profile { 8 | email: string = ''; 9 | password: string = ''; 10 | heading: string = 'Profile'; 11 | profile: any; 12 | 13 | constructor(private authService: LocalAuthService, private popupService: PopupService) { } 14 | 15 | activate(): any { 16 | this.getCurrentUserInfo(); 17 | this.getCurrentUserInfoWithGraphql(); 18 | } 19 | 20 | getCurrentUserInfo() { 21 | this.authService.getMe() 22 | .then(data => this.profile = data) 23 | .catch(err => console.log(`activate failure in profile.ts => ${err}`)); 24 | } 25 | 26 | getCurrentUserInfoWithGraphql() { 27 | this.authService.getWhoAmI() 28 | .then(data => console.log('[GraphQL] Who am I...', data)); 29 | } 30 | 31 | async authenticate(provider) { 32 | try { 33 | const providerConfig = authConfig.providers[provider]; 34 | const endpointUrl = `${providerConfig.authorizationEndpoint}?save=false`; 35 | const target = authConfig.platform === 'mobile' ? '_self' : provider; // you can perhaps look at the screen size to know it's a mobile? 36 | const popupSizes = { 37 | height: providerConfig.popupSize.height || 800, 38 | width: providerConfig.popupSize.width || 800, 39 | } 40 | 41 | this.popupService.open(endpointUrl, target, popupSizes); 42 | 43 | // Desktop 44 | return await this.popupService.pollPopup(); 45 | } catch (error) { 46 | console.log(error); 47 | } 48 | } 49 | 50 | findByProviderName(profile, providerName: string) { 51 | if (profile && Array.isArray(profile.providers)) { 52 | return profile.providers.find((provider) => provider.name === providerName); 53 | } 54 | return null; 55 | } 56 | 57 | async applyLink(providerName: string, providerId: string) { 58 | providerId ? this.unlink(providerName, providerId) : this.link(providerName); 59 | } 60 | 61 | async link(providerName: string) { 62 | const token = await this.authenticate(providerName); 63 | const user = await this.authService.link(providerName, token); 64 | this.profile = user; 65 | } 66 | 67 | async unlink(providerName: string, providerId: string) { 68 | const user = await this.authService.unlink({ id: providerId, name: providerName }); 69 | this.profile = user; 70 | } 71 | 72 | } 73 | -------------------------------------------------------------------------------- /client/src/auth/signup.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /client/src/auth/signup.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { ValidationController, ValidationControllerFactory, ValidationRules } from 'aurelia-validation'; 3 | import Swal from 'sweetalert2'; 4 | 5 | import { AuthDataService } from './auth-data.service'; 6 | import { AuthUtilityService } from './auth-utility.service'; 7 | import { BootstrapFormRenderer } from 'shared/bootstrap-form-renderer'; 8 | 9 | @autoinject() 10 | export class Signup { 11 | heading = 'Sign Up'; 12 | email: string = ''; 13 | password = ''; 14 | confirmPassword = ''; 15 | displayName = ''; 16 | controller: ValidationController = null; 17 | 18 | constructor( 19 | private authUtilityService: AuthUtilityService, 20 | private authDataService: AuthDataService, 21 | private controllerFactory: ValidationControllerFactory, 22 | ) { 23 | this.controller = this.controllerFactory.createForCurrentScope(); 24 | this.controller.addRenderer(new BootstrapFormRenderer()); 25 | 26 | this.addFormValidations(); 27 | } 28 | 29 | async signup(): Promise { 30 | const result = await this.controller.validate(); 31 | if (result.valid) { 32 | return this.authDataService.signup(this.email.toLowerCase(), this.password, this.displayName) 33 | .then((resp) => { 34 | this.authUtilityService.saveTokenAndRedirect(resp.token); 35 | Swal.fire('Success...', `You are signup up, please re-enter your email & password to confirm your identity`, 'success'); 36 | }) 37 | .catch((error) => { 38 | Swal.fire('Oops...', `Something went wrong and we could not verify your sign up. ${error}`, 'error'); 39 | }); 40 | } 41 | return false; 42 | } 43 | 44 | async usernameAvailable(email): Promise { 45 | return this.authDataService.usernameAvailable(email.toLowerCase()); 46 | } 47 | 48 | addFormValidations() { 49 | ValidationRules.customRule( 50 | 'matchesProperty', 51 | (value, obj, otherPropertyName) => 52 | value === null 53 | || value === undefined || value === '' 54 | || obj[otherPropertyName] === null 55 | || obj[otherPropertyName] === undefined 56 | || obj[otherPropertyName] === '' 57 | || value === obj[otherPropertyName], 58 | '${$displayName} must match ${$getDisplayName($config.otherPropertyName)}', 59 | otherPropertyName => ({ otherPropertyName }) 60 | ); 61 | 62 | ValidationRules.customRule( 63 | 'usernameAvailable', 64 | (value, obj) => this.usernameAvailable(obj.email), 65 | 'Sorry this username is not available', 66 | ) 67 | 68 | 69 | ValidationRules 70 | .ensure('displayName').required() 71 | .ensure('email').email().required().satisfiesRule('usernameAvailable') 72 | .ensure('password').required().minLength(5) 73 | .ensure('confirmPassword').required().satisfiesRule('matchesProperty', 'password') 74 | .on(this); 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /client/src/cats/cat.interface.ts: -------------------------------------------------------------------------------- 1 | export interface Cat { 2 | name: string; 3 | age: number; 4 | breed: string; 5 | ownerId: string; 6 | } 7 | -------------------------------------------------------------------------------- /client/src/cats/cats-data.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { HttpClient, json } from 'aurelia-fetch-client'; 3 | import { Globals } from '../shared/globals'; 4 | 5 | @autoinject() 6 | export class CatsDataService { 7 | constructor(protected http: HttpClient) { 8 | this.http = http; 9 | } 10 | 11 | getAll(): Promise { 12 | return this.http.fetch(Globals.baseGraphQlUrl, { 13 | method: 'post', 14 | body: json({ query: `query { cats { id, name, age, breed, owner { id, displayName } }}` }) 15 | }).then(response => response.json()); 16 | } 17 | 18 | getCats(query: string): Promise { 19 | console.log(query) 20 | return new Promise(async resolve => { 21 | const response = await this.http.fetch(Globals.baseGraphQlUrl, { 22 | method: 'post', 23 | body: json({ query }) 24 | }); 25 | resolve(response.json()); 26 | }); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/src/cats/cats-list.html: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /client/src/cats/cats-list.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { Column, Formatters, GridOption } from 'aurelia-slickgrid'; 3 | import { GraphqlService, GraphqlServiceApi, } from '@slickgrid-universal/graphql'; 4 | 5 | import { Cat } from './cat.interface'; 6 | import { CatsDataService } from './cats-data.service'; 7 | 8 | @autoinject() 9 | export class CatsList { 10 | gridOptions: GridOption; 11 | columnDefinitions: Column[]; 12 | 13 | constructor(private catsDataService: CatsDataService) { 14 | this.defineGrid(); 15 | } 16 | 17 | defineGrid() { 18 | this.columnDefinitions = [ 19 | { id: 'name', name: 'Name', field: 'name', filterable: true, sortable: true }, 20 | { id: 'breed', name: 'Breed', field: 'breed', filterable: true, sortable: true }, 21 | { id: 'age', name: 'Age', field: 'age', filterable: true, sortable: true }, 22 | { id: 'owner', name: 'Owner', field: 'owner.displayName', filterable: true, sortable: true, formatter: Formatters.complexObject }, 23 | ]; 24 | 25 | this.gridOptions = { 26 | autoResize: { 27 | container: '.grid-container' 28 | }, 29 | enableFiltering: true, 30 | backendServiceApi: { 31 | service: new GraphqlService(), 32 | options: { 33 | datasetName: 'cats', 34 | columnDefinitions: this.columnDefinitions, 35 | useLocalFiltering: true, 36 | useLocalSorting: true, 37 | }, 38 | process: (query) => this.catsDataService.getCats(query), 39 | useLocalFiltering: true, 40 | useLocalSorting: true, 41 | } as GraphqlServiceApi, 42 | enablePagination: false, 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /client/src/contacts/contact-detail.html: -------------------------------------------------------------------------------- 1 | 41 | -------------------------------------------------------------------------------- /client/src/contacts/contact-detail.ts: -------------------------------------------------------------------------------- 1 | import {autoinject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {RouterConfiguration} from 'aurelia-router'; 4 | import {Contact} from './contact'; 5 | import {WebAPI} from './web-api'; 6 | import {ContactUpdated, ContactViewed} from './messages'; 7 | import {areEqual} from './utility'; 8 | import $ from 'bootstrap'; 9 | 10 | @autoinject() 11 | export class ContactDetail { 12 | api: WebAPI; 13 | ea: EventAggregator; 14 | contact: Contact; 15 | routeConfig: any; 16 | originalContact: Contact; 17 | 18 | constructor(api: WebAPI, eventAggregator: EventAggregator) { 19 | this.api = api; 20 | this.ea = eventAggregator; 21 | } 22 | 23 | activate(params: any, routeConfig: any): Promise{ 24 | this.routeConfig = routeConfig; 25 | //$('#example').tooltip(options) 26 | 27 | return this.api.getContactDetails(params.id).then(contact => { 28 | this.contact = contact; 29 | this.routeConfig.navModel.setTitle(contact.firstName); 30 | this.originalContact = JSON.parse(JSON.stringify(contact)); 31 | this.ea.publish(new ContactViewed(contact)); 32 | }); 33 | } 34 | 35 | get canSave(): boolean { 36 | return this.contact.firstName && this.contact.lastName && !this.api.isRequesting; 37 | } 38 | 39 | save(): void { 40 | this.api.saveContact(this.contact).then(contact => { 41 | this.contact = contact; 42 | this.routeConfig.navModel.setTitle(contact.firstName); 43 | this.originalContact = JSON.parse(JSON.stringify(contact)); 44 | this.ea.publish(new ContactUpdated(this.contact)); 45 | }); 46 | } 47 | 48 | canDeactivate(): boolean { 49 | if(!areEqual(this.originalContact, this.contact)){ 50 | let result = confirm('You have unsaved changes. Are you sure you wish to leave?'); 51 | 52 | if(!result){ 53 | this.ea.publish(new ContactViewed(this.contact)); 54 | } 55 | 56 | return result; 57 | } 58 | 59 | return true; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /client/src/contacts/contact-list.html: -------------------------------------------------------------------------------- 1 | 13 | -------------------------------------------------------------------------------- /client/src/contacts/contact-list.ts: -------------------------------------------------------------------------------- 1 | import {autoinject} from 'aurelia-framework'; 2 | import {EventAggregator} from 'aurelia-event-aggregator'; 3 | import {Contact} from './contact'; 4 | import {WebAPI} from './web-api'; 5 | import {ContactUpdated, ContactViewed} from './messages'; 6 | 7 | @autoinject() 8 | export class ContactList { 9 | api: WebAPI; 10 | ea: EventAggregator; 11 | contacts: Array; 12 | selectedId: number; 13 | 14 | constructor(eventAggregator: EventAggregator, api: WebAPI) { 15 | this.ea = eventAggregator; 16 | this.api = api; 17 | 18 | this.ea.subscribe(ContactViewed, msg => { 19 | this.select(msg.contact) 20 | }); 21 | this.ea.subscribe(ContactUpdated, msg => { 22 | let id = msg.contact.id; 23 | let found = this.contacts.find(x => x.id == id); 24 | Object.assign(found, msg.contact); 25 | }); 26 | } 27 | 28 | created() { 29 | this.api.getContactList().then(contacts => this.contacts = contacts).catch(err => console.log(err)); 30 | } 31 | 32 | select(contact) { 33 | this.selectedId = contact.id; 34 | return true; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /client/src/contacts/contact.ts: -------------------------------------------------------------------------------- 1 | export class Contact { 2 | id: number; 3 | firstName: string; 4 | lastName: string; 5 | email: string; 6 | phoneNumber: string; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/contacts/index.html: -------------------------------------------------------------------------------- 1 | 11 | -------------------------------------------------------------------------------- /client/src/contacts/index.ts: -------------------------------------------------------------------------------- 1 | import {autoinject, PLATFORM} from 'aurelia-framework'; 2 | import {Router, RouterConfiguration} from 'aurelia-router'; 3 | import {WebAPI} from './web-api'; 4 | 5 | @autoinject() 6 | export class Index { 7 | api: WebAPI; 8 | router: Router; 9 | 10 | configureRouter(config: RouterConfiguration, router: Router): void { 11 | config.title = 'Contacts'; 12 | config.map([ 13 | { route: '', moduleId: PLATFORM.moduleName('./no-selection'), name:'contact', title: 'Select'}, 14 | { route: 'detail/:id', moduleId: PLATFORM.moduleName('./contact-detail'), name:'detail' } 15 | ]); 16 | 17 | this.router = router; 18 | } 19 | 20 | onDeactivate(): void { 21 | this.api = null; 22 | } 23 | destroy(): void { 24 | this.api = null; 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /client/src/contacts/messages.ts: -------------------------------------------------------------------------------- 1 | import {autoinject} from 'aurelia-framework'; 2 | import {Contact} from './contact'; 3 | 4 | @autoinject() 5 | export class ContactUpdated { 6 | contact?: Contact; 7 | constructor(contact?: Contact) { 8 | this.contact = contact; 9 | } 10 | } 11 | 12 | @autoinject() 13 | export class ContactViewed { 14 | contact?: Contact; 15 | constructor(contact?: Contact) { 16 | this.contact = contact; 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /client/src/contacts/no-selection.html: -------------------------------------------------------------------------------- 1 | 6 | -------------------------------------------------------------------------------- /client/src/contacts/no-selection.ts: -------------------------------------------------------------------------------- 1 | export class NoSelection { 2 | message: string = 'Please Select a Contact.'; 3 | } 4 | -------------------------------------------------------------------------------- /client/src/contacts/style.css: -------------------------------------------------------------------------------- 1 | 2 | section { 3 | margin: 0 20px; 4 | } 5 | 6 | a:focus { 7 | outline: none; 8 | } 9 | 10 | .navbar-nav li.loader { 11 | margin: 12px 24px 0 6px; 12 | } 13 | 14 | .no-selection { 15 | margin: 20px; 16 | } 17 | 18 | .contact-list { 19 | overflow-y: auto; 20 | border: 1px solid #ddd; 21 | padding: 10px; 22 | } 23 | 24 | .panel { 25 | margin: 20px; 26 | } 27 | 28 | .button-bar { 29 | right: 0; 30 | left: 0; 31 | bottom: 0; 32 | border-top: 1px solid #ddd; 33 | background: white; 34 | } 35 | 36 | .button-bar > button { 37 | float: right; 38 | margin: 20px; 39 | } 40 | 41 | li.list-group-item { 42 | list-style: none; 43 | } 44 | 45 | li.list-group-item > a { 46 | text-decoration: none; 47 | } 48 | 49 | li.list-group-item.active > a { 50 | color: white; 51 | } 52 | -------------------------------------------------------------------------------- /client/src/contacts/utility.ts: -------------------------------------------------------------------------------- 1 | export function areEqual(obj1: object, obj2: object): boolean { 2 | if(obj1 && !obj2) { 3 | return false; 4 | } 5 | return Object.keys(obj1).every((key) => obj2.hasOwnProperty(key) && (obj1[key] === obj2[key])); 6 | }; 7 | -------------------------------------------------------------------------------- /client/src/contacts/web-api.ts: -------------------------------------------------------------------------------- 1 | import {Contact} from './contact'; 2 | 3 | let contacts: Array; 4 | let latency: number = 200; 5 | let id: number = 0; 6 | 7 | function getId(): number { 8 | return ++id; 9 | } 10 | 11 | contacts = [ 12 | { 13 | id: getId(), 14 | firstName: 'John', 15 | lastName: 'Tolkien', 16 | email: 'tolkien@inklings.com', 17 | phoneNumber: '867-1234' 18 | }, 19 | { 20 | id: getId(), 21 | firstName: 'Clive', 22 | lastName: 'Lewis', 23 | email: 'lewis@inklings.com', 24 | phoneNumber: '867-5566' 25 | }, 26 | { 27 | id: getId(), 28 | firstName: 'Owen', 29 | lastName: 'Barfield', 30 | email: 'barfield@inklings.com', 31 | phoneNumber: '867-8870' 32 | }, 33 | { 34 | id: getId(), 35 | firstName: 'Charles', 36 | lastName: 'Williams', 37 | email: 'williams@inklings.com', 38 | phoneNumber: '867-9900' 39 | }, 40 | { 41 | id: getId(), 42 | firstName: 'Roger', 43 | lastName: 'Green', 44 | email: 'green@inklings.com', 45 | phoneNumber: '867-5309' 46 | } 47 | ]; 48 | 49 | export class WebAPI { 50 | isRequesting: boolean = false; 51 | 52 | getContactList(): Promise { 53 | this.isRequesting = true; 54 | return new Promise(resolve => { 55 | setTimeout(() => { 56 | let results = contacts.map(x => { return { 57 | id: x.id, 58 | firstName: x.firstName, 59 | lastName: x.lastName, 60 | email: x.email 61 | }}); 62 | resolve(results); 63 | this.isRequesting = false; 64 | }, latency); 65 | }); 66 | } 67 | 68 | getContactDetails(id: number): Promise { 69 | this.isRequesting = true; 70 | return new Promise(resolve => { 71 | setTimeout(() => { 72 | let found = contacts.filter(x => x.id == id)[0]; 73 | resolve(JSON.parse(JSON.stringify(found))); 74 | this.isRequesting = false; 75 | }, latency); 76 | }); 77 | } 78 | 79 | saveContact(contact: Contact): Promise { 80 | this.isRequesting = true; 81 | return new Promise(resolve => { 82 | setTimeout(() => { 83 | let instance = JSON.parse(JSON.stringify(contact)); 84 | let found = contacts.filter(x => x.id == contact.id)[0]; 85 | 86 | if(found) { 87 | let index = contacts.indexOf(found); 88 | contacts[index] = instance; 89 | } else { 90 | instance.id = getId(); 91 | contacts.push(instance); 92 | } 93 | 94 | this.isRequesting = false; 95 | resolve(instance); 96 | }, latency); 97 | }); 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /client/src/custom-http-client.ts: -------------------------------------------------------------------------------- 1 | import { EventAggregator } from 'aurelia-event-aggregator'; 2 | import { HttpClient } from 'aurelia-fetch-client'; 3 | import { autoinject } from 'aurelia-framework'; 4 | import 'isomorphic-fetch'; // if you need a fetch polyfill 5 | import * as globalConfig from '../config'; 6 | import { AuthService } from 'shared/services/auth.service'; 7 | 8 | const _httpQueue = []; 9 | 10 | @autoinject() 11 | export class CustomHttpClient extends HttpClient { 12 | 13 | constructor(private auth: AuthService, private ea: EventAggregator) { 14 | super(); 15 | this.configure(config => { 16 | config 17 | .withBaseUrl(globalConfig.baseUrl) 18 | .withDefaults({ 19 | credentials: 'same-origin', 20 | headers: { 21 | 'Accept': 'application/json', 22 | 'X-Requested-With': 'Fetch' 23 | } 24 | }) 25 | //we call ourselves the interceptor which comes with aurelia-auth 26 | //obviously when this custom Http Client is used for services 27 | //which don't need a bearer token, you should not inject the token interceptor 28 | .withInterceptor(this.auth['tokenInterceptor']) 29 | //still we can augment the custom HttpClient with own interceptors 30 | .withInterceptor({ 31 | request(request) { 32 | _httpQueue.push(request.url); 33 | // console.log(`Requesting ${request.method} ${request.url}`); 34 | ea.publish('http:started', _httpQueue); 35 | return request; // you can return a modified Request, or you can short-circuit the request by returning a Response 36 | }, 37 | response(response) { 38 | // remove any URLs from the queue that are equal 39 | _httpQueue.forEach((url, i) => { 40 | if (url === response.url) { 41 | _httpQueue.splice(i, 1); 42 | } 43 | }); 44 | if (_httpQueue.length === 0) { 45 | ea.publish('http:stopped', _httpQueue); 46 | } 47 | // console.log(`Received ${response.status} ${response.url}`); 48 | return response; // you can return a modified Response 49 | } 50 | }); 51 | }); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /client/src/environment.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | debug: false, 3 | testing: false 4 | }; 5 | -------------------------------------------------------------------------------- /client/src/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ghiscoding/aurelia-nest-auth-mongodb/56ec8b9c178c5b7bbb60dd361ef3f421c7eb5449/client/src/favicon.ico -------------------------------------------------------------------------------- /client/src/main.ts: -------------------------------------------------------------------------------- 1 | // we want font-awesome to load as soon as possible to show the fa-spinner 2 | import 'font-awesome/css/font-awesome.css'; 3 | import 'nprogress/nprogress.css'; 4 | import './styles/bootstrap.scss'; 5 | import 'sweetalert2/dist/sweetalert2.min.css'; 6 | import 'bootstrap-social/bootstrap-social.css'; 7 | import 'font-awesome/css/font-awesome.css'; 8 | import 'flatpickr/dist/flatpickr.min.css'; 9 | import { Aurelia } from 'aurelia-framework'; 10 | import { PLATFORM } from 'aurelia-pal'; 11 | import { GridOption } from 'aurelia-slickgrid'; 12 | import 'bootstrap'; 13 | 14 | import './styles/styles.scss'; 15 | import './styles/bootstrap-social.scss'; 16 | 17 | export async function configure(aurelia: Aurelia) { 18 | aurelia.use 19 | .standardConfiguration() 20 | .feature(PLATFORM.moduleName('resources/index')); 21 | 22 | aurelia.use.plugin(PLATFORM.moduleName('aurelia-validation')); 23 | aurelia.use.plugin(PLATFORM.moduleName('aurelia-slickgrid'), (config: { options: GridOption }) => { 24 | // define a few global grid options 25 | // config.options.gridMenu.iconCssClass = 'fa fa-ellipsis-v' 26 | }); 27 | 28 | await aurelia.start(); 29 | await aurelia.setRoot(PLATFORM.moduleName('app')); 30 | } 31 | -------------------------------------------------------------------------------- /client/src/nav-bar.html: -------------------------------------------------------------------------------- 1 | 28 | -------------------------------------------------------------------------------- /client/src/nav-bar.ts: -------------------------------------------------------------------------------- 1 | import { autoinject, bindable, BindingEngine } from 'aurelia-framework'; 2 | import { Router } from 'aurelia-router'; 3 | import { AuthService } from './shared/services/auth.service'; 4 | 5 | @autoinject() 6 | export class NavBar { 7 | @bindable router: Router; 8 | _isAuthenticated = false; 9 | isAdmin = false; 10 | displayName: string = ''; 11 | subscription: { dispose: () => void }; 12 | 13 | constructor(private bindingEngine: BindingEngine, private authService: AuthService) { 14 | this._isAuthenticated = this.authService.isAuthenticated(); 15 | this.subscription = this.bindingEngine.propertyObserver(this, 'isAuthenticated') 16 | .subscribe((newValue, oldValue) => { 17 | if (this.isAuthenticated) { 18 | this.authService.getMe().then(data => { 19 | localStorage.setItem('userProfile', JSON.stringify(data)); 20 | return this.displayName = data.displayName; 21 | }); 22 | } 23 | }); 24 | } 25 | 26 | get isAuthenticated(): boolean { 27 | const isLoggedIn = this.authService.isAuthenticated(); 28 | if (isLoggedIn) { 29 | let profile = localStorage.getItem('userProfile'); 30 | const userProfile = (typeof profile === 'string') ? JSON.parse(profile) : {}; 31 | if (userProfile.roles) { 32 | this.isAdmin = userProfile.roles.findIndex((role: string) => role.toUpperCase() === 'ADMIN') >= 0; 33 | } else { 34 | this.isAdmin = false; 35 | } 36 | this.displayName = userProfile.displayName; 37 | } 38 | return isLoggedIn; 39 | } 40 | 41 | deactivate() { 42 | this.subscription.dispose(); 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /client/src/resources/elements/abp-modal.html: -------------------------------------------------------------------------------- 1 | 30 | -------------------------------------------------------------------------------- /client/src/resources/elements/abp-modal.ts: -------------------------------------------------------------------------------- 1 | import {inject, bindable, bindingMode, DOM, PLATFORM} from 'aurelia-framework'; 2 | import $ from 'jquery'; 3 | import 'bootstrap/js/src/modal'; 4 | 5 | @inject(Element) 6 | export class AbpModalCustomElement { 7 | @bindable() disabled; 8 | @bindable() element; 9 | @bindable() value; 10 | @bindable() dismissFlag; 11 | @bindable() dismissReason; 12 | @bindable() model; 13 | @bindable() saveMethod; 14 | @bindable() viewModel: string; 15 | @bindable() onClose: (reason?: any) => void; 16 | @bindable() onDismiss: (reason?: any) => void; 17 | @bindable() buttonList = []; 18 | 19 | parent: any; 20 | 21 | // plugin own variables 22 | @bindable placeholder = ''; 23 | 24 | // picker options 25 | @bindable options; 26 | @bindable showing = true; 27 | 28 | // events (from the View) 29 | @bindable onBeforeItemAdd; 30 | @bindable onBeforeItemRemove; 31 | @bindable onItemAdded; 32 | @bindable onItemAddedOnInit; 33 | @bindable onItemRemoved; 34 | 35 | // variables 36 | customVM: any; 37 | domElm: any; 38 | elm: any; 39 | events = {}; 40 | methods = {}; 41 | suppressValueChanged; 42 | 43 | constructor(elm) { 44 | this.elm = elm; 45 | } 46 | 47 | attached() { 48 | console.log('attached abp-modal') 49 | // reference to the DOM element 50 | this.domElm = $(this.elm).find('.modal') 51 | .modal({ show: true }) 52 | .on('show.bs.modal', () => { 53 | this.showing = true; 54 | }) 55 | .on('hide.bs.modal', () => { 56 | this.showing = false; 57 | }); 58 | console.log(PLATFORM.moduleName(this.viewModel)); 59 | } 60 | 61 | detached() { 62 | console.log('detached abp-modal') 63 | this.domElm.modal('hide'); 64 | // this.domElm.modal('dispose'); 65 | } 66 | 67 | modelChanged(newVal) { 68 | console.log(newVal); 69 | console.log(this.customVM.currentViewModel); 70 | } 71 | 72 | bind(parent: any) { 73 | this.parent = parent; 74 | console.log(parent.showBootstrapModal, this.showing); 75 | } 76 | 77 | unbind() { 78 | console.log('unbind abp-modal') 79 | } 80 | 81 | closeModal(reason?: any) { 82 | this.showing = false; 83 | // this.parent.showBootstrapModal = false; 84 | if (this.parent[this.dismissFlag]) { 85 | this.parent[this.dismissFlag] = false; 86 | } 87 | if (typeof this.onClose === 'function') { 88 | this.onClose({ reason }); 89 | } 90 | this.hideModal(); 91 | } 92 | 93 | dismiss(reason?: any) { 94 | console.log('dismissing', this.onDismiss) 95 | this.closeModal(); 96 | if (typeof this.onDismiss === 'function') { 97 | console.log('dismissing reason', reason) 98 | this.onDismiss({ reason }); 99 | } 100 | } 101 | 102 | hideModal() { 103 | this.domElm.modal('hide'); 104 | } 105 | 106 | public showModal() { 107 | this.domElm.modal('show'); 108 | } 109 | 110 | showingChanged(newValue) { 111 | if (newValue) { 112 | $(this.domElm).modal('show') 113 | } else { 114 | $(this.domElm).modal('hide') 115 | } 116 | } 117 | 118 | buttonMethod(method?: any) { 119 | const childMethod = this.customVM && this.customVM.currentViewModel && this.customVM.currentViewModel[method]; 120 | console.log(method, this.customVM.currentViewModel, childMethod) 121 | if (typeof childMethod === 'function') { 122 | const promise = childMethod.bind(this.customVM.currentViewModel)(); 123 | if (promise && promise.then) { 124 | promise.then((response) => this.dismiss(response)); 125 | } 126 | } 127 | if (typeof method === 'function') { 128 | method.bind(this.customVM.currentViewModel); 129 | method(this.model); 130 | } 131 | } 132 | 133 | saveChanges() { 134 | const promise = this.customVM && this.customVM.currentViewModel && this.customVM.currentViewModel.save(this.model) 135 | if (promise && promise.then) { 136 | promise.then((response) => this.dismiss(response)); 137 | } 138 | } 139 | } 140 | -------------------------------------------------------------------------------- /client/src/resources/elements/bootstrap-tooltip.ts: -------------------------------------------------------------------------------- 1 | import {inject, customAttribute} from 'aurelia-framework'; 2 | import 'bootstrap/js/src/tooltip'; 3 | import * as $ from 'jquery'; 4 | 5 | @customAttribute('bootstrap-tooltip') 6 | @inject(Element) 7 | export class BootstrapTooltip { 8 | element: HTMLElement; 9 | 10 | constructor(element: HTMLElement) { 11 | this.element = element; 12 | } 13 | 14 | bind() { 15 | $(this.element).tooltip(); 16 | } 17 | 18 | unbind() { 19 | $(this.element).tooltip('dispose'); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /client/src/resources/elements/loading-indicator.ts: -------------------------------------------------------------------------------- 1 | import * as nprogress from 'nprogress'; 2 | import {bindable, noView} from 'aurelia-framework'; 3 | 4 | @noView() 5 | export class LoadingIndicator { 6 | @bindable loading: boolean = false; 7 | 8 | loadingChanged(newValue: boolean) { 9 | if(newValue) { 10 | nprogress.start(); 11 | }else { 12 | nprogress.done(); 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /client/src/resources/index.ts: -------------------------------------------------------------------------------- 1 | import { FrameworkConfiguration } from 'aurelia-framework'; 2 | import { PLATFORM } from 'aurelia-pal'; 3 | 4 | export function configure(config: FrameworkConfiguration) { 5 | config.globalResources([ 6 | PLATFORM.moduleName('./elements/abp-modal'), 7 | PLATFORM.moduleName('./elements/bootstrap-tooltip'), 8 | PLATFORM.moduleName('./elements/loading-indicator'), 9 | PLATFORM.moduleName('./value-converters/admin-filter'), 10 | PLATFORM.moduleName('./value-converters/auth-filter'), 11 | PLATFORM.moduleName('./value-converters/date-format'), 12 | PLATFORM.moduleName('./value-converters/number'), 13 | PLATFORM.moduleName('./value-converters/stringify') 14 | ]); 15 | } 16 | -------------------------------------------------------------------------------- /client/src/resources/value-converters/admin-filter.ts: -------------------------------------------------------------------------------- 1 | export class AdminFilterValueConverter { 2 | toView(routes, isAdmin) { 3 | return routes.filter(r => r.config.admin === undefined || r.config.admin === isAdmin); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/resources/value-converters/auth-filter.ts: -------------------------------------------------------------------------------- 1 | export class AuthFilterValueConverter { 2 | toView(routes, isAuthenticated) { 3 | return routes.filter(r => r.config.auth === undefined || r.config.auth === isAuthenticated); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/resources/value-converters/date-format.ts: -------------------------------------------------------------------------------- 1 | import * as moment from 'moment'; 2 | 3 | export class DateFormatValueConverter { 4 | toView(value: any, format: string): string { 5 | return moment(value).format(format); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/src/resources/value-converters/number.ts: -------------------------------------------------------------------------------- 1 | export class NumberValueConverter { 2 | fromView(value: any, format: string): number { 3 | const number = parseFloat(value); 4 | return isNaN(number) ? value : number; 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /client/src/resources/value-converters/stringify.ts: -------------------------------------------------------------------------------- 1 | export class StringifyValueConverter { 2 | public toView(value: any): string { 3 | return JSON.stringify(value, null, 4); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/shared/bootstrap-form-renderer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ValidationRenderer, 3 | RenderInstruction, 4 | ValidateResult 5 | } from 'aurelia-validation'; 6 | 7 | export class BootstrapFormRenderer { 8 | render(instruction: RenderInstruction) { 9 | for (let { result, elements } of instruction.unrender) { 10 | for (let element of elements) { 11 | this.remove(element, result); 12 | } 13 | } 14 | 15 | for (let { result, elements } of instruction.render) { 16 | for (let element of elements) { 17 | this.add(element, result); 18 | } 19 | } 20 | } 21 | 22 | add(element: Element, result: ValidateResult) { 23 | const formGroup = element.closest('.form-group'); 24 | if (!formGroup) { 25 | return; 26 | } 27 | 28 | if (result.valid) { 29 | if (!formGroup.classList.contains('has-error')) { 30 | formGroup.classList.add('has-success'); 31 | } 32 | } else { 33 | // add the has-error class to the enclosing form-group div 34 | formGroup.classList.remove('has-success'); 35 | formGroup.classList.add('has-error'); 36 | 37 | // add form-text 38 | const message = document.createElement('span'); 39 | message.className = 'form-text validation-message text-danger'; 40 | message.textContent = result.message; 41 | message.id = `validation-message-${result.id}`; 42 | formGroup.appendChild(message); 43 | } 44 | } 45 | 46 | remove(element: Element, result: ValidateResult) { 47 | const formGroup = element.closest('.form-group'); 48 | if (!formGroup) { 49 | return; 50 | } 51 | 52 | if (result.valid) { 53 | if (formGroup.classList.contains('has-success')) { 54 | formGroup.classList.remove('has-success'); 55 | } 56 | } else { 57 | // remove form-text 58 | const message = formGroup.querySelector(`#validation-message-${result.id}`); 59 | if (message) { 60 | formGroup.removeChild(message); 61 | 62 | // remove the has-error class from the enclosing form-group div 63 | if (formGroup.querySelectorAll('.form-text.validation-message').length === 0) { 64 | formGroup.classList.remove('has-error'); 65 | } 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /client/src/shared/globals.ts: -------------------------------------------------------------------------------- 1 | export class Globals { 2 | static baseUrl = 'http://localhost:3000'; 3 | static baseApiUrl = 'api'; 4 | static baseGraphQlUrl = 'http://localhost:3000/graphql'; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/shared/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { HttpClient, json } from 'aurelia-fetch-client'; 3 | import { Router } from 'aurelia-router'; 4 | import authConfig from '../../auth/auth-config.development'; 5 | import { AuthenticationService } from './authentication.service'; 6 | 7 | @autoinject() 8 | export class AuthService { 9 | constructor(private authenticationService: AuthenticationService, private http: HttpClient, private router: Router) { } 10 | 11 | authenticate() { 12 | 13 | } 14 | 15 | isAuthenticated(): boolean { 16 | return this.authenticationService.isAuthenticated(); 17 | } 18 | 19 | /** Get current user info */ 20 | getMe(): Promise { 21 | return this.http.fetch(authConfig.profileEndpoint).then(this.status); 22 | } 23 | 24 | /** Get current user info using the GraphQL WhoAmI query */ 25 | getWhoAmI(): Promise { 26 | return this.http.fetch(`http://localhost:3000/graphql`, { 27 | method: 'post', 28 | body: json({ query: `query { whoAmI { userId, displayName, email }}` }) 29 | }).then(response => response.json()); 30 | } 31 | 32 | logout(navigateUrl: string) { 33 | return new Promise((resolve) => { 34 | window.localStorage.removeItem(authConfig.tokenName); 35 | this.router.navigateToRoute(navigateUrl); 36 | resolve(true); 37 | }); 38 | } 39 | 40 | status(response) { 41 | if (response.status >= 200 && response.status < 400) { 42 | return response.json().catch(error => null); 43 | } else if (response.status === 401) { 44 | throw new Error(`401 (Unauthorized)`); 45 | } 46 | throw response; 47 | } 48 | 49 | /** Link a new Provider to the Current Users */ 50 | link(provider: string, token: string): Promise { 51 | return this.http.fetch(`${authConfig.baseUrl}/link/${provider}`, { 52 | method: 'post', 53 | body: json({ token }) 54 | }) 55 | .then(this.status); 56 | } 57 | 58 | /** Unlink a Provider to the Current Users */ 59 | unlink(provider: { id: string, name: string }): Promise { 60 | return this.http.fetch(`${authConfig.baseUrl}/unlink/${provider.name}`) 61 | .then(this.status); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/src/shared/services/authentication.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { StorageService } from './storage.service'; 3 | import authConfig from '../../auth/auth-config.development'; 4 | 5 | @autoinject() 6 | export class AuthenticationService { 7 | initialUrl: string; 8 | 9 | constructor(private storage: StorageService) { } 10 | 11 | getLoginRedirect() { 12 | return this.initialUrl || '/'; 13 | } 14 | 15 | getLoginRoute() { 16 | return '/login'; 17 | } 18 | 19 | getInitialUrl(url) { 20 | this.initialUrl = url; 21 | } 22 | 23 | setInitialUrl(url) { 24 | this.initialUrl = url; 25 | } 26 | 27 | isAuthenticated() { 28 | let token = this.storage.get(authConfig.tokenName); 29 | 30 | // There's no token, so user is not authenticated. 31 | if (!token) { 32 | return false; 33 | } 34 | 35 | // There is a token, but in a different format. Return true. 36 | if (token.split('.').length !== 3) { 37 | return true; 38 | } 39 | 40 | // make sure the token is not expired 41 | let exp; 42 | try { 43 | let base64Url = token.split('.')[1]; 44 | let base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/'); 45 | exp = JSON.parse(window.atob(base64)).exp; 46 | } catch (error) { 47 | return false; 48 | } 49 | 50 | if (exp) { 51 | return Math.round(new Date().getTime() / 1000) <= exp; 52 | } 53 | 54 | return true; 55 | } 56 | 57 | get tokenInterceptor() { 58 | let config = { 59 | authHeader: 'Authorization', 60 | authToken: 'Bearer', 61 | httpInterceptor: true, 62 | tokenPrefix: '', 63 | tokenName: authConfig.tokenName, 64 | }; 65 | let storage = this.storage; 66 | let auth = this; 67 | return { 68 | request(request) { 69 | if (auth.isAuthenticated() && config.httpInterceptor) { 70 | let tokenName = config.tokenPrefix ? `${config.tokenPrefix}_${config.tokenName}` : config.tokenName; 71 | let token = storage.get(tokenName); 72 | 73 | if (config.authHeader && config.authToken) { 74 | token = `${config.authToken} ${token}`; 75 | } 76 | 77 | request.headers.set(config.authHeader, token); 78 | } 79 | return request; 80 | } 81 | }; 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /client/src/shared/services/authorize-step.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-dependency-injection'; 2 | import { Redirect } from 'aurelia-router'; 3 | import { AuthenticationService } from './authentication.service'; 4 | 5 | @autoinject() 6 | export class AuthorizeStep { 7 | constructor(private auth: AuthenticationService) { } 8 | 9 | run(routingContext, next) { 10 | let isLoggedIn = this.auth.isAuthenticated(); 11 | let loginRoute = this.auth.getLoginRoute(); 12 | 13 | if (routingContext.getAllInstructions().some(i => i.config.auth)) { 14 | if (!isLoggedIn) { 15 | this.auth.setInitialUrl(window.location.href); 16 | return next.cancel(new Redirect(loginRoute)); 17 | } 18 | } else if (isLoggedIn && routingContext.getAllInstructions().some(i => i.fragment === loginRoute)) { 19 | let loginRedirect = this.auth.getLoginRedirect(); 20 | return next.cancel(new Redirect(loginRedirect)); 21 | } 22 | 23 | return next(); 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /client/src/shared/services/data.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { HttpClient, json } from 'aurelia-fetch-client'; 3 | import { Globals } from './../globals'; 4 | 5 | @autoinject() 6 | export class DataService { 7 | _baseUrl: string = Globals.baseApiUrl; 8 | 9 | constructor(protected http: HttpClient) { 10 | this.http = http; 11 | } 12 | 13 | get baseUrl() { 14 | return this._baseUrl; 15 | } 16 | 17 | set baseUrl(url: string) { 18 | this._baseUrl = url; 19 | } 20 | 21 | create(resource: T): Promise { 22 | return this.http.fetch(`${this._baseUrl}`, { 23 | method: 'post', 24 | body: json(resource) 25 | }) 26 | .then(response => response.json()); 27 | } 28 | 29 | get(id: number): Promise { 30 | return this.http.fetch(`${this._baseUrl}/${id}`) 31 | .then(response => response.json()); 32 | } 33 | 34 | getAll(): Promise { 35 | return this.http.fetch(`${this._baseUrl}`) 36 | .then(response => response.json()); 37 | } 38 | 39 | delete(id: number): Promise { 40 | return this.http.fetch(`${this._baseUrl}/${id}`, { 41 | method: 'delete' 42 | }) 43 | .then(response => response.json()); 44 | } 45 | 46 | save(resource: T): Promise { 47 | if (resource.hasOwnProperty('id')) { 48 | return this.update(resource); 49 | } 50 | else { 51 | return this.create(resource); 52 | } 53 | } 54 | 55 | update(resource: T): Promise { 56 | if (!resource.hasOwnProperty('id')) { 57 | throw new Error('Error: your object is missing an id') 58 | } 59 | return this.http.fetch(`${this._baseUrl}/${resource['id']}`, { 60 | method: 'put', 61 | body: json(resource) 62 | }) 63 | .then(response => response.json()); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /client/src/shared/services/fetch-config.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-dependency-injection'; 2 | import { HttpClient } from 'aurelia-fetch-client'; 3 | import { AuthenticationService } from './authentication.service'; 4 | 5 | @autoinject() 6 | export class FetchConfigService { 7 | constructor(private httpClient: HttpClient, private authService: AuthenticationService) { } 8 | 9 | configure() { 10 | this.httpClient.configure(httpConfig => { 11 | httpConfig.withInterceptor(this.authService.tokenInterceptor); 12 | }); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /client/src/shared/services/popup.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-dependency-injection'; 2 | 3 | @autoinject() 4 | export class PopupService { 5 | config = {}; 6 | popupWindow; 7 | polling = null; 8 | redirectUri: string; 9 | 10 | constructor() { 11 | this.redirectUri = window.location.origin + '/' || window.location.protocol + '//' + window.location.host + '/'; 12 | } 13 | 14 | open(url: string, windowName: string, options: any) { 15 | const optionString = typeof options === 'string' ? options : this.convertJsonToDomString(options); 16 | this.popupWindow = window.open(url, windowName, optionString); 17 | if (this.popupWindow && this.popupWindow.focus) { 18 | this.popupWindow.focus(); 19 | } 20 | } 21 | 22 | /** 23 | * Convert a JSON object into a DOM String 24 | * Example: { height: 800, width: 600} into "height=800,width=600" 25 | */ 26 | convertJsonToDomString(options: any): string { 27 | let params = new URLSearchParams(); 28 | for (let key in options) { 29 | params.set(key, options[key]) 30 | } 31 | const queryParams = params.toString(); 32 | return queryParams.replace('&', ','); 33 | } 34 | 35 | eventListener() { 36 | let promise = new Promise((resolve, reject) => { 37 | this.popupWindow.addEventListener('loadstart', (event) => { 38 | if (event.url.indexOf(this.redirectUri) !== 0) { 39 | return; 40 | } 41 | 42 | let parser = document.createElement('a'); 43 | parser.href = event.url; 44 | 45 | if (parser.search || parser.hash) { 46 | let queryParams = new URLSearchParams(this.popupWindow.location.search); 47 | let hashParams = new URLSearchParams(this.popupWindow.location.hash); 48 | 49 | this.popupWindow.close(); 50 | resolve(queryParams.get('code') || hashParams.get('code')); 51 | return; 52 | } 53 | }); 54 | 55 | this.popupWindow.addEventListener('exit', () => { 56 | reject(new Error('Provider Popup was closed')); 57 | }); 58 | 59 | this.popupWindow.addEventListener('loaderror', () => { 60 | reject(new Error('Authorization Failed')); 61 | }); 62 | }); 63 | return promise; 64 | } 65 | 66 | pollPopup(): Promise { 67 | return new Promise((resolve, reject) => { 68 | this.polling = setInterval(() => { 69 | try { 70 | let documentOrigin = document.location.host; 71 | let popupWindowOrigin = this.popupWindow.location.host; 72 | 73 | if (popupWindowOrigin === documentOrigin && (this.popupWindow.location.search || this.popupWindow.location.hash)) { 74 | let queryParams = new URLSearchParams(this.popupWindow.location.search); 75 | let hashParams = new URLSearchParams(this.popupWindow.location.hash); 76 | console.log('queryParams::', queryParams.get('save')) 77 | this.popupWindow.close(); 78 | resolve(queryParams.get('code') || hashParams.get('code')); 79 | clearInterval(this.polling); 80 | return; 81 | } 82 | } catch (error) { 83 | // no-op 84 | } 85 | 86 | if (!this.popupWindow) { 87 | clearInterval(this.polling); 88 | reject(new Error('Provider Popup Blocked')); 89 | } else if (this.popupWindow.closed) { 90 | clearInterval(this.polling); 91 | reject(new Error('Problem poll popup')); 92 | } 93 | }, 35); 94 | }); 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /client/src/shared/services/storage.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-dependency-injection'; 2 | 3 | @autoinject() 4 | export class StorageService { 5 | storage: Storage; 6 | 7 | constructor() { 8 | this.storage = this._getStorage('localStorage'); 9 | } 10 | 11 | get(key): any { 12 | return this.storage.getItem(key); 13 | } 14 | 15 | set(key: string, value: any) { 16 | this.storage.setItem(key, value); 17 | } 18 | 19 | remove(key: string) { 20 | this.storage.removeItem(key); 21 | } 22 | 23 | _getStorage(type: string) { 24 | if (type === 'localStorage') { 25 | if ('localStorage' in window && window.localStorage !== null) { 26 | return localStorage; 27 | } 28 | throw new Error('Local Storage is disabled or unavailable.'); 29 | } else if (type === 'sessionStorage') { 30 | if ('sessionStorage' in window && window.sessionStorage !== null) { 31 | return sessionStorage; 32 | } 33 | throw new Error('Session Storage is disabled or unavailable.'); 34 | } 35 | 36 | throw new Error('Invalid storage type specified: ' + type); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /client/src/styles/bootstrap-social.scss: -------------------------------------------------------------------------------- 1 | $color-facebook: #3b5998; 2 | $color-github: #444; 3 | $color-google: #dd4b39; 4 | $color-linkedin: #007bb6; 5 | $color-microsoft: #2672ec; 6 | $color-twitter: #55acee; 7 | 8 | .btn-social { 9 | padding-left: 44px !important; 10 | text-align: left !important; 11 | } 12 | .btn-social.btn-lg { 13 | padding-left: 61px !important; 14 | text-align: left !important; 15 | } 16 | .btn-social.btn-sm i.fa { 17 | font-size: 1rem; 18 | } 19 | 20 | .btn-facebook, .btn-facebook:hover { 21 | color: #fff; 22 | background-color: $color-facebook; 23 | border-color: rgba(0,0,0,0.2); 24 | &:hover { 25 | background-color: darken($color-facebook, 10%); 26 | } 27 | } 28 | .btn-github, .btn-github:hover { 29 | color: #fff; 30 | background-color: $color-github; 31 | border-color: rgba(0,0,0,0.2); 32 | &:hover { 33 | background-color: darken($color-github, 10%); 34 | } 35 | } 36 | .btn-google, .btn-google:hover { 37 | color: #fff; 38 | background-color: $color-google; 39 | border-color: rgba(0,0,0,0.2); 40 | &:hover { 41 | background-color: darken($color-google, 10%); 42 | } 43 | } 44 | .btn-linkedin, .btn-linkedin:hover { 45 | color: #fff; 46 | background-color: $color-linkedin; 47 | border-color: rgba(0,0,0,0.2); 48 | &:hover { 49 | background-color: darken($color-linkedin, 10%); 50 | } 51 | } 52 | .btn-microsoft, .btn-microsoft:hover { 53 | color: #fff; 54 | background-color: $color-microsoft; 55 | border-color: rgba(0,0,0,0.2); 56 | &:hover { 57 | background-color: darken($color-microsoft, 10%); 58 | } 59 | } 60 | .btn-twitter, .btn-twitter:hover { 61 | color: #fff; 62 | background-color: $color-twitter; 63 | border-color: rgba(0,0,0,0.2); 64 | &:hover { 65 | background-color: darken($color-twitter, 10%); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /client/src/styles/bootstrap.scss: -------------------------------------------------------------------------------- 1 | $navbar-dark-color: rgba(#ffffff, .60); 2 | $navbar-dark-hover-color: rgba(#ffffff, .80); 3 | 4 | //@import "./variables"; 5 | @import "../../node_modules/bootstrap/scss/bootstrap"; 6 | -------------------------------------------------------------------------------- /client/src/styles/styles.scss: -------------------------------------------------------------------------------- 1 | @import "./bootstrap"; 2 | @import '@slickgrid-universal/common/dist/styles/sass/slickgrid-theme-bootstrap.scss'; 3 | 4 | $brand-primary: #3275B3 !default; 5 | 6 | label { 7 | display: inline-block; 8 | } 9 | 10 | .ms-drop { 11 | label { 12 | margin-bottom: 5px; 13 | } 14 | } 15 | .btn-group-xs > .btn, .btn-xs { 16 | padding : 6px 2px; 17 | font-size : .875rem; 18 | line-height : .5; 19 | border-radius : .2rem; 20 | margin: 0 2px; 21 | font-size: 12px; 22 | } 23 | 24 | body { 25 | margin: 0; 26 | } 27 | form { 28 | label { 29 | font-weight: bold; 30 | } 31 | ::placeholder { 32 | color: #c0c0c0 !important; 33 | } 34 | } 35 | .italic { 36 | font-style: italic; 37 | } 38 | .faded-60 { 39 | opacity: 0.6; 40 | } 41 | .faded { 42 | opacity: 0.2; 43 | } 44 | .faded:hover { 45 | opacity: 0.5; 46 | } 47 | .red { 48 | color: red; 49 | } 50 | .subtitle { 51 | font-size: 19px; 52 | font-style: italic; 53 | color: grey; 54 | margin-bottom: 10px; 55 | } 56 | 57 | .page-host { 58 | position: absolute; 59 | left: 0; 60 | right: 0; 61 | top: 56px; 62 | bottom: 0; 63 | overflow-x: hidden; 64 | overflow-y: auto; 65 | } 66 | 67 | @media print { 68 | .page-host { 69 | position: absolute; 70 | left: 10px; 71 | right: 0; 72 | top: 56px; 73 | bottom: 0; 74 | overflow-y: inherit; 75 | overflow-x: inherit; 76 | } 77 | } 78 | 79 | section { 80 | margin: 0 20px; 81 | } 82 | 83 | a:focus { 84 | outline: none; 85 | } 86 | 87 | .bd-navbar .navbar-nav .nav-link.active { 88 | font-weight: 500; 89 | color: #121314; 90 | } 91 | .navbar-right { 92 | padding-right: 15px 93 | } 94 | .navbar-nav li.loader { 95 | margin: 12px 24px 0 6px; 96 | } 97 | 98 | .no-selection { 99 | margin: 20px; 100 | } 101 | 102 | .btn { 103 | cursor: pointer; 104 | } 105 | .btn-group-vertical.padded { 106 | padding-top: 10px; 107 | } 108 | 109 | .contact-list { 110 | overflow-y: auto; 111 | border: 1px solid #ddd; 112 | padding: 10px; 113 | } 114 | 115 | .input-group-text.no-color { 116 | background-color: #FFFFFF; 117 | font-weight: bold; 118 | } 119 | 120 | .signup-or-separator span { 121 | padding: 5px; 122 | } 123 | .signup-or-separator span:nth-child(1) { 124 | margin-left:5px; 125 | } 126 | .signup-or-separator .text { 127 | top: 5px; 128 | font-weight: 500; 129 | } 130 | .panel { 131 | margin: 20px; 132 | } 133 | 134 | .button-bar { 135 | right: 0; 136 | left: 0; 137 | bottom: 0; 138 | border-top: 1px solid #ddd; 139 | background: white; 140 | } 141 | 142 | .button-bar > button { 143 | float: right; 144 | margin: 20px; 145 | } 146 | 147 | li.list-group-item { 148 | list-style: none; 149 | } 150 | 151 | li.list-group-item > a { 152 | text-decoration: none; 153 | } 154 | 155 | li.list-group-item.active > a { 156 | color: white; 157 | } 158 | 159 | .pointer { 160 | cursor: pointer; 161 | } 162 | 163 | /* nprogress color change with brand-primary */ 164 | $nprogress-color: lighten($brand-primary, 30%); 165 | 166 | /* Make clicks pass-through */ 167 | #nprogress .bar { 168 | background: $nprogress-color; 169 | height: 5px; 170 | } 171 | 172 | /* Fancy blur effect */ 173 | #nprogress .peg { 174 | box-shadow: 0 0 10px $nprogress-color, 0 0 5px $nprogress-color; 175 | } 176 | 177 | #nprogress .spinner-icon { 178 | width: 24px; 179 | height: 24px; 180 | border-top-color: $nprogress-color; 181 | border-left-color: $nprogress-color; 182 | } 183 | -------------------------------------------------------------------------------- /client/src/users/user.interface.ts: -------------------------------------------------------------------------------- 1 | export interface User { 2 | readonly userId: string; 3 | readonly displayName: string; 4 | readonly email: string; 5 | readonly picture: string; 6 | readonly provider: string; 7 | readonly providers: [{ id: string; name: string; }]; 8 | readonly roles?: string[]; 9 | readonly facebook?: string; 10 | readonly github?: string; 11 | readonly google?: string; 12 | readonly linkedin?: string; 13 | readonly live?: string; 14 | readonly microsoft?: string; 15 | readonly twitter?: string; 16 | readonly windowslive?: string; 17 | } 18 | -------------------------------------------------------------------------------- /client/src/users/users-data.service.ts: -------------------------------------------------------------------------------- 1 | import { autoinject } from 'aurelia-framework'; 2 | import { HttpClient, json } from 'aurelia-fetch-client'; 3 | 4 | import { Globals } from '../shared/globals'; 5 | 6 | @autoinject() 7 | export class UsersDataService { 8 | constructor(protected http: HttpClient) { 9 | this.http = http; 10 | } 11 | 12 | getUsers(query: string): Promise { 13 | return new Promise(async resolve => { 14 | const response = await this.http.fetch(Globals.baseGraphQlUrl, { 15 | method: 'post', 16 | body: json({ query }) 17 | }); 18 | resolve(response.json()); 19 | }); 20 | } 21 | 22 | async getAll(): Promise { 23 | const response = await this.http.fetch(Globals.baseGraphQlUrl, { 24 | method: 'post', 25 | body: json({ 26 | query: `query { users { userId, displayName, email, picture, roles, 27 | facebook, google, github, linkedin, live, microsoft, windowslive, twitter }}` 28 | }) 29 | }); 30 | return await response.json(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /client/src/users/users-list.html: -------------------------------------------------------------------------------- 1 | 12 | -------------------------------------------------------------------------------- /client/src/users/users-list.scss: -------------------------------------------------------------------------------- 1 | .fa-border { 2 | border: solid 1px #84b9fd; 3 | border-radius: 3px; 4 | } 5 | .fa-noborder { 6 | border: solid 1px transparent; 7 | } 8 | -------------------------------------------------------------------------------- /client/src/welcome.html: -------------------------------------------------------------------------------- 1 | 21 | -------------------------------------------------------------------------------- /client/src/welcome.ts: -------------------------------------------------------------------------------- 1 | //import {computedFrom} from 'aurelia-framework'; 2 | 3 | export class Welcome { 4 | heading: string = 'Welcome to the Aurelia Navigation App'; 5 | firstName: string = 'John'; 6 | lastName: string = 'Doe'; 7 | previousValue: string = this.fullName; 8 | 9 | //Getters can't be directly observed, so they must be dirty checked. 10 | //However, if you tell Aurelia the dependencies, it no longer needs to dirty check the property. 11 | //To optimize by declaring the properties that this getter is computed from, uncomment the line below 12 | //as well as the corresponding import above. 13 | //@computedFrom('firstName', 'lastName') 14 | get fullName(): string { 15 | return `${this.firstName} ${this.lastName}`; 16 | } 17 | 18 | submit() { 19 | this.previousValue = this.fullName; 20 | alert(`Welcome, ${this.fullName}!`); 21 | } 22 | 23 | canDeactivate(): boolean | undefined { 24 | if (this.fullName !== this.previousValue) { 25 | return confirm('Are you sure you want to leave?'); 26 | } 27 | } 28 | } 29 | 30 | export class UpperValueConverter { 31 | toView(value: string): string { 32 | return value && value.toUpperCase(); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/test/e2e/demo.e2e.ts: -------------------------------------------------------------------------------- 1 | import {PageObject_Welcome} from './welcome.po'; 2 | import {PageObject_Skeleton} from './skeleton.po'; 3 | import {browser, element, by, By, $, $$, ExpectedConditions} from 'aurelia-protractor-plugin/protractor'; 4 | import {config} from '../protractor.conf'; 5 | 6 | describe('aurelia skeleton app', function() { 7 | let poWelcome: PageObject_Welcome; 8 | let poSkeleton: PageObject_Skeleton; 9 | 10 | beforeEach(async () => { 11 | poSkeleton = new PageObject_Skeleton(); 12 | poWelcome = new PageObject_Welcome(); 13 | 14 | await browser.loadAndWaitForAureliaPage(`http://localhost:${config.port}`); 15 | }); 16 | 17 | it('should load the page and display the initial page title', async () => { 18 | await expect(await poSkeleton.getCurrentPageTitle()).toBe('Aurelia Navigation Skeleton'); 19 | }); 20 | 21 | it('should display greeting', async () => { 22 | await expect(await poWelcome.getGreeting()).toBe('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /client/test/e2e/skeleton.po.ts: -------------------------------------------------------------------------------- 1 | import {browser, element, by, By, $, $$, ExpectedConditions} from 'aurelia-protractor-plugin/protractor'; 2 | 3 | export class PageObject_Skeleton { 4 | getCurrentPageTitle() { 5 | return browser.getTitle(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/test/e2e/welcome.po.ts: -------------------------------------------------------------------------------- 1 | import {browser, element, by, By, $, $$, ExpectedConditions} from 'aurelia-protractor-plugin/protractor'; 2 | 3 | export class PageObject_Welcome { 4 | getGreeting() { 5 | return element(by.tagName('h1')).getText(); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/test/jest-pretest.ts: -------------------------------------------------------------------------------- 1 | import 'aurelia-polyfills'; 2 | import {Options} from 'aurelia-loader-nodejs'; 3 | import {globalize} from 'aurelia-pal-nodejs'; 4 | import * as path from 'path'; 5 | Options.relativeToDir = path.join(__dirname, 'unit'); 6 | globalize(); 7 | -------------------------------------------------------------------------------- /client/test/protractor.conf.js: -------------------------------------------------------------------------------- 1 | const port = 19876; 2 | 3 | exports.config = { 4 | port: port, 5 | 6 | baseUrl: `http://localhost:${port}/`, 7 | 8 | // use `npm start -- e2e` 9 | 10 | specs: [ 11 | '**/*.e2e.ts' 12 | ], 13 | 14 | exclude: [], 15 | 16 | framework: 'jasmine', 17 | 18 | allScriptsTimeout: 110000, 19 | 20 | jasmineNodeOpts: { 21 | showTiming: true, 22 | showColors: true, 23 | isVerbose: true, 24 | includeStackTrace: false, 25 | defaultTimeoutInterval: 900000 26 | }, 27 | 28 | SELENIUM_PROMISE_MANAGER: false, 29 | 30 | directConnect: true, 31 | 32 | capabilities: { 33 | 'browserName': 'chrome', 34 | 'chromeOptions': { 35 | 'args': [ 36 | '--show-fps-counter', 37 | '--no-default-browser-check', 38 | '--no-first-run', 39 | '--disable-default-apps', 40 | '--disable-popup-blocking', 41 | '--disable-translate', 42 | '--disable-background-timer-throttling', 43 | '--disable-renderer-backgrounding', 44 | '--disable-device-discovery-notifications', 45 | /* enable these if you'd like to test using Chrome Headless 46 | '--no-gpu', 47 | '--headless' 48 | */ 49 | ] 50 | } 51 | }, 52 | 53 | onPrepare: function () { 54 | require('ts-node').register({ compilerOptions: { module: 'commonjs' }, disableWarnings: true, fast: true }); 55 | }, 56 | 57 | plugins: [{ 58 | package: 'aurelia-protractor-plugin' 59 | }], 60 | }; 61 | -------------------------------------------------------------------------------- /client/test/unit/app.spec.ts: -------------------------------------------------------------------------------- 1 | import {App} from '../../src/app'; 2 | 3 | describe('the app', () => { 4 | it('says hello', () => { 5 | // expect(new App().message).toBe('Hello World!'); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compileOnSave": false, 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "skipLibCheck": true, 6 | "typeRoots": [ 7 | "./node_modules/@types" 8 | ], 9 | "removeComments": true, 10 | "emitDecoratorMetadata": true, 11 | "experimentalDecorators": true, 12 | "sourceMap": true, 13 | "target": "es5", 14 | "lib": [ 15 | "es2015", 16 | "dom" 17 | ], 18 | "moduleResolution": "node", 19 | "baseUrl": "src", 20 | "resolveJsonModule": true, 21 | "allowJs": true 22 | }, 23 | "include": [ 24 | "./src/**/*.ts", 25 | "./test/**/*.ts", 26 | "./types/**/*.d.ts" 27 | ], 28 | "atom": { 29 | "rewriteTsconfig": false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "rules": { 3 | 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /server/.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | charset = utf-8 6 | end_of_line = lf 7 | indent_style = space 8 | indent_size = 4 9 | insert_final_newline = true 10 | trim_trailing_whitespace = true 11 | 12 | [*] 13 | indent_style = space 14 | indent_size = 2 15 | 16 | [*.md] 17 | max_line_length = 0 18 | trim_trailing_whitespace = false 19 | -------------------------------------------------------------------------------- /server/.env.dev: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | -------------------------------------------------------------------------------- /server/.env.prod: -------------------------------------------------------------------------------- 1 | PORT=3000 2 | -------------------------------------------------------------------------------- /server/.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "./tsconfig.json", 5 | "sourceType": "module" 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint/eslint-plugin" 9 | ], 10 | "extends": [ 11 | "plugin:@typescript-eslint/eslint-recommended", 12 | "plugin:@typescript-eslint/recommended", 13 | "prettier", 14 | "prettier/@typescript-eslint" 15 | ], 16 | "root": true, 17 | "env": { 18 | "node": true, 19 | "jest": true 20 | }, 21 | "rules": { 22 | "@typescript-eslint/interface-name-prefix": "off", 23 | "@typescript-eslint/explicit-function-return-type": "off", 24 | "@typescript-eslint/explicit-module-boundary-types": "off", 25 | "@typescript-eslint/no-explicit-any": "off", 26 | "@typescript-eslint/no-unused-vars": "off", 27 | "no-unused-vars": "off" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | 5 | # Logs 6 | logs 7 | *.log 8 | npm-debug.log* 9 | yarn-debug.log* 10 | yarn-error.log* 11 | lerna-debug.log* 12 | 13 | # OS 14 | .DS_Store 15 | 16 | # Tests 17 | /coverage 18 | /.nyc_output 19 | 20 | # IDEs and editors 21 | /.idea 22 | .project 23 | .classpath 24 | .c9/ 25 | *.launch 26 | .settings/ 27 | *.sublime-workspace 28 | 29 | # IDE - VSCode 30 | .vscode/* 31 | !.vscode/settings.json 32 | !.vscode/tasks.json 33 | !.vscode/launch.json 34 | !.vscode/extensions.json 35 | 36 | # remove auth config with personal keys 37 | auth-config.development.ts 38 | 39 | ## Remove NPM/Yarn logs and lock files 40 | npm-debug.log 41 | yarn-error.log 42 | 43 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /server/.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 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug Nest Framework", 11 | "args": [ 12 | "${workspaceFolder}/src/main.ts" 13 | ], 14 | "runtimeArgs": [ 15 | "--nolazy", 16 | "-r", 17 | "ts-node/register" 18 | ], 19 | "sourceMaps": true, 20 | "cwd": "${workspaceRoot}", 21 | "protocol": "inspector" 22 | }, 23 | { 24 | "type": "node", 25 | "request": "attach", 26 | "name": "Attach NestJS WS", 27 | "port": 9229, 28 | "restart": true, 29 | "stopOnEntry": false, 30 | "protocol": "inspector" 31 | } 32 | ] 33 | } 34 | -------------------------------------------------------------------------------- /server/.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "label": "NestJS Dev (server)", 6 | "command": "yarn", 7 | "type": "shell", 8 | "args": [ 9 | "start:dev" 10 | ], 11 | "problemMatcher": [] 12 | }, 13 | { 14 | "label": "NestJS Debug (server)", 15 | "command": "yarn", 16 | "type": "shell", 17 | "args": [ 18 | "start:debug" 19 | ], 20 | "problemMatcher": [] 21 | } 22 | ] 23 | } 24 | -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Aurelia / NestJS / MongoDB / GraphQL and OAuth 2 | Full stack boilerplate with Aurelia, NestJS, GraphQL, MongoDB and OAuth login. 3 | 4 | ### Backend Server installation (NestJS) 5 | To install and start the backend server (NestJS), go into the server folder and run `npm start` (make sure you ran the `npm install` first) 6 | ```bash 7 | cd aurelia-nest-auth-mongodb/server 8 | npm install # or: yarn install 9 | npm start # or: yarn start 10 | ``` 11 | 12 | ### Running the App 13 | The simplest way of running the App is to use the VSCode tasks there were created **Aurelia (client)** and **NestJS Dev (server)** (or _NestJS Debug (server)_ if you wish to debug your code with NestJS) 14 | 15 | The second way would be to type the shell command `yarn start` in both `client` and `server` folders. 16 | ```bash 17 | npm start # or: yarn start 18 | ``` 19 | 20 | ### OAuth 21 | For the OAuth to work, we use Passport and you will need to rename a file and configure your keys to get going. Here are the steps 22 | 1. rename [server/src/auth/auth-config.development.template.ts](https://github.com/ghiscoding/aurelia-nest-auth-mongodb/blob/master/server/src/auth/auth-config.development.template.ts) to `server/src/auth/auth-config.development.ts` 23 | 2. open the file and change all necessary `clientID` and `clientSecret` properties then save the file. 24 | 3. run the project 25 | 26 | ## License 27 | MIT 28 | 29 | ## Getting started 30 | 31 | Before you start, make sure you have a recent version of [NodeJS](http://nodejs.org/) environment *>=10.0* with NPM 6 or Yarn. 32 | 33 | From the project folder, execute the following commands: 34 | 35 | ```shell 36 | npm install # or: yarn install 37 | ``` 38 | 39 | This will install all required dependencies, including a local version of Webpack that is going to 40 | build and bundle the app. There is no need to install Webpack globally. 41 | 42 | To run the app execute the following command: 43 | 44 | ```shell 45 | npm start # or: yarn start 46 | ``` 47 | 48 | ### GraphQL 49 | After installing and starting the server you should be able to see your GraphQL playground on http://localhost:3000/graphql. 50 | You can see if it works by typing the following in the query window 51 | ```ts 52 | { 53 | hello 54 | } 55 | ``` 56 | Also note that most of the GraphQL query are protected and cannot be run directly in the GraphQL playground unless you use the JWT token. 57 | 58 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "language": "ts", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "plugins": [ 7 | "@nestjs/swagger/plugin" 8 | ] 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nest-graph-mongo", 3 | "version": "0.0.1", 4 | "description": "", 5 | "author": "", 6 | "license": "MIT", 7 | "scripts": { 8 | "format": "prettier --write \"src/**/*.ts\"", 9 | "build": "nest build", 10 | "start": "nest start", 11 | "start:dev": "cross-env NODE_ENV=dev nest start --watch", 12 | "start:debug": "cross-env NODE_ENV=dev nest start --debug --watch", 13 | "start:prod": "cross-env NODE_ENV=prod node dist/main.js", 14 | "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", 15 | "test": "jest", 16 | "test:watch": "jest --watch", 17 | "test:cov": "jest --coverage", 18 | "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand", 19 | "test:e2e": "jest --config ./test/jest-e2e.json" 20 | }, 21 | "dependencies": { 22 | "@nestjs/apollo": "^10.0.9", 23 | "@nestjs/common": "^8.4.4", 24 | "@nestjs/config": "^2.0.0", 25 | "@nestjs/core": "^8.4.4", 26 | "@nestjs/graphql": "^10.0.10", 27 | "@nestjs/jwt": "^8.0.0", 28 | "@nestjs/mapped-types": "^1.0.1", 29 | "@nestjs/mongoose": "^9.0.3", 30 | "@nestjs/passport": "^8.2.1", 31 | "@nestjs/platform-express": "^8.4.4", 32 | "@nestjs/swagger": "^5.2.1", 33 | "apollo-server": "3.6.7", 34 | "apollo-server-express": "^3.6.7", 35 | "bcryptjs": "^2.4.3", 36 | "class-transformer": "^0.5.1", 37 | "class-validator": "^0.14.0", 38 | "dotenv": "^16.0.0", 39 | "graphql": "^16.8.1", 40 | "graphql-tools": "^8.2.8", 41 | "jsonwebtoken": "^9.0.0", 42 | "mongoose": "^6.13.6", 43 | "nest-access-control": "^2.2.0", 44 | "oauth": "^0.9.15", 45 | "passport": "^0.6.0", 46 | "passport-azure-ad": "^4.3.4", 47 | "passport-facebook": "^3.0.0", 48 | "passport-github2": "^0.1.12", 49 | "passport-google-oauth20": "^2.0.0", 50 | "passport-jwt": "^4.0.0", 51 | "passport-linkedin-oauth2": "^2.0.0", 52 | "passport-local": "^1.0.0", 53 | "passport-microsoft": "^1.0.0", 54 | "passport-twitter": "^1.0.4", 55 | "passport-twitter-oauth2": "^2.1.1", 56 | "passport-windowslive": "^1.0.2", 57 | "reflect-metadata": "^0.1.13", 58 | "rimraf": "^3.0.2", 59 | "rxjs": "^7.5.5", 60 | "saslprep": "^1.0.3", 61 | "swagger-ui-express": "^4.5.0" 62 | }, 63 | "devDependencies": { 64 | "@nestjs/cli": "^8.2.5", 65 | "@nestjs/testing": "^8.4.4", 66 | "@types/bcryptjs": "^2.4.2", 67 | "@types/express": "^4.17.13", 68 | "@types/jest": "^27.4.1", 69 | "@types/node": "^17.0.30", 70 | "@types/supertest": "^2.0.12", 71 | "@typescript-eslint/eslint-plugin": "^5.21.0", 72 | "@typescript-eslint/parser": "^5.21.0", 73 | "cross-env": "^7.0.3", 74 | "eslint": "^8.14.0", 75 | "eslint-config-prettier": "^8.5.0", 76 | "eslint-plugin-import": "^2.26.0", 77 | "jest": "^27.5.1", 78 | "prettier": "^2.6.2", 79 | "supertest": "^6.2.3", 80 | "ts-jest": "^27.1.4", 81 | "ts-node": "^10.7.0", 82 | "tsc-watch": "^5.0.3", 83 | "tsconfig-paths": "^3.14.1", 84 | "typescript": "^4.6.4" 85 | }, 86 | "engines": { 87 | "node": ">=14.17.0", 88 | "npm": ">=6.14.8" 89 | }, 90 | "jest": { 91 | "moduleFileExtensions": [ 92 | "js", 93 | "json", 94 | "ts" 95 | ], 96 | "rootDir": "src", 97 | "testRegex": ".spec.ts$", 98 | "transform": { 99 | "^.+\\.(t|j)s$": "ts-jest" 100 | }, 101 | "coverageDirectory": "../coverage", 102 | "testEnvironment": "node" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /server/schema.gql: -------------------------------------------------------------------------------- 1 | # ------------------------------------------------------ 2 | # THIS FILE WAS AUTOMATICALLY GENERATED (DO NOT MODIFY) 3 | # ------------------------------------------------------ 4 | 5 | type Owner { 6 | id: ID! 7 | displayName: String! 8 | email: String! 9 | } 10 | 11 | type Cat { 12 | id: ID! 13 | name: String! 14 | age: Int! 15 | breed: String! 16 | owner: Owner 17 | } 18 | 19 | type PageInfo { 20 | hasNextPage: Boolean 21 | hasPreviousPage: Boolean 22 | endCursor: String 23 | startCursor: String 24 | } 25 | 26 | type Provider { 27 | id: ID! 28 | providerId: String! 29 | name: String! 30 | } 31 | 32 | type User { 33 | id: ID! 34 | userId: ID! 35 | displayName: String! 36 | email: String! 37 | picture: String 38 | provider: String 39 | providers: [Provider!] 40 | roles: [String!]! 41 | facebook: String 42 | github: String 43 | google: String 44 | linkedin: String 45 | live: String 46 | microsoft: String 47 | twitter: String 48 | windowslive: String 49 | } 50 | 51 | type Query { 52 | hello: String! 53 | author(id: Int!): Cat! 54 | cats(filterBy: [FilterByCatFields!], orderBy: [OrderByCatFields!]): [Cat!]! 55 | users(filterBy: [FilterByString!], orderBy: [OrderByString!], first: Int!, offset: Int!, cursor: String): PaginatedResponseClass! 56 | whoAmI: User! 57 | } 58 | 59 | input FilterByCatFields { 60 | field: CatFields! 61 | operator: Operator! 62 | value: String! 63 | } 64 | 65 | """The list of Cat Fields""" 66 | enum CatFields { 67 | id 68 | name 69 | age 70 | breed 71 | } 72 | 73 | """Possible filter operators""" 74 | enum Operator { 75 | LT 76 | LE 77 | GT 78 | GE 79 | NE 80 | EQ 81 | IN 82 | NIN 83 | StartsWith 84 | EndsWith 85 | Contains 86 | } 87 | 88 | input OrderByCatFields { 89 | field: CatFields! 90 | direction: Direction! 91 | } 92 | 93 | """The orderBy directions""" 94 | enum Direction { 95 | ASC 96 | DESC 97 | } 98 | 99 | type PaginatedResponseClass { 100 | nodes: [User!]! 101 | totalCount: Int! 102 | pageInfo: PageInfo! 103 | } 104 | 105 | input FilterByString { 106 | field: String! 107 | operator: Operator! 108 | value: String! 109 | } 110 | 111 | input OrderByString { 112 | field: String! 113 | direction: Direction! 114 | } 115 | 116 | type Mutation { 117 | createCat(input: CatInput!): Cat! 118 | } 119 | 120 | input CatInput { 121 | name: String! 122 | age: Int! 123 | breed: String! 124 | ownerId: String! 125 | } -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Request } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor( 7 | private readonly appService: AppService, 8 | ) { } 9 | 10 | @Get() 11 | root(@Request() req): string { 12 | return this.appService.root(); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo'; 3 | import { GraphQLModule } from '@nestjs/graphql'; 4 | import { ConfigModule } from '@nestjs/config'; 5 | import { MongooseModule } from '@nestjs/mongoose'; 6 | import { AppController } from './app.controller'; 7 | import { AppService } from './app.service'; 8 | 9 | import { AuthModule } from './auth/auth.module'; 10 | import { CatsModule } from './cats/cats.module'; 11 | import { UsersModule } from './users/users.module'; 12 | import { CommonModule } from './shared/common.module'; 13 | 14 | @Module({ 15 | imports: [ 16 | AuthModule, 17 | CatsModule, 18 | // CommonModule, 19 | ConfigModule.forRoot({ 20 | isGlobal: true, 21 | envFilePath: process.env.NODE_ENV === 'dev' ? '.env.dev' : '.env.prod', 22 | // ignoreEnvFile: process.env.NODE_ENV === 'prod' 23 | }), 24 | GraphQLModule.forRoot({ 25 | driver: ApolloDriver, 26 | autoSchemaFile: 'schema.gql', 27 | debug: true, 28 | context: ({ req }) => ({ req }), 29 | }), 30 | MongooseModule.forRoot('mongodb://localhost:27017/nest'), 31 | UsersModule, 32 | ], 33 | controllers: [AppController], 34 | providers: [ 35 | AppService, 36 | ], 37 | }) 38 | export class AppModule { } 39 | -------------------------------------------------------------------------------- /server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AppService { 5 | root(): string { 6 | return 'Hello World!!'; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/auth/auth-config.development.template.ts: -------------------------------------------------------------------------------- 1 | const authConfig = { 2 | callbackSuccessUrl: 'http://localhost:9000/login/success', 3 | callbackFailureUrl: 'http://localhost:9000/login/failure', 4 | jwtSecretKey: 'A hard to guess string', 5 | providers: { 6 | facebook: { 7 | // https://developers.facebook.com/ 8 | clientID: 'Facebook Client Id', 9 | clientSecret: 'Facebook Client Secret', 10 | callbackURL: 'http://localhost:3000/auth/facebook/callback', 11 | }, 12 | github: { 13 | // https://github.com/settings/developers 14 | clientID: 'GitHub Client Id', 15 | clientSecret: 'GitHub Client Secret', 16 | callbackURL: 'http://localhost:3000/auth/github/callback', 17 | }, 18 | google: { 19 | // https://console.developers.google.com/ 20 | // OR https://console.cloud.google.com/ 21 | clientID: 'Google Client Id', 22 | clientSecret: 'Google Client Secret', 23 | callbackURL: 'http://localhost:3000/auth/google/callback', 24 | }, 25 | linkedin: { 26 | // https://www.linkedin.com/developers/ 27 | clientID: 'LinkedIn Client Id', 28 | clientSecret: 'LinkedIn Client Secret', 29 | callbackURL: 'http://localhost:3000/auth/linkedin/callback', 30 | }, 31 | twitter: { 32 | // https://developer.twitter.com/ 33 | clientID: 'Twitter Client Id', 34 | clientSecret: 'Twitter Client Secret', 35 | callbackURL: 'http://localhost:3000/auth/twitter/callback', 36 | }, 37 | windowslive: { 38 | // Windows Live Account Strategy 39 | // https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps 40 | clientID: 'Windows Live Client Id', 41 | clientSecret: 'Windows Live Client Secret', 42 | callbackURL: 'http://localhost:3000/auth/windowslive/callback', 43 | }, 44 | microsoft: { 45 | // Similar to Windows Live but with Microsoft Graph (API) Strategy 46 | // https://portal.azure.com/#blade/Microsoft_AAD_IAM/ActiveDirectoryMenuBlade/RegisteredApps 47 | clientID: 'Microsoft Graph Client Id', 48 | clientSecret: 'Microsoft Graph Client Secret', 49 | callbackURL: 'http://localhost:3000/auth/microsoft/callback', 50 | }, 51 | }, 52 | }; 53 | 54 | export default authConfig; 55 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('Auth Controller', () => { 5 | let module: TestingModule; 6 | beforeAll(async () => { 7 | module = await Test.createTestingModule({ 8 | controllers: [AuthController], 9 | }).compile(); 10 | }); 11 | it('should be defined', () => { 12 | const controller: AuthController = module.get(AuthController); 13 | expect(controller).toBeDefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { JwtModule } from '@nestjs/jwt'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | import { PassportModule } from '@nestjs/passport'; 5 | 6 | import { AuthController } from './auth.controller'; 7 | import { AuthService } from './services/auth.service'; 8 | import { GithubStrategy } from './strategies/github.strategy'; 9 | import { GoogleStrategy } from './strategies/google.strategy'; 10 | import { JwtStrategy } from './strategies/jwt.strategy'; 11 | import { FacebookService } from './services/facebook.service'; 12 | import { FacebookStrategy } from './strategies/facebook.strategy'; 13 | import { LinkedInStrategy } from './strategies/linkedin.strategy'; 14 | import { MicrosoftStrategy } from './strategies/microsoft.strategy'; 15 | import { TwitterStrategy } from './strategies/twitter.strategy'; 16 | import { UserService } from './services/user.service'; 17 | import { WindowsliveService } from './services/windowslive.service'; 18 | import { WindowsliveStrategy } from './strategies/windowslive.strategy'; 19 | 20 | import authConfig from './auth-config.development'; 21 | import { LocalStrategy } from './strategies/local.strategy'; 22 | import { UserSchema } from '../shared/schemas/user.schema'; 23 | 24 | @Module({ 25 | imports: [ 26 | PassportModule.register({ 27 | defaultStrategy: 'jwt', 28 | property: 'user', 29 | session: false, 30 | }), 31 | JwtModule.register({ 32 | secret: authConfig.jwtSecretKey, 33 | signOptions: { expiresIn: '7d' }, 34 | }), 35 | MongooseModule.forFeature([{ name: 'User', schema: UserSchema }]), 36 | ], 37 | controllers: [AuthController], 38 | providers: [ 39 | AuthService, 40 | FacebookStrategy, 41 | FacebookService, 42 | GithubStrategy, 43 | GoogleStrategy, 44 | JwtStrategy, 45 | LocalStrategy, 46 | LinkedInStrategy, 47 | MicrosoftStrategy, 48 | TwitterStrategy, 49 | UserService, 50 | WindowsliveStrategy, 51 | WindowsliveService, 52 | ], 53 | exports: [ 54 | AuthService, 55 | UserService, 56 | WindowsliveService, 57 | ], 58 | }) 59 | export class AuthModule { } 60 | -------------------------------------------------------------------------------- /server/src/auth/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './user.dto'; 2 | export * from './username.dto'; 3 | export * from './userLogin.dto'; 4 | export * from './userSignup.dto'; 5 | export * from './token.dto'; 6 | -------------------------------------------------------------------------------- /server/src/auth/dto/token.dto.ts: -------------------------------------------------------------------------------- 1 | export class TokenDto { 2 | readonly token: string; 3 | } 4 | -------------------------------------------------------------------------------- /server/src/auth/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEmail, IsOptional, MinLength } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserDto { 5 | @IsEmail() 6 | @ApiProperty({ example: 'someone@company.com', description: 'User\'s Email', type: () => 'string' }) 7 | readonly email: { type: string, lowercase: true }; 8 | 9 | @IsString() 10 | @MinLength(5) 11 | @ApiProperty({ description: 'User\'s Password (only applies when using username/password)', type: () => 'string' }) 12 | readonly password?: string; 13 | 14 | @IsString() 15 | @ApiProperty({ description: 'User\'s Display Name to use in the UI', type: () => 'string' }) 16 | readonly displayName: string; 17 | 18 | readonly id?: string; 19 | 20 | @ApiProperty({ description: 'User Id (when defined)', type: () => 'string' }) 21 | readonly userId?: string; 22 | 23 | @IsOptional() 24 | @ApiProperty({ description: 'User\'s Profile Picture URL', type: () => 'string' }) 25 | readonly picture?: string; 26 | 27 | @IsOptional() 28 | @ApiProperty({ description: 'User\'s Original OAuth2 Provider', type: () => 'string' }) 29 | readonly provider?: string; 30 | 31 | @ApiProperty({ description: 'User\'s Role(s)' }) 32 | readonly roles: string[]; 33 | 34 | @IsOptional() 35 | @ApiProperty({ description: 'User\'s Username (only applies when using username/password)' }) 36 | readonly username?: string; 37 | 38 | @IsOptional() 39 | @ApiProperty({ description: 'Facebook User Id when using OAuth2 to Login' }) 40 | readonly facebook?: string; 41 | 42 | @IsOptional() 43 | @ApiProperty({ description: 'GitHub User Id when using OAuth2 to Login' }) 44 | readonly github?: string; 45 | 46 | @IsOptional() 47 | @ApiProperty({ description: 'Google User Id when using OAuth2 to Login' }) 48 | readonly google?: string; 49 | 50 | @IsOptional() 51 | @ApiProperty({ description: 'LinkedIn User Id when using OAuth2 to Login' }) 52 | readonly linkedin?: string; 53 | 54 | @IsOptional() 55 | @ApiProperty({ description: 'Microsoft Windows Live User Id when using OAuth2 to Login' }) 56 | readonly live?: string; 57 | 58 | @IsOptional() 59 | @ApiProperty({ description: 'Microsoft User Id when using OAuth2 to Login' }) 60 | readonly microsoft?: string; 61 | 62 | @IsOptional() 63 | @ApiProperty({ description: 'Twitter User Id when using OAuth2 to Login' }) 64 | readonly twitter?: string; 65 | 66 | @IsOptional() 67 | @ApiProperty({ description: 'Microsoft Windows Live User Id when using OAuth2 to Login' }) 68 | readonly windowslive?: string; 69 | } 70 | -------------------------------------------------------------------------------- /server/src/auth/dto/userLogin.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEmail, MinLength } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserLoginDto { 5 | @IsEmail() 6 | @ApiProperty({ example: 'someone@company.com', description: 'User\'s Email', type: () => 'string' }) 7 | readonly username: { type: string, lowercase: true }; 8 | 9 | @IsString() 10 | @MinLength(5) 11 | @ApiProperty({ description: 'User\'s Password (only applies when using username/password)', type: () => 'string' }) 12 | readonly password: string; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/auth/dto/userSignup.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsString, IsEmail, MinLength } from 'class-validator'; 2 | import { ApiProperty } from '@nestjs/swagger'; 3 | 4 | export class UserSignupDto { 5 | @IsEmail() 6 | @ApiProperty({ example: 'someone@company.com', description: 'User\'s Email', type: () => 'string' }) 7 | readonly email: { type: string, lowercase: true }; 8 | 9 | @IsString() 10 | @MinLength(5) 11 | @ApiProperty({ description: 'User\'s Password (only applies when using username/password)', type: () => 'string' }) 12 | readonly password: string; 13 | 14 | @IsString() 15 | @ApiProperty({ description: 'User\'s Display Name to use in the UI', type: () => 'string' }) 16 | readonly displayName: string; 17 | 18 | readonly id?: string; 19 | 20 | @ApiProperty({ description: 'User Id when defined', type: () => 'string' }) 21 | readonly userId?: string; 22 | } 23 | -------------------------------------------------------------------------------- /server/src/auth/dto/username.dto.ts: -------------------------------------------------------------------------------- 1 | export class UsernameDto { 2 | readonly username: string; 3 | } 4 | -------------------------------------------------------------------------------- /server/src/auth/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './provider.interface'; 2 | export * from './user.interface'; 3 | -------------------------------------------------------------------------------- /server/src/auth/models/provider.interface.ts: -------------------------------------------------------------------------------- 1 | export class Provider { 2 | providerId: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/auth/models/user.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | import { Document } from 'mongoose'; 3 | import { Provider } from './provider.interface'; 4 | 5 | export interface User extends Document { 6 | readonly userId?: string; 7 | readonly email: { type: string, lowercase: true }; 8 | readonly displayName: string; 9 | readonly picture?: string; 10 | readonly provider?: string; 11 | readonly providers?: Provider[]; 12 | readonly roles?: string[]; 13 | readonly username?: string; 14 | readonly password?: string; 15 | readonly facebook?: string; 16 | readonly github?: string; 17 | readonly google?: string; 18 | readonly linkedin?: string; 19 | readonly live?: string; 20 | readonly microsoft?: string; 21 | readonly twitter?: string; 22 | readonly windowslive?: string; 23 | } 24 | -------------------------------------------------------------------------------- /server/src/auth/services/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | beforeAll(async () => { 7 | const module: TestingModule = await Test.createTestingModule({ 8 | providers: [AuthService], 9 | }).compile(); 10 | service = module.get(AuthService); 11 | }); 12 | it('should be defined', () => { 13 | expect(service).toBeDefined(); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /server/src/auth/services/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, InternalServerErrorException } from '@nestjs/common'; 2 | import { JwtService } from '@nestjs/jwt'; 3 | import * as bcrypt from 'bcryptjs'; 4 | import { sign } from 'jsonwebtoken'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { UserService } from './user.service'; 8 | import { User } from '../models'; 9 | import { UserSignupDto } from '../dto'; 10 | import e = require('express'); 11 | 12 | export enum Provider { 13 | FACEBOOK = 'facebook', 14 | GITHUB = 'github', 15 | GOOGLE = 'google', 16 | LINKEDIN = 'linkedin', 17 | MICROSOFT = 'microsoft', 18 | TWITTER = 'twitter', 19 | WINDOWS_LIVE = 'windowslive', 20 | } 21 | 22 | @Injectable() 23 | export class AuthService { 24 | 25 | private readonly JWT_SECRET_KEY = authConfig.jwtSecretKey; 26 | 27 | constructor(private jwtService: JwtService, private userService: UserService) { } 28 | 29 | async validateOAuthLogin(userProfile: any, provider: Provider): Promise<{ jwt: string; user: User }> { 30 | try { 31 | // find user in MongoDB and if not found then create it in the DB 32 | let existingUser = await this.userService.findOne({ [provider]: userProfile.userId }); 33 | if (!existingUser) { 34 | existingUser = await this.userService.create({ ...userProfile, provider, providers: [{ providerId: userProfile.userId, name: provider }] }); 35 | } 36 | 37 | const { userId, email, displayName, picture, providers, roles } = existingUser; 38 | const signingPayload = { userId, email, displayName, picture, providers, roles }; 39 | const jwt: string = sign(signingPayload, this.JWT_SECRET_KEY, { expiresIn: 3600 }); 40 | return { jwt, user: existingUser }; 41 | } catch (err) { 42 | throw new InternalServerErrorException('validateOAuthLogin', err.message); 43 | } 44 | } 45 | 46 | async validateUser(email: string, pass: string): Promise { 47 | const user = await this.userService.findOne({ email }); 48 | if (user && (await this.passwordsAreEqual(user.password, pass))) { 49 | return user; 50 | } 51 | return null; 52 | } 53 | 54 | async login(user: User): Promise<{ token: string }> { 55 | const { email, displayName, userId, roles } = user; 56 | return { token: this.jwtService.sign({ email, displayName, userId, roles }) }; 57 | } 58 | 59 | async signup(signupUser: UserSignupDto): Promise<{ token: string }> { 60 | const password = await bcrypt.hash(signupUser.password, 10); 61 | const createdUser = await this.userService.create({ ...signupUser, password }); 62 | const { email, displayName, userId } = createdUser; 63 | return { token: this.jwtService.sign({ email, displayName, userId }) }; 64 | } 65 | 66 | async usernameAvailable(user: Partial): Promise { 67 | if (!user || !user.email) { 68 | return false; 69 | } 70 | const userFound = await this.userService.findOne({ email: user.email }); 71 | return !userFound; 72 | } 73 | 74 | private async passwordsAreEqual(hashedPassword: string, plainPassword: string): Promise { 75 | return await bcrypt.compare(plainPassword, hashedPassword); 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /server/src/auth/services/facebook.service.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2 } from 'oauth'; 2 | 3 | import authConfig from '../auth-config.development'; 4 | import { rejects } from 'assert'; 5 | 6 | export class FacebookService { 7 | oauth; 8 | 9 | constructor() { 10 | this.oauth = new OAuth2( 11 | authConfig.providers.facebook.clientID, 12 | authConfig.providers.facebook.clientSecret, 13 | 'https://graph.facebook.com', 14 | null, 15 | 'oauth2/token', 16 | null); 17 | } 18 | 19 | getImage(token: string): Promise { 20 | return new Promise((resolve, reject) => { 21 | this.oauth.get( 22 | 'https://graph.facebook.com/v4.0/me/picture?redirect=false&type=large', 23 | token, 24 | (err, results, res) => { 25 | if (err) { 26 | reject(err); 27 | } 28 | const result = JSON.parse(results || {}); 29 | const data = result && result.data || {}; 30 | resolve(data); 31 | }, 32 | ); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/src/auth/services/user.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { Model, Types } from 'mongoose'; 4 | import { decode } from 'jsonwebtoken'; 5 | 6 | import { User } from '../models/user.interface'; 7 | import { UserSignupDto } from '../dto/userSignup.dto'; 8 | 9 | @Injectable() 10 | export class UserService { 11 | constructor( 12 | @InjectModel('User') 13 | private readonly userModel: Model, 14 | ) { } 15 | 16 | async create(newUser: User | UserSignupDto): Promise { 17 | const objectId = Types.ObjectId; 18 | const roles = ['USER']; 19 | const userCount = await this.count(); 20 | if (userCount === 0) { 21 | roles.push('ADMIN'); // the very first user will automatically get the ADMIN role 22 | } 23 | const userId = newUser?.userId || objectId.toString(); // copy over the same _id when userId isn't provided (by local signup users) 24 | const createdUser = new this.userModel({ ...newUser, roles, _id: objectId, userId }); 25 | return await createdUser.save(); 26 | } 27 | 28 | async count(): Promise { 29 | return await this.userModel.countDocuments().exec(); 30 | } 31 | 32 | async findAll(): Promise { 33 | return await this.userModel.find().exec(); 34 | } 35 | 36 | async findById(id: string): Promise { 37 | const user = await this.userModel?.findById(id).exec(); 38 | if (!user) { 39 | throw new NotFoundException('Could not find user.'); 40 | } 41 | return user; 42 | } 43 | 44 | async findOne(userProperty): Promise { 45 | return await this.userModel.findOne(userProperty).exec(); 46 | } 47 | 48 | async link(userId: string, token: string, providerName: string) { 49 | let result; 50 | const decodedToken = decode(token) as User; 51 | const user = await this.userModel.findOne({ userId }); 52 | console.log('link user2', (user && decodedToken && providerName), user) 53 | if (user && decodedToken && providerName) { 54 | user[providerName] = decodedToken[userId]; 55 | user.providers.push({ providerId: decodedToken.userId, name: providerName }); 56 | result = await user.save(); 57 | } 58 | return result; 59 | } 60 | 61 | async unlink(userId: string, providerName: string) { 62 | console.log('unlink userId', userId) 63 | const result = await this.userModel.findOneAndUpdate({ userId }, { $unset: { [providerName]: true }, $pull: { 'providers': { name: providerName } } }, { new: true }); 64 | return result; 65 | } 66 | 67 | // async updateMe(updatedUser: User) { 68 | // const user = await this.findOne({ userId: updatedUser.userId }); 69 | // user.displayName = updatedUser.displayName; 70 | // user.email = updatedUser.email; 71 | // } 72 | } 73 | -------------------------------------------------------------------------------- /server/src/auth/services/windowslive.service.ts: -------------------------------------------------------------------------------- 1 | import { OAuth2 } from 'oauth'; 2 | 3 | import authConfig from '../auth-config.development'; 4 | 5 | export class WindowsliveService { 6 | oauth; 7 | 8 | constructor() { 9 | this.oauth = new OAuth2( 10 | authConfig.providers.windowslive.clientID, 11 | authConfig.providers.windowslive.clientSecret, 12 | 'https://graph.microsoft.com', 13 | null, 14 | 'oauth2/token', 15 | null); 16 | } 17 | 18 | getImage(token): Promise { 19 | return new Promise((resolve, reject) => { 20 | this.oauth.get( 21 | // 'https://graph.microsoft.com/v1.0/me/photo/$value', 22 | 'https://graph.microsoft.com/beta/me/photo/$value', 23 | token, 24 | (err, results, res) => { 25 | if (err) { 26 | reject(err); 27 | } 28 | console.log('getImage result:', results, err, res) 29 | resolve(results); 30 | // results = JSON.parse(results); 31 | }, 32 | ); 33 | }); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /server/src/auth/strategies/facebook.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-facebook'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | import { FacebookService } from '../services/facebook.service'; 9 | 10 | @Injectable() 11 | export class FacebookStrategy extends PassportStrategy(Strategy, 'facebook') { 12 | constructor(private readonly authService: AuthService, private facebookService: FacebookService) { 13 | super({ 14 | clientID: authConfig.providers.facebook.clientID, 15 | clientSecret: authConfig.providers.facebook.clientSecret, 16 | callbackURL: authConfig.providers.facebook.callbackURL, 17 | profileFields: ['id', 'displayName', 'photos', 'email'], 18 | passReqToCallback: true, 19 | scope: ['email'], 20 | }); 21 | } 22 | 23 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 24 | try { 25 | Logger.log(`Facebook UserProfile`, 'Auth'); 26 | // get larger image from Facebook Graph API 27 | const image = await this.facebookService.getImage(accessToken); 28 | 29 | const jsonProfile = profile && profile._json || {}; 30 | 31 | const userProfile = { 32 | userId: profile.id || jsonProfile.id, 33 | facebook: profile.id || jsonProfile.id, 34 | username: profile.userName || jsonProfile.userName, 35 | email: profile.email || jsonProfile.email, 36 | displayName: profile.displayName, 37 | picture: image && image.url || profile.photos[0].value, 38 | }; 39 | 40 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.FACEBOOK); 41 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 42 | } catch (err) { 43 | done(err, false); 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/src/auth/strategies/github.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-github2'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | 9 | @Injectable() 10 | export class GithubStrategy extends PassportStrategy(Strategy, 'github') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | clientID: authConfig.providers.github.clientID, 14 | clientSecret: authConfig.providers.github.clientSecret, 15 | callbackURL: authConfig.providers.github.callbackURL, 16 | passReqToCallback: true, 17 | scope: ['user:email'], 18 | }); 19 | } 20 | 21 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 22 | try { 23 | Logger.log(`GitHub UserProfile`, 'Auth'); 24 | const jsonProfile = profile && profile._json || {}; 25 | const userProfile = { 26 | userId: profile.id || jsonProfile.id, 27 | github: profile.id || jsonProfile.id, 28 | username: profile.login || jsonProfile.login, 29 | email: profile.email || Array.isArray(profile.emails) && profile.emails[0].value, 30 | displayName: profile.displayName || jsonProfile.displayName, 31 | picture: `${jsonProfile.avatar_url}&size=200`, 32 | }; 33 | console.log('userProfile::', userProfile, ' - req::', req.headers) 34 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.GITHUB); 35 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 36 | } catch (err) { 37 | done(err, false); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /server/src/auth/strategies/google.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-google-oauth20'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | 9 | @Injectable() 10 | export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | clientID: authConfig.providers.google.clientID, 14 | clientSecret: authConfig.providers.google.clientSecret, 15 | callbackURL: authConfig.providers.google.callbackURL, 16 | passReqToCallback: true, 17 | scope: ['profile', 'email'], 18 | }); 19 | } 20 | 21 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 22 | try { 23 | Logger.log(`Google UserProfile`, 'Auth'); 24 | const jsonProfile = profile && profile._json || {}; 25 | 26 | const userProfile = { 27 | userId: jsonProfile.sub, 28 | google: jsonProfile.sub, 29 | username: jsonProfile.userName, 30 | email: jsonProfile.email, 31 | displayName: profile.displayName, 32 | picture: jsonProfile.picture.replace('sz=50', 'sz=200'), 33 | }; 34 | 35 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.GOOGLE); 36 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 37 | } catch (err) { 38 | // console.log(err) 39 | done(err, false); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/auth/strategies/jwt.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { ExtractJwt, Strategy } from 'passport-jwt'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | 8 | @Injectable() 9 | export class JwtStrategy extends PassportStrategy(Strategy, 'jwt') { 10 | 11 | constructor(/*private readonly authService: AuthService*/) { 12 | super({ 13 | jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), 14 | secretOrKey: authConfig.jwtSecretKey, 15 | }); 16 | } 17 | 18 | async validate(payload: any, done: VerifiedCallback) { 19 | try { 20 | Logger.log(`JWT UserProfile`, 'Auth'); 21 | // You could add a function to the authService to verify the claims of the token: 22 | // i.e. does the user still have the roles that are claimed by the token 23 | // const validClaims = await this.authService.verifyTokenClaims(payload); 24 | 25 | // if (!validClaims) 26 | // return done(new UnauthorizedException('invalid token claims'), false); 27 | 28 | done(null, payload); 29 | } catch (err) { 30 | throw new UnauthorizedException('unauthorized', err.message); 31 | } 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/auth/strategies/linkedin.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-linkedin-oauth2'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | 9 | @Injectable() 10 | export class LinkedInStrategy extends PassportStrategy(Strategy, 'linkedin') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | clientID: authConfig.providers.linkedin.clientID, 14 | clientSecret: authConfig.providers.linkedin.clientSecret, 15 | callbackURL: authConfig.providers.linkedin.callbackURL, 16 | passReqToCallback: true, 17 | scope: ['r_emailaddress', 'r_liteprofile', 'r_basicprofile'], 18 | }); 19 | } 20 | 21 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 22 | try { 23 | Logger.log(`LinkedIn UserProfile`, 'Auth'); 24 | const jsonProfile = profile && profile._json || {}; 25 | const userProfile = { 26 | userId: jsonProfile.id, 27 | linkedin: jsonProfile.id, 28 | username: jsonProfile.userName, 29 | email: profile.emails[0].value, 30 | displayName: profile.displayName, 31 | picture: profile.pictureUrl || profile.photos[0].value, 32 | }; 33 | 34 | // console.log('userProfile::', profile) 35 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.LINKEDIN); 36 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 37 | } catch (err) { 38 | done(err, false); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/auth/strategies/local.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UnauthorizedException } from '@nestjs/common'; 2 | import { Strategy } from 'passport-local'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { AuthService } from '../services/auth.service'; 5 | 6 | @Injectable() 7 | export class LocalStrategy extends PassportStrategy(Strategy) { 8 | constructor(private readonly authService: AuthService) { 9 | super(); 10 | } 11 | 12 | async validate(email: string, password: string): Promise { 13 | const user = await this.authService.validateUser(email, password); 14 | if (!user) { 15 | throw new UnauthorizedException(); 16 | } 17 | return user; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /server/src/auth/strategies/microsoft.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-microsoft'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | 9 | @Injectable() 10 | export class MicrosoftStrategy extends PassportStrategy(Strategy, 'microsoft') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | clientID: authConfig.providers.microsoft.clientID, 14 | clientSecret: authConfig.providers.microsoft.clientSecret, 15 | callbackURL: authConfig.providers.microsoft.callbackURL, 16 | passReqToCallback: true, 17 | scope: ['user.read'], 18 | }); 19 | } 20 | 21 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 22 | try { 23 | Logger.log(`Microsoft UserProfile`, 'Auth'); 24 | const jsonProfile = profile && profile._json || {}; 25 | 26 | const userProfile = { 27 | userId: jsonProfile.id, 28 | microsoft: jsonProfile.id, 29 | username: jsonProfile.userName, 30 | email: jsonProfile.userPrincipalName, 31 | displayName: profile.displayName, 32 | picture: null, // profile.photos[0].value, <-- no longer valid, we now have to use MS Graph API 33 | }; 34 | 35 | // console.log('userProfile::', profile) 36 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.MICROSOFT); 37 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 38 | } catch (err) { 39 | done(err, false); 40 | } 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /server/src/auth/strategies/twitter.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-twitter-oauth2'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | 9 | @Injectable() 10 | export class TwitterStrategy extends PassportStrategy(Strategy, 'twitter') { 11 | constructor(private readonly authService: AuthService) { 12 | super({ 13 | clientID: authConfig.providers.twitter.clientID, 14 | clientSecret: authConfig.providers.twitter.clientSecret, 15 | callbackURL: authConfig.providers.twitter.callbackURL, 16 | passReqToCallback: true, 17 | profileFields: ['id', 'displayName', 'photos', 'email'], 18 | }); 19 | } 20 | 21 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 22 | try { 23 | Logger.log(`Twitter UserProfile`, 'Auth'); 24 | const jsonProfile = profile && profile._json || {}; 25 | console.log(profile); 26 | const userProfile = { 27 | userId: profile.id || jsonProfile.id, 28 | twitter: profile.id || jsonProfile.id, 29 | username: profile.userName || jsonProfile.userName, 30 | email: profile.email || jsonProfile.email, 31 | displayName: profile.displayName, 32 | picture: null, 33 | }; 34 | 35 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.TWITTER); 36 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 37 | } catch (err) { 38 | done(err, false); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /server/src/auth/strategies/windowslive.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Logger } from '@nestjs/common'; 2 | import { PassportStrategy } from '@nestjs/passport'; 3 | import { Strategy } from 'passport-windowslive'; 4 | import { VerifiedCallback } from 'passport-jwt'; 5 | 6 | import authConfig from '../auth-config.development'; 7 | import { AuthService, Provider } from '../services/auth.service'; 8 | import { WindowsliveService } from '../services/windowslive.service'; 9 | 10 | @Injectable() 11 | export class WindowsliveStrategy extends PassportStrategy(Strategy, 'windowslive') { 12 | constructor(private readonly authService: AuthService, private windowsliveService: WindowsliveService) { 13 | super({ 14 | clientID: authConfig.providers.windowslive.clientID, 15 | clientSecret: authConfig.providers.windowslive.clientSecret, 16 | callbackURL: authConfig.providers.windowslive.callbackURL, 17 | passReqToCallback: true, 18 | scope: ['wl.signin', 'wl.basic', 'wl.emails', 'user.read'], 19 | }); 20 | } 21 | 22 | async validate(req: any, accessToken: string, refreshToken: string, profile: any, done: VerifiedCallback) { 23 | try { 24 | Logger.log(`WindowsLive UserProfile`, 'Auth'); 25 | // get larger image from Microsoft Graph API 26 | // const image = await this.windowsliveService.getImage(accessToken); 27 | 28 | const jsonProfile = profile && profile._json || {}; 29 | 30 | const userProfile = { 31 | userId: jsonProfile.id, 32 | windowslive: jsonProfile.id, 33 | username: jsonProfile.userName, 34 | email: jsonProfile.emails.account, 35 | displayName: jsonProfile.name, 36 | picture: null, // profile.photos[0].value, <-- no longer valid, we now have to use MS Graph API 37 | }; 38 | const oauthResponse = await this.authService.validateOAuthLogin(userProfile, Provider.WINDOWS_LIVE); 39 | done(null, { ...JSON.parse(JSON.stringify(oauthResponse.user)), jwt: oauthResponse.jwt }); 40 | } catch (err) { 41 | done(err, false); 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /server/src/cats/cats.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { MongooseModule } from '@nestjs/mongoose'; 3 | 4 | import { CatsResolver } from './cats.resolver'; 5 | import { CatSchema } from './schemas/cats.schema'; 6 | import { CatsService } from './services/cats.service'; 7 | import { GraphqlPassportAuthGuard } from '../shared/guards/graphql-passport-auth.guard'; 8 | import { OwnersService } from './services/owners.service'; 9 | import { UserSchema } from '../shared/schemas/user.schema'; 10 | 11 | @Module({ 12 | imports: [MongooseModule.forFeature([ 13 | { name: 'Cat', schema: CatSchema }, 14 | { name: 'User', schema: UserSchema }, 15 | ])], 16 | providers: [CatsResolver, CatsService, GraphqlPassportAuthGuard, OwnersService], 17 | }) 18 | export class CatsModule { } 19 | -------------------------------------------------------------------------------- /server/src/cats/cats.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Int, Query, Resolver, Mutation, ResolveProperty, Parent } from '@nestjs/graphql'; 2 | import { UseGuards } from '@nestjs/common'; 3 | import { Schema as MongooseSchema } from 'mongoose'; 4 | 5 | import { CatsService } from './services/cats.service'; 6 | import { Cat } from './graphql/types/cat.type'; 7 | import { CatInput } from './graphql/inputs/cat-input'; 8 | import { GraphqlPassportAuthGuard } from '../shared/guards/graphql-passport-auth.guard'; 9 | import { Roles } from '../shared/decorators/roles.decorator'; 10 | import { CurrentUser } from '../shared/decorators'; 11 | import { User } from '../shared/models'; 12 | import { User as AuthUser } from '../auth/models'; 13 | import { OwnersService } from './services/owners.service'; 14 | import { CatQueryArgs } from './graphql/inputs/catQueryArgs.input'; 15 | 16 | @Resolver('Cat') 17 | @Resolver(_of => Cat) 18 | export class CatsResolver { 19 | constructor( 20 | private readonly catsService: CatsService, 21 | private readonly ownersService: OwnersService, 22 | ) { } 23 | 24 | @Query(_returns => String) 25 | async hello(): Promise { 26 | return 'kitty'; 27 | } 28 | 29 | @Query(_returns => Cat) 30 | async author(@Args({ name: 'id', type: () => Int }) id: MongooseSchema.Types.ObjectId) { 31 | return await this.catsService.findOneById(id); 32 | } 33 | 34 | @Query(_returns => [Cat]) 35 | @UseGuards(GraphqlPassportAuthGuard) 36 | async cats(@Args() queryArgs: CatQueryArgs) { 37 | return await this.catsService.query(queryArgs); 38 | } 39 | 40 | @ResolveProperty('owner') 41 | async owner(@Parent() cat): Promise { 42 | const { ownerId } = cat; 43 | return await this.ownersService.findByUserId(ownerId); 44 | } 45 | 46 | @Roles('user') 47 | @UseGuards(new GraphqlPassportAuthGuard('USER')) 48 | @Mutation(_returns => Cat) 49 | @UseGuards(GraphqlPassportAuthGuard) 50 | async createCat(@Args('input') input: CatInput, @CurrentUser() currentUser: User) { 51 | return this.catsService.create(input, currentUser); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /server/src/cats/graphql/enums/catFields.enum.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType } from '@nestjs/graphql'; 2 | 3 | export enum CatFields { 4 | id = 'id', 5 | name = 'name', 6 | age = 'age', 7 | breed = 'breed', 8 | } 9 | 10 | registerEnumType(CatFields, { 11 | name: 'CatFields', 12 | description: 'The list of Cat Fields', 13 | }); 14 | -------------------------------------------------------------------------------- /server/src/cats/graphql/inputs/cat-input.ts: -------------------------------------------------------------------------------- 1 | import { Field, Int, InputType } from '@nestjs/graphql'; 2 | import { IsNotEmpty, Min, MinLength } from 'class-validator'; 3 | 4 | @InputType() 5 | export class CatInput { 6 | @Field() 7 | @MinLength(1) 8 | readonly name: string; 9 | 10 | @Field(type => Int) 11 | @Min(0) 12 | readonly age: number; 13 | 14 | @Field() 15 | @MinLength(0) 16 | readonly breed: string; 17 | 18 | @Field() 19 | @IsNotEmpty() 20 | readonly ownerId: string; 21 | } 22 | -------------------------------------------------------------------------------- /server/src/cats/graphql/inputs/catQueryArgs.input.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from '@nestjs/graphql'; 2 | import { CatFields } from '../enums/catFields.enum'; 3 | import { FilterByGeneric, OrderByGeneric } from '../../../shared/graphql/types'; 4 | import { IsOptional } from 'class-validator'; 5 | 6 | const FilterByCatFields = FilterByGeneric(CatFields, 'CatFields'); 7 | const OrderByCatFields = OrderByGeneric(CatFields, 'CatFields'); 8 | type FilterByCatFields = InstanceType; 9 | type OrderByCatFields = InstanceType; 10 | 11 | @ArgsType() 12 | export class CatQueryArgs { 13 | @Field(type => [FilterByCatFields], { nullable: true }) 14 | @IsOptional() 15 | filterBy?: FilterByCatFields[]; 16 | 17 | @Field(type => [OrderByCatFields], { nullable: true }) 18 | @IsOptional() 19 | orderBy?: OrderByCatFields[]; 20 | } 21 | -------------------------------------------------------------------------------- /server/src/cats/graphql/types/cat.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int, ID } from '@nestjs/graphql'; 2 | import { Schema } from '@nestjs/mongoose'; 3 | import { Owner } from './owner.type'; 4 | 5 | @ObjectType() 6 | @Schema() 7 | export class Cat { 8 | @Field(type => ID) 9 | id: string; 10 | 11 | @Field() 12 | readonly name: string; 13 | 14 | @Field(type => Int) 15 | readonly age: number; 16 | 17 | @Field() 18 | readonly breed: string; 19 | 20 | @Field(type => Owner, { nullable: true }) 21 | readonly owner?: Owner; 22 | } 23 | -------------------------------------------------------------------------------- /server/src/cats/graphql/types/owner.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from '@nestjs/graphql'; 2 | import { Schema } from '@nestjs/mongoose'; 3 | 4 | @ObjectType() 5 | @Schema() 6 | export class Owner { 7 | @Field(type => ID) 8 | id: string; 9 | 10 | @Field() 11 | readonly displayName: string; 12 | 13 | @Field() 14 | readonly email: string; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/cats/models/cat.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | 3 | export interface Cat extends Document { 4 | readonly id: string; 5 | readonly name: string; 6 | readonly age: number; 7 | readonly breed: string; 8 | readonly ownerId: string; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/cats/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './cat.interface'; 2 | -------------------------------------------------------------------------------- /server/src/cats/schemas/cats.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | 3 | export const CatSchema = new mongoose.Schema({ 4 | name: String, 5 | age: Number, 6 | breed: String, 7 | ownerId: String, 8 | }, { timestamps: true }); 9 | -------------------------------------------------------------------------------- /server/src/cats/services/cats.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UseGuards, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { Model, Schema as MongooseSchema } from 'mongoose'; 5 | 6 | import { Cat } from '../models/cat.interface'; 7 | import { CatInput } from '../graphql/inputs/cat-input'; 8 | import { User, Direction } from '../../shared/models'; 9 | 10 | @Injectable() 11 | @UseGuards(AuthGuard('jwt')) 12 | export class CatsService { 13 | constructor(@InjectModel('Cat') private readonly catModel: Model) { } 14 | 15 | async create(createCat: CatInput, currentUser: User): Promise { 16 | const newCat = { ...createCat, ownerId: currentUser.userId }; 17 | const createdCat = new this.catModel(newCat); 18 | return await createdCat.save(); 19 | } 20 | 21 | async query(queryArgs: { orderBy?: Array<{ field: string; direction: Direction; }> }): Promise { 22 | let modelFind = this.catModel.find(); 23 | // [['name', 'asc']] 24 | if (queryArgs && Array.isArray(queryArgs.orderBy)) { 25 | const sort = []; 26 | queryArgs.orderBy.forEach(sorter => sort.push([sorter.field, sorter.direction])); 27 | modelFind = modelFind.sort(sort as any); 28 | } 29 | return await modelFind.exec(); 30 | } 31 | 32 | async findOneById(id: MongooseSchema.Types.ObjectId): Promise { 33 | const cat = await this.catModel.findById(id).exec(); 34 | if (!cat) { 35 | throw new NotFoundException('Could not find cat.'); 36 | } 37 | return cat; 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /server/src/cats/services/owners.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UseGuards, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { Model } from 'mongoose'; 5 | 6 | import { User } from '../../auth/models'; 7 | 8 | @Injectable() 9 | @UseGuards(AuthGuard('jwt')) 10 | export class OwnersService { 11 | constructor(@InjectModel('User') private readonly userModel: Model) { } 12 | 13 | async findAll(): Promise { 14 | return await this.userModel.find().exec(); 15 | } 16 | 17 | async findByUserId(id: string): Promise { 18 | const owner = await this.userModel.findOne({ userId: id }).exec(); 19 | if (!owner) { 20 | throw new NotFoundException('Could not find cat\'s owner.'); 21 | } 22 | return owner; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/main.hmr.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { AppModule } from './app.module'; 4 | 5 | declare const module: any; 6 | 7 | async function bootstrap() { 8 | const app = await NestFactory.create(AppModule); 9 | await app.listen(3000); 10 | Logger.log(`Preload`, 'Swagger'); 11 | if (module.hot) { 12 | module.hot.accept(); 13 | module.hot.dispose(() => app.close()); 14 | } 15 | } 16 | bootstrap(); 17 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { Logger, ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | 4 | import { AppModule } from './app.module'; 5 | import { HttpExceptionFilter } from './shared/filters/http-exception.filter'; 6 | import { LoggingInterceptor } from './shared/interceptors/logging.interceptor'; 7 | import { TimeoutInterceptor } from './shared/interceptors/timeout.interceptor'; 8 | import { setupSwagger } from './swagger'; 9 | 10 | const port = process.env.PORT; 11 | 12 | async function bootstrap() { 13 | const app = await NestFactory.create(AppModule); 14 | app.enableCors(); 15 | setupSwagger(app, port); 16 | 17 | app.useGlobalFilters(new HttpExceptionFilter()); 18 | app.useGlobalInterceptors( 19 | new TimeoutInterceptor(), 20 | new LoggingInterceptor() 21 | ); 22 | app.useGlobalPipes(new ValidationPipe({ 23 | whitelist: true, 24 | transform: true, 25 | transformOptions: { 26 | enableImplicitConversion: true 27 | } 28 | })); 29 | await app.listen(port); 30 | Logger.log(`Server running on http://localhost:${port}`, 'Bootstrap'); 31 | } 32 | bootstrap(); 33 | -------------------------------------------------------------------------------- /server/src/shared/common.module.ts: -------------------------------------------------------------------------------- 1 | import { MiddlewareConsumer, Module, NestModule } from '@nestjs/common'; 2 | import { DateScalar } from './graphql/scalars/date.scalar'; 3 | import { LoggerMiddleware } from './middleware/logger.middleware'; 4 | 5 | @Module({ 6 | providers: [DateScalar], 7 | }) 8 | export class CommonModule implements NestModule { 9 | configure(consumer: MiddlewareConsumer) { 10 | // consumer.apply(LoggerMiddleware).forRoutes('*'); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/shared/decorators/currentUser.decorator.ts: -------------------------------------------------------------------------------- 1 | import { createParamDecorator, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | 4 | export const CurrentUser = createParamDecorator( 5 | (data: unknown, context: ExecutionContext) => { 6 | const ctx = GqlExecutionContext.create(context); 7 | return ctx.getContext().req.user; 8 | }, 9 | ); 10 | -------------------------------------------------------------------------------- /server/src/shared/decorators/index.ts: -------------------------------------------------------------------------------- 1 | export * from './currentUser.decorator'; 2 | export * from './roles.decorator'; 3 | -------------------------------------------------------------------------------- /server/src/shared/decorators/roles.decorator.ts: -------------------------------------------------------------------------------- 1 | import { SetMetadata } from '@nestjs/common'; 2 | 3 | export const Roles = (...roles: string[]) => SetMetadata('roles', roles); 4 | -------------------------------------------------------------------------------- /server/src/shared/filters/http-exception.filter.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentsHost, 3 | Catch, 4 | ExceptionFilter, 5 | HttpException, 6 | } from '@nestjs/common'; 7 | import { Response } from 'express'; 8 | 9 | @Catch(HttpException) 10 | export class HttpExceptionFilter implements ExceptionFilter { 11 | catch(exception: HttpException, host: ArgumentsHost) { 12 | const ctx = host.switchToHttp(); 13 | const response = ctx.getResponse(); 14 | const request = ctx.getRequest(); 15 | const statusCode = exception.getStatus(); 16 | const exceptionResponse = exception.getResponse(); 17 | const error = typeof response === 'string' ? { message: exceptionResponse } : (exceptionResponse as Record); 18 | 19 | if (response && typeof response.status === 'function') { 20 | response.status(statusCode).json({ 21 | ...error, 22 | path: request.url, 23 | timestamp: new Date().toISOString(), 24 | }); 25 | } else { 26 | return exception; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /server/src/shared/graphql/enums/direction.enum.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType } from '@nestjs/graphql'; 2 | 3 | export enum Direction { 4 | ASC = 'ASC', 5 | DESC = 'DESC', 6 | } 7 | 8 | registerEnumType(Direction, { 9 | name: 'Direction', 10 | description: 'The orderBy directions', 11 | }); 12 | -------------------------------------------------------------------------------- /server/src/shared/graphql/enums/index.ts: -------------------------------------------------------------------------------- 1 | export * from './direction.enum'; 2 | export * from './operator.enum'; 3 | -------------------------------------------------------------------------------- /server/src/shared/graphql/enums/operator.enum.ts: -------------------------------------------------------------------------------- 1 | import { registerEnumType } from '@nestjs/graphql'; 2 | 3 | export enum Operator { 4 | LT = 'LT', 5 | LE = 'LE', 6 | GT = 'GT', 7 | GE = 'GE', 8 | NE = 'NE', 9 | EQ = 'EQ', 10 | IN = 'IN', 11 | NIN = 'NIN', 12 | StartsWith = 'StartsWith', 13 | EndsWith = 'EndsWith', 14 | Contains = 'Contains', 15 | } 16 | 17 | registerEnumType(Operator, { 18 | name: 'Operator', 19 | description: 'Possible filter operators', 20 | }); 21 | -------------------------------------------------------------------------------- /server/src/shared/graphql/inputs/index.ts: -------------------------------------------------------------------------------- 1 | export * from './stringQueryArgs.input'; 2 | -------------------------------------------------------------------------------- /server/src/shared/graphql/inputs/stringQueryArgs.input.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field } from '@nestjs/graphql'; 2 | import { IsOptional } from 'class-validator'; 3 | import { FilterByString } from '../types/filterByString.type'; 4 | import { OrderByString } from '../types/orderByString.type'; 5 | 6 | @ArgsType() 7 | export class StringQueryArgs { 8 | @Field(type => [FilterByString], { nullable: true }) 9 | @IsOptional() 10 | filterBy?: FilterByString[]; 11 | 12 | @Field(type => [OrderByString], { nullable: true }) 13 | @IsOptional() 14 | orderBy?: OrderByString[]; 15 | } 16 | -------------------------------------------------------------------------------- /server/src/shared/graphql/scalars/date.scalar.ts: -------------------------------------------------------------------------------- 1 | import { Scalar, CustomScalar } from '@nestjs/graphql'; 2 | import { Kind, ValueNode } from 'graphql'; 3 | 4 | @Scalar('Date', type => Date) 5 | export class DateScalar implements CustomScalar { 6 | description = 'Date custom scalar type'; 7 | 8 | parseValue(value: number): Date { 9 | return new Date(value); // value from the client 10 | } 11 | 12 | serialize(value: Date): number { 13 | return value.getTime(); // value sent to the client 14 | } 15 | 16 | parseLiteral(ast: ValueNode): Date { 17 | if (ast.kind === Kind.INT) { 18 | return new Date(ast.value); 19 | } 20 | return null; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/filterByGeneric.type.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | import { IsNotEmpty, MinLength } from 'class-validator'; 3 | import { Operator } from '../enums/operator.enum'; 4 | 5 | export function FilterByGeneric(TItemEnum: any, name: string): any { 6 | @InputType(`FilterBy${name}`, { isAbstract: true }) 7 | abstract class FilterByGenericClass { 8 | @Field(type => TItemEnum) 9 | @IsNotEmpty() 10 | field: TItem; 11 | 12 | @Field(type => Operator) 13 | @IsNotEmpty() 14 | readonly operator: Operator; 15 | 16 | @Field() 17 | @MinLength(0) 18 | readonly value: string; 19 | } 20 | return FilterByGenericClass; 21 | } 22 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/filterByString.type.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | import { MinLength, IsNotEmpty } from 'class-validator'; 3 | import { Operator } from '../enums/operator.enum'; 4 | 5 | @InputType(`FilterByString`, { isAbstract: true }) 6 | export class FilterByString { 7 | @Field() 8 | @MinLength(0) 9 | field: string; 10 | 11 | @Field(type => Operator) 12 | @IsNotEmpty() 13 | readonly operator: Operator; 14 | 15 | @Field() 16 | @MinLength(0) 17 | readonly value: string; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './filterByGeneric.type'; 2 | export * from './filterByString.type'; 3 | export * from './orderByGeneric.type'; 4 | export * from './orderByString.type'; 5 | export * from './pageInfo.type'; 6 | export * from './paginatedResponse.type'; 7 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/orderByGeneric.type.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | import { IsNotEmpty } from 'class-validator'; 3 | import { Direction } from '../enums/direction.enum'; 4 | 5 | export function OrderByGeneric(TItemEnum: any, name: string): any { 6 | @InputType(`OrderBy${name}`, { isAbstract: true }) 7 | abstract class OrderByGenericClass { 8 | @Field(type => TItemEnum) 9 | @IsNotEmpty() 10 | field: TItem; 11 | 12 | @Field(type => Direction) 13 | @IsNotEmpty() 14 | readonly direction: Direction; 15 | } 16 | return OrderByGenericClass; 17 | } 18 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/orderByString.type.ts: -------------------------------------------------------------------------------- 1 | import { InputType, Field } from '@nestjs/graphql'; 2 | import { IsNotEmpty, MinLength } from 'class-validator'; 3 | import { Direction } from '../enums/direction.enum'; 4 | 5 | @InputType(`OrderByString`, { isAbstract: true }) 6 | export class OrderByString { 7 | @Field() 8 | @MinLength(0) 9 | field: string; 10 | 11 | @Field(type => Direction) 12 | @IsNotEmpty() 13 | readonly direction: Direction; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/pageInfo.type.ts: -------------------------------------------------------------------------------- 1 | import { Field, ObjectType } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class PageInfo { 5 | @Field({ nullable: true }) 6 | readonly hasNextPage?: boolean; 7 | 8 | @Field({ nullable: true }) 9 | readonly hasPreviousPage?: boolean; 10 | 11 | @Field({ nullable: true }) 12 | readonly endCursor?: string; 13 | 14 | @Field({ nullable: true }) 15 | readonly startCursor?: string; 16 | } 17 | -------------------------------------------------------------------------------- /server/src/shared/graphql/types/paginatedResponse.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, Int } from '@nestjs/graphql'; 2 | import { PageInfo } from './pageInfo.type'; 3 | 4 | // export function PaginatedResponse(TItemClass: ClassType): any { 5 | export function PaginatedResponse(TItemClass: any): any { 6 | @ObjectType({ isAbstract: true }) 7 | abstract class PaginatedResponseClass { 8 | @Field(type => [TItemClass]) 9 | nodes: TItem[]; 10 | 11 | @Field(type => Int) 12 | totalCount: number; 13 | 14 | @Field(type => PageInfo) 15 | pageInfo?: PageInfo; 16 | } 17 | return PaginatedResponseClass; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/shared/graphql/utils/utilities.ts: -------------------------------------------------------------------------------- 1 | import { Operator } from '../../models'; 2 | import { FilterByString } from '../types'; 3 | 4 | interface FilterByResult { 5 | [operator: string]: string; 6 | } 7 | 8 | export function getFilterByQuery(filterBy: FilterByString[]) { 9 | const filterObj = {}; 10 | if (Array.isArray(filterBy)) { 11 | filterBy.forEach(filter => { 12 | filterObj[filter.field] = getFilterByOperator(filter.operator, filter.value); 13 | }); 14 | } 15 | return filterObj; 16 | } 17 | 18 | export function getFilterByOperator(operator: Operator, searchValue: string | number | boolean): FilterByResult { 19 | let operation; 20 | 21 | switch (operator) { 22 | case Operator.EQ: 23 | operation = { $eq: searchValue }; 24 | break; 25 | case Operator.NE: 26 | operation = { $ne: searchValue }; 27 | break; 28 | case Operator.GE: 29 | operation = { $gte: searchValue }; 30 | break; 31 | case Operator.GT: 32 | operation = { $gt: searchValue }; 33 | break; 34 | case Operator.LE: 35 | operation = { $lte: searchValue }; 36 | break; 37 | case Operator.LT: 38 | operation = { $lt: searchValue }; 39 | break; 40 | case Operator.IN: 41 | const inValues = typeof searchValue === 'string' ? searchValue.split(',') : searchValue; 42 | operation = { $in: inValues || [] }; 43 | break; 44 | case Operator.NIN: 45 | const notInValues = typeof searchValue === 'string' ? searchValue.split(',') : searchValue; 46 | operation = { $nin: notInValues || [] }; 47 | break; 48 | case Operator.EndsWith: 49 | operation = { $regex: new RegExp(`.*${searchValue}$`, 'i') }; 50 | break; 51 | case Operator.StartsWith: 52 | operation = { $regex: new RegExp(`^${searchValue}.*`, 'i') }; 53 | break; 54 | case Operator.Contains: 55 | default: 56 | operation = { $regex: new RegExp(`.*${searchValue}.*`, 'i') }; 57 | break; 58 | } 59 | return operation; 60 | } 61 | -------------------------------------------------------------------------------- /server/src/shared/guards/graphql-passport-auth.guard.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, ExecutionContext } from '@nestjs/common'; 2 | import { GqlExecutionContext } from '@nestjs/graphql'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | 5 | @Injectable() 6 | export class GraphqlPassportAuthGuard extends AuthGuard('jwt') { 7 | _roles: string[] = ['USER']; 8 | 9 | constructor(roles?: string | string[]) { 10 | super(); 11 | if (roles) { 12 | this._roles = Array.isArray(roles) ? roles : [roles]; 13 | } 14 | } 15 | 16 | async canActivate(context: ExecutionContext): Promise { 17 | await super.canActivate(context); 18 | const ctx = GqlExecutionContext.create(context); 19 | const req = ctx.getContext().req; 20 | 21 | // check if user has access by validating that he has the required role 22 | if (Array.isArray(this._roles)) { 23 | for (const requiredRole of this._roles) { 24 | if (this.hasAccess(req.user.roles, requiredRole)) { 25 | return true; 26 | } 27 | } 28 | } 29 | return false; 30 | } 31 | 32 | getRequest(context: ExecutionContext) { 33 | const ctx = GqlExecutionContext.create(context); 34 | const req = ctx.getContext().req; 35 | return req; 36 | } 37 | 38 | private hasAccess(roles, requiredRole): boolean { 39 | if (Array.isArray(roles)) { 40 | const adminFoundIndex = roles.findIndex((role: string) => role.toUpperCase() === requiredRole); 41 | if (adminFoundIndex >= 0) { 42 | return true; 43 | } 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /server/src/shared/guards/index.ts: -------------------------------------------------------------------------------- 1 | export * from './graphql-passport-auth.guard'; 2 | -------------------------------------------------------------------------------- /server/src/shared/interceptors/exception.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | HttpException, 5 | HttpStatus, 6 | Injectable, 7 | NestInterceptor, 8 | } from '@nestjs/common'; 9 | import { Observable, throwError } from 'rxjs'; 10 | import { catchError } from 'rxjs/operators'; 11 | 12 | @Injectable() 13 | export class ErrorsInterceptor implements NestInterceptor { 14 | intercept(context: ExecutionContext, next: CallHandler): Observable { 15 | return next 16 | .handle() 17 | .pipe( 18 | catchError(err => 19 | throwError(new HttpException('New message', HttpStatus.BAD_GATEWAY)), 20 | ), 21 | ); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/src/shared/interceptors/logging.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { tap } from 'rxjs/operators'; 9 | 10 | @Injectable() 11 | export class LoggingInterceptor implements NestInterceptor { 12 | intercept(context: ExecutionContext, next: CallHandler): Observable { 13 | console.time('Request-Response time'); 14 | return next 15 | .handle() 16 | .pipe(tap(() => console.timeEnd('Request-Response time'))); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /server/src/shared/interceptors/timeout.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { timeout } from 'rxjs/operators'; 9 | 10 | const REQUEST_TIMEOUT = 5000; 11 | 12 | @Injectable() 13 | export class TimeoutInterceptor implements NestInterceptor { 14 | intercept(context: ExecutionContext, next: CallHandler): Observable { 15 | return next.handle().pipe(timeout(REQUEST_TIMEOUT)); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/shared/interceptors/transform.interceptor.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CallHandler, 3 | ExecutionContext, 4 | Injectable, 5 | NestInterceptor, 6 | } from '@nestjs/common'; 7 | import { Observable } from 'rxjs'; 8 | import { map } from 'rxjs/operators'; 9 | 10 | export interface Response { 11 | data: T; 12 | } 13 | 14 | @Injectable() 15 | export class TransformInterceptor 16 | implements NestInterceptor> { 17 | intercept( 18 | context: ExecutionContext, 19 | next: CallHandler, 20 | ): Observable> { 21 | return next.handle().pipe(map(data => ({ data }))); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /server/src/shared/middleware/logger.middleware.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, NestMiddleware } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class LoggerMiddleware implements NestMiddleware { 5 | use(req: any, res: any, next: () => void) { 6 | console.time('Request-Response time'); 7 | 8 | res.on('finish', () => console.timeEnd('Request-Response time')); 9 | next(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /server/src/shared/models/direction.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Direction { 2 | ASC = 'ASC', 3 | DESC = 'DESC', 4 | } 5 | -------------------------------------------------------------------------------- /server/src/shared/models/filterBy.interface.ts: -------------------------------------------------------------------------------- 1 | 2 | export interface FilterBy { 3 | field: string; 4 | 5 | operator: any; 6 | 7 | value: string; 8 | } 9 | -------------------------------------------------------------------------------- /server/src/shared/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './direction.enum'; 2 | export * from './filterBy.interface'; 3 | export * from './operator.enum'; 4 | export * from './provider.interface'; 5 | export * from './user.interface'; 6 | -------------------------------------------------------------------------------- /server/src/shared/models/operator.enum.ts: -------------------------------------------------------------------------------- 1 | export enum Operator { 2 | /** where value is less than */ 3 | 'LT' = 'LT', 4 | 5 | /** where value is less than or equal to */ 6 | 'LE' = 'LE', 7 | 8 | /** where value is greater than */ 9 | 'GT' = 'GT', 10 | 11 | /** where value is greater than or equal to */ 12 | 'GE' = 'GE', 13 | 14 | /** where value is not equal to */ 15 | 'NE' = 'NE', 16 | 17 | /** where value is equal to */ 18 | 'EQ' = 'EQ', 19 | 20 | /** where value is in the specified array */ 21 | 'IN' = 'IN', 22 | 23 | /** where value is not in the specified array */ 24 | 'NIN' = 'NIN', 25 | 26 | /** where value starts with string */ 27 | 'StartsWith' = 'StartsWith', 28 | 29 | /** where value ends with string */ 30 | 'EndsWith' = 'EndsWith', 31 | 32 | /** where value contains a substring */ 33 | 'Contains' = 'Contains', 34 | } 35 | -------------------------------------------------------------------------------- /server/src/shared/models/provider.interface.ts: -------------------------------------------------------------------------------- 1 | export class Provider { 2 | id: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/shared/models/user.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { Provider } from './provider.interface'; 3 | 4 | export interface User extends Document { 5 | readonly userId?: string; 6 | readonly displayName: string; 7 | readonly email: string; 8 | readonly picture: string; 9 | readonly provider?: string; 10 | readonly providers?: Provider[]; 11 | readonly roles?: string[]; 12 | readonly facebook?: string; 13 | readonly github?: string; 14 | readonly google?: string; 15 | readonly linkedin?: string; 16 | readonly live?: string; 17 | readonly microsoft?: string; 18 | readonly twitter?: string; 19 | readonly windowslive?: string; 20 | } 21 | -------------------------------------------------------------------------------- /server/src/shared/pipes/parse-int.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | BadRequestException, 3 | PipeTransform, 4 | Injectable, 5 | ArgumentMetadata, 6 | } from '@nestjs/common'; 7 | 8 | @Injectable() 9 | export class ParseIntPipe implements PipeTransform { 10 | async transform(value: string, metadata: ArgumentMetadata) { 11 | const val = parseInt(value, 10); 12 | if (isNaN(val)) { 13 | throw new BadRequestException('Validation failed'); 14 | } 15 | return val; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/shared/pipes/validation.pipe.ts: -------------------------------------------------------------------------------- 1 | import { 2 | ArgumentMetadata, 3 | BadRequestException, 4 | Injectable, 5 | PipeTransform, 6 | Type, 7 | } from '@nestjs/common'; 8 | import { plainToClass } from 'class-transformer'; 9 | import { validate } from 'class-validator'; 10 | 11 | @Injectable() 12 | export class ValidationPipe implements PipeTransform { 13 | async transform(value: any, metadata: ArgumentMetadata) { 14 | const { metatype } = metadata; 15 | if (!metatype || !this.toValidate(metatype)) { 16 | return value; 17 | } 18 | const object = plainToClass(metatype, value); 19 | const errors = await validate(object); 20 | if (errors.length > 0) { 21 | throw new BadRequestException('Validation failed'); 22 | } 23 | return value; 24 | } 25 | 26 | private toValidate(metatype: Type): boolean { 27 | const types = [String, Boolean, Number, Array, Object]; 28 | return !types.find(type => metatype === type); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/shared/schemas/user.schema.ts: -------------------------------------------------------------------------------- 1 | import * as mongoose from 'mongoose'; 2 | import { Schema } from 'mongoose'; 3 | 4 | export const ProviderSchema = new Schema({ 5 | providerId: String, 6 | name: String, 7 | }); 8 | 9 | export const UserSchema = new Schema({ 10 | _id: { type: mongoose.Types.ObjectId }, 11 | userId: { type: String, unique: true }, 12 | password: String, 13 | email: { type: String, lowercase: true, unique: true }, 14 | displayName: String, 15 | provider: String, 16 | providers: [ProviderSchema], 17 | roles: [String], 18 | picture: String, 19 | facebook: String, 20 | foursquare: String, 21 | google: String, 22 | github: String, 23 | linkedin: String, 24 | live: String, 25 | microsoft: String, 26 | twitter: String, 27 | windowslive: String, 28 | }, { timestamps: true }); 29 | -------------------------------------------------------------------------------- /server/src/swagger.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '@nestjs/common'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 4 | 5 | export function setupSwagger(app: INestApplication, appPort: number | string) { 6 | const uri = 'api'; 7 | const options = new DocumentBuilder() 8 | .setTitle('NestJS Example API') 9 | .setDescription('API Documentation') 10 | .setVersion('1.0') 11 | .addBearerAuth() 12 | .build(); 13 | const document = SwaggerModule.createDocument(app, options); 14 | SwaggerModule.setup(uri, app, document); 15 | Logger.log(`Load API Documentation at http://localhost:${appPort}/${uri}`, 'Swagger'); 16 | } 17 | -------------------------------------------------------------------------------- /server/src/users/graphql/inputs/pagination.input.ts: -------------------------------------------------------------------------------- 1 | import { ArgsType, Field, Int } from '@nestjs/graphql'; 2 | import { IsOptional, Min } from 'class-validator'; 3 | import { StringQueryArgs } from '../../../shared/graphql/inputs'; 4 | 5 | @ArgsType() 6 | export class UserQueryArgs extends StringQueryArgs { 7 | @Field(type => Int) 8 | @Min(1) 9 | first: number; 10 | 11 | @Field(type => Int) 12 | @Min(0) 13 | offset: number; 14 | 15 | @Field({ nullable: true }) 16 | @IsOptional() 17 | cursor?: string; 18 | } 19 | -------------------------------------------------------------------------------- /server/src/users/graphql/types/provider.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from '@nestjs/graphql'; 2 | 3 | @ObjectType() 4 | export class Provider { 5 | @Field(() => ID) 6 | readonly id: string; 7 | 8 | @Field() 9 | readonly providerId: string; 10 | 11 | @Field() 12 | readonly name: string; 13 | } 14 | -------------------------------------------------------------------------------- /server/src/users/graphql/types/user.type.ts: -------------------------------------------------------------------------------- 1 | import { ObjectType, Field, ID } from '@nestjs/graphql'; 2 | import { Provider } from './provider.type'; 3 | 4 | @ObjectType() 5 | export class User { 6 | @Field(() => ID) 7 | readonly id: string; 8 | 9 | @Field(() => ID) 10 | readonly userId: string; 11 | 12 | @Field() 13 | readonly displayName: string; 14 | 15 | @Field() 16 | readonly email: string; 17 | 18 | @Field({ nullable: true }) 19 | readonly picture: string; 20 | 21 | @Field({ nullable: true }) 22 | readonly provider: string; 23 | 24 | @Field(type => [Provider], { nullable: true }) 25 | readonly providers: Provider[]; 26 | 27 | @Field(type => [String]) 28 | readonly roles: string[]; 29 | 30 | @Field({ nullable: true }) 31 | readonly facebook?: string; 32 | 33 | @Field({ nullable: true }) 34 | readonly github?: string; 35 | 36 | @Field({ nullable: true }) 37 | readonly google?: string; 38 | 39 | @Field({ nullable: true }) 40 | readonly linkedin?: string; 41 | 42 | @Field({ nullable: true }) 43 | readonly live?: string; 44 | 45 | @Field({ nullable: true }) 46 | readonly microsoft?: string; 47 | 48 | @Field({ nullable: true }) 49 | readonly twitter?: string; 50 | 51 | @Field({ nullable: true }) 52 | readonly windowslive?: string; 53 | } 54 | -------------------------------------------------------------------------------- /server/src/users/models/index.ts: -------------------------------------------------------------------------------- 1 | export * from './provider.interface'; 2 | export * from './user.interface'; 3 | -------------------------------------------------------------------------------- /server/src/users/models/provider.interface.ts: -------------------------------------------------------------------------------- 1 | export class Provider { 2 | id: string; 3 | name: string; 4 | } 5 | -------------------------------------------------------------------------------- /server/src/users/models/user.interface.ts: -------------------------------------------------------------------------------- 1 | import { Document } from 'mongoose'; 2 | import { Provider } from './provider.interface'; 3 | 4 | export interface User extends Document { 5 | readonly userId: string; 6 | readonly displayName: string; 7 | readonly email: string; 8 | readonly picture: string; 9 | readonly provider: string; 10 | readonly providers: Provider[]; 11 | readonly roles: string[]; 12 | readonly facebook?: string; 13 | readonly github?: string; 14 | readonly google?: string; 15 | readonly linkedin?: string; 16 | readonly live?: string; 17 | readonly microsoft?: string; 18 | readonly twitter?: string; 19 | readonly windowslive?: string; 20 | } 21 | -------------------------------------------------------------------------------- /server/src/users/users.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { APP_GUARD } from '@nestjs/core'; 3 | import { MongooseModule } from '@nestjs/mongoose'; 4 | 5 | import { UsersResolver } from './users.resolver'; 6 | import { UserSchema } from '../shared/schemas/user.schema'; 7 | import { UsersService } from './users.service'; 8 | import { GraphqlPassportAuthGuard } from '../shared/guards/graphql-passport-auth.guard'; 9 | 10 | @Module({ 11 | imports: [MongooseModule.forFeature([{ name: 'User', schema: UserSchema }])], 12 | providers: [ 13 | UsersResolver, 14 | UsersService, 15 | GraphqlPassportAuthGuard, 16 | ], 17 | }) 18 | export class UsersModule { } 19 | -------------------------------------------------------------------------------- /server/src/users/users.resolver.ts: -------------------------------------------------------------------------------- 1 | import { Args, Int, Query, Resolver } from '@nestjs/graphql'; 2 | import { UseGuards } from '@nestjs/common'; 3 | 4 | import { UsersService } from './users.service'; 5 | import { GraphqlPassportAuthGuard } from '../shared/guards'; 6 | import { PaginatedResponse } from '../shared/graphql/types/paginatedResponse.type'; 7 | import { User } from './graphql/types/user.type'; 8 | import { Roles } from '../shared/decorators/roles.decorator'; 9 | import { CurrentUser } from '../shared/decorators'; 10 | import { UserQueryArgs } from './graphql/inputs/pagination.input'; 11 | 12 | const PaginatedUserResponse = PaginatedResponse(User); 13 | type PaginatedUserResponse = InstanceType; 14 | 15 | @Resolver() 16 | export class UsersResolver { 17 | constructor( 18 | private readonly usersService: UsersService, 19 | ) { } 20 | 21 | @Query(returns => PaginatedUserResponse) 22 | @Roles('admin') 23 | @UseGuards(new GraphqlPassportAuthGuard('ADMIN')) 24 | async users(@Args() args: UserQueryArgs): Promise { 25 | return await this.usersService.getUsers(args); 26 | } 27 | 28 | @Query(() => User) 29 | @UseGuards(GraphqlPassportAuthGuard) 30 | whoAmI(@CurrentUser() user: User): User { 31 | return user; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /server/src/users/users.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, UseGuards, NotFoundException } from '@nestjs/common'; 2 | import { InjectModel } from '@nestjs/mongoose'; 3 | import { AuthGuard } from '@nestjs/passport'; 4 | import { Model } from 'mongoose'; 5 | 6 | import { User } from './models/user.interface'; 7 | import { UserQueryArgs } from './graphql/inputs/pagination.input'; 8 | import { getFilterByQuery } from '../shared/graphql/utils/utilities'; 9 | 10 | 11 | @Injectable() 12 | @UseGuards(AuthGuard('jwt')) 13 | export class UsersService { 14 | constructor(@InjectModel('User') private readonly userModel: Model) { } 15 | 16 | stringToBase64 = (data: any): string => Buffer.from(data).toString('base64'); 17 | base64ToString = (data: any): string => Buffer.from(data, 'base64').toString('ascii'); 18 | 19 | async findAll(): Promise { 20 | return await this.userModel.find().exec(); 21 | } 22 | 23 | async getUsers(userQueryArgs: UserQueryArgs): Promise<{ totalCount: number, nodes: User[], pageInfo?: { hasNextPage: boolean; endCursor: string; } }> { 24 | const { first = 1, offset = 0, filterBy, orderBy, cursor } = userQueryArgs; 25 | 26 | const findQuery = getFilterByQuery(filterBy) || {}; 27 | if (cursor) { 28 | findQuery['_id'] = { 29 | $lt: this.base64ToString(cursor) 30 | // $lt: (cursor) 31 | }; 32 | } 33 | let schema = this.userModel.find(findQuery); 34 | if (Array.isArray(orderBy)) { 35 | const sort = []; 36 | orderBy.forEach(sorter => sort.push([sorter.field, sorter.direction])); // [['name', 'asc']] 37 | schema = schema.sort(sort as any); 38 | } 39 | const query = schema.toConstructor(); 40 | const totalCount = await schema.countDocuments().exec(); 41 | let nodes: any = await new query().skip(offset).limit(first + 1).exec() || []; // add +1 to check if we have next page 42 | 43 | // let nodes = await query().limit(first + 1).exec(); // with cursor 44 | const hasNextPage = nodes.length > first; 45 | if (hasNextPage) { 46 | nodes = nodes.slice(0, -1); // remove unnecessary extra item pulled for hasNextPage check 47 | } 48 | return { totalCount, nodes, pageInfo: { hasNextPage, endCursor: hasNextPage ? this.stringToBase64(nodes[nodes.length - 1].id) : null } }; 49 | } 50 | 51 | async getTotalUserCount(): Promise { 52 | return await this.userModel.countDocuments().exec(); 53 | } 54 | 55 | async findById(id: string): Promise { 56 | const user = await this.userModel.findById(id).exec(); 57 | if (!user) { 58 | throw new NotFoundException('Could not find user.'); 59 | } 60 | return user; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import * as request from 'supertest'; 3 | import { AppModule } from './../src/app.module'; 4 | 5 | describe('AppController (e2e)', () => { 6 | let app; 7 | 8 | beforeEach(async () => { 9 | const moduleFixture: TestingModule = await Test.createTestingModule({ 10 | imports: [AppModule], 11 | }).compile(); 12 | 13 | app = moduleFixture.createNestApplication(); 14 | await app.init(); 15 | }); 16 | 17 | it('/ (GET)', () => { 18 | return request(app.getHttpServer()) 19 | .get('/') 20 | .expect(200) 21 | .expect('Hello World!'); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2018", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true 15 | }, 16 | "exclude": [ 17 | "node_modules", 18 | "dist" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "es2019", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true 14 | }, 15 | "exclude": [ 16 | "node_modules", 17 | "dist" 18 | ] 19 | } 20 | --------------------------------------------------------------------------------