├── .github └── workflows │ └── main.yml ├── .gitignore ├── .gitlab-ci.yml ├── .trivyignore ├── .vscode └── settings.json ├── LICENSE ├── README.md ├── api ├── .dockerignore ├── .editorconfig ├── .eslintrc.json ├── .gitignore ├── .prettierignore ├── .prettierrc.js ├── .vscode │ └── settings.json ├── Dockerfile ├── README.md ├── app │ ├── controllers │ │ ├── __tests__ │ │ │ └── authController.test.js │ │ ├── authController.js │ │ ├── meController.js │ │ ├── permissionController.js │ │ ├── settingController.js │ │ ├── todoController.js │ │ └── userController.js │ ├── helpers │ │ ├── __tests__ │ │ │ ├── response.test.js │ │ │ └── utils.test.js │ │ ├── authentication.js │ │ ├── config.js │ │ ├── database.js │ │ ├── logger.js │ │ ├── mail.js │ │ ├── response.js │ │ └── util.js │ ├── models │ │ ├── __tests__ │ │ │ └── authModel.test.js │ │ ├── authModel.js │ │ ├── permissionModel.js │ │ ├── settingModel.js │ │ ├── todoModel.js │ │ ├── userAuthModel.js │ │ └── userModel.js │ ├── routes │ │ ├── index.js │ │ └── validations │ │ │ ├── auth.js │ │ │ ├── auth │ │ │ ├── loginPost.js │ │ │ ├── passwordResetPost.js │ │ │ ├── passwordResetRequestPost.js │ │ │ ├── passwordResetVerifyGet.js │ │ │ ├── refreshTokenPost.js │ │ │ ├── registerConfirmGet.js │ │ │ └── registerPost.js │ │ │ ├── me.js │ │ │ ├── me │ │ │ ├── get.js │ │ │ └── patch.js │ │ │ ├── permission.js │ │ │ ├── permission │ │ │ └── listGet.js │ │ │ ├── setting.js │ │ │ ├── setting │ │ │ ├── delete.js │ │ │ ├── get.js │ │ │ ├── listGet.js │ │ │ ├── patch.js │ │ │ └── post.js │ │ │ ├── todo.js │ │ │ ├── todo │ │ │ ├── delete.js │ │ │ ├── get.js │ │ │ ├── listGet.js │ │ │ ├── listPost.js │ │ │ ├── patch.js │ │ │ └── post.js │ │ │ ├── user.js │ │ │ └── user │ │ │ ├── delete.js │ │ │ ├── get.js │ │ │ ├── listGet.js │ │ │ ├── patch.js │ │ │ └── post.js │ ├── server.js │ └── templates │ │ ├── password-reset.html │ │ └── register-confirm.html ├── config │ ├── custom-environment-variables.json │ ├── default.json │ └── test.json ├── database.json ├── image-files │ └── usr │ │ └── local │ │ └── bin │ │ ├── docker-entrypoint.sh │ │ └── migration.sh ├── jest.setup.js ├── migrations │ ├── 20201116105522-initial.js │ ├── 20220303121546-create-user-auth.js │ └── sqls │ │ ├── 20201116105522-initial-down.sql │ │ └── 20201116105522-initial-up.sql ├── package-lock.json ├── package.json └── webpack.config.prod.js ├── backend ├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .markdownlint.yml ├── .prettierignore ├── .prettierrc.js ├── .stylelintrc.json ├── .vscode │ └── settings.json ├── Dockerfile ├── README.md ├── babel.config.js ├── cypress.json ├── image-files │ ├── etc │ │ └── nginx │ │ │ └── nginx.conf │ └── usr │ │ └── local │ │ └── bin │ │ ├── docker-entrypoint.dev.sh │ │ └── docker-entrypoint.sh ├── jest.config.js ├── jsconfig.json ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ ├── manifest.json │ ├── robots.txt │ └── static │ │ └── config.json ├── src │ ├── App.vue │ ├── assets │ │ ├── css │ │ │ └── custom.css │ │ └── images │ │ │ ├── bootstrap-vue.png │ │ │ └── logo.png │ ├── components │ │ ├── FooterBar.vue │ │ ├── LoginBox.vue │ │ ├── NavBar.vue │ │ ├── TableBox.vue │ │ ├── TodoBox.vue │ │ └── UserFormBox.vue │ ├── helper │ │ └── utils.js │ ├── main.js │ ├── model │ │ ├── setting.js │ │ └── user.js │ ├── registerServiceWorker.js │ ├── router │ │ └── index.js │ ├── services │ │ ├── api.js │ │ ├── authService.js │ │ ├── configService.js │ │ ├── permissionService.js │ │ ├── settingService.js │ │ ├── setupInterceptors.js │ │ ├── todoService.js │ │ └── userService.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ │ ├── alert.js │ │ │ ├── auth.js │ │ │ ├── common.js │ │ │ ├── permission.js │ │ │ ├── setting.js │ │ │ ├── todo.js │ │ │ └── user.js │ └── views │ │ ├── Account │ │ ├── Account.vue │ │ └── AccountUpdate.vue │ │ ├── Home.vue │ │ ├── Layout │ │ ├── BackendLayout.vue │ │ └── SimpleLayout.vue │ │ ├── Login.vue │ │ ├── Logout.vue │ │ ├── Setting │ │ ├── SettingForm.vue │ │ └── SettingList.vue │ │ ├── Staff │ │ ├── StaffForm.vue │ │ └── StaffList.vue │ │ ├── Todo │ │ └── Todo.vue │ │ └── User │ │ ├── UserForm.vue │ │ └── UserList.vue ├── tests │ ├── e2e │ │ ├── .eslintrc.js │ │ ├── plugins │ │ │ └── index.js │ │ ├── specs │ │ │ └── login.js │ │ └── support │ │ │ ├── commands.js │ │ │ └── index.js │ └── unit │ │ ├── App.spec.js │ │ ├── components │ │ ├── FooterBar.spec.js │ │ └── LoginBox.spec.js │ │ ├── helper │ │ └── utils.spec.js │ │ ├── model │ │ └── user.spec.js │ │ ├── services │ │ └── authService.spec.js │ │ ├── store │ │ └── modules │ │ │ └── alert.spec.js │ │ └── views │ │ └── Home.spec.js └── vue.config.js ├── docker-compose.actions.yml ├── docker-compose.prod.yml ├── docker-compose.yml ├── frontend-nuxt ├── .babelrc ├── .dockerignore ├── .editorconfig ├── .env.dist ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── .vscode │ └── settings.json ├── Dockerfile ├── README.md ├── assets │ ├── README.md │ └── css │ │ └── custom.scss ├── components │ ├── FindPasswordBox.vue │ ├── FooterBar.vue │ ├── LoginBox.vue │ ├── NavBar.vue │ ├── PasswordResetBox.vue │ ├── README.md │ ├── RegisterBox.vue │ ├── TodoAddBox.vue │ └── TodoBox.vue ├── image-files │ └── usr │ │ └── local │ │ └── bin │ │ ├── create-env.sh │ │ └── docker-entrypoint.sh ├── jest.config.js ├── jsconfig.json ├── layouts │ ├── README.md │ ├── default.vue │ └── error.vue ├── middleware │ ├── README.md │ ├── check-auth.js │ ├── guest-only.js │ └── require-auth.js ├── nuxt.config.js ├── package-lock.json ├── package.json ├── pages │ ├── README.md │ ├── account │ │ ├── index.vue │ │ └── update.vue │ ├── find-password.vue │ ├── index.vue │ ├── login.vue │ ├── logout.vue │ ├── page.vue │ ├── password-reset.vue │ ├── register.vue │ └── todo │ │ └── index.vue ├── plugins │ ├── README.md │ ├── axios.js │ └── vuelidate.js ├── server │ └── index.js ├── services │ ├── authService.js │ ├── todoService.js │ └── userService.js ├── static │ ├── README.md │ ├── favicon.ico │ └── icon.png ├── store │ ├── README.md │ ├── alert.js │ ├── auth.js │ ├── common.js │ ├── index.js │ ├── todo.js │ └── user.js ├── stylelint.config.js └── test │ ├── components │ └── LoginBox.spec.js │ ├── pages │ └── index.spec.js │ ├── services │ └── authService.spec.js │ └── store │ └── modules │ └── alert.spec.js ├── frontend-vue ├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.js ├── .gitattributes ├── .gitignore ├── .markdownlint.yml ├── .prettierignore ├── .prettierrc.js ├── .stylelintrc.json ├── .vscode │ └── settings.json ├── Dockerfile ├── README.md ├── babel.config.js ├── cypress.json ├── image-files │ ├── etc │ │ └── nginx │ │ │ └── nginx.conf │ └── usr │ │ └── local │ │ └── bin │ │ ├── docker-entrypoint.dev.sh │ │ └── docker-entrypoint.sh ├── jest.config.js ├── jsconfig.json ├── lint-staged.config.js ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ ├── img │ │ └── icons │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon-120x120.png │ │ │ ├── apple-touch-icon-152x152.png │ │ │ ├── apple-touch-icon-180x180.png │ │ │ ├── apple-touch-icon-60x60.png │ │ │ ├── apple-touch-icon-76x76.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── msapplication-icon-144x144.png │ │ │ ├── mstile-150x150.png │ │ │ └── safari-pinned-tab.svg │ ├── index.html │ ├── manifest.json │ ├── robots.txt │ └── static │ │ └── config.json ├── src │ ├── App.vue │ ├── assets │ │ ├── css │ │ │ └── custom.css │ │ └── images │ │ │ ├── bootstrap-vue.png │ │ │ └── logo.png │ ├── components │ │ ├── FindPasswordBox.vue │ │ ├── FooterBar.vue │ │ ├── LoginBox.vue │ │ ├── NavBar.vue │ │ ├── PasswordResetBox.vue │ │ ├── RegisterBox.vue │ │ ├── TodoAddBox.vue │ │ └── TodoBox.vue │ ├── main.js │ ├── registerServiceWorker.js │ ├── router │ │ └── index.js │ ├── services │ │ ├── api.js │ │ ├── authService.js │ │ ├── configService.js │ │ ├── setupInterceptors.js │ │ ├── todoService.js │ │ └── userService.js │ ├── store │ │ ├── index.js │ │ └── modules │ │ │ ├── alert.js │ │ │ ├── auth.js │ │ │ ├── common.js │ │ │ ├── todo.js │ │ │ └── user.js │ └── views │ │ ├── Account │ │ ├── Account.vue │ │ └── AccountUpdate.vue │ │ ├── FindPassword.vue │ │ ├── Home.vue │ │ ├── Login.vue │ │ ├── Logout.vue │ │ ├── Page │ │ └── Page.vue │ │ ├── PasswordReset.vue │ │ ├── Register.vue │ │ └── Todo │ │ └── Todo.vue ├── tests │ ├── e2e │ │ ├── .eslintrc.js │ │ ├── plugins │ │ │ └── index.js │ │ ├── specs │ │ │ └── login.js │ │ └── support │ │ │ ├── commands.js │ │ │ └── index.js │ └── unit │ │ ├── App.spec.js │ │ ├── components │ │ └── LoginBox.spec.js │ │ ├── services │ │ └── authService.spec.js │ │ ├── store │ │ └── modules │ │ │ └── alert.spec.js │ │ └── views │ │ └── Home.spec.js └── vue.config.js ├── mysql ├── .gitignore ├── backup │ └── .gitkeep ├── boilerplate.mwb ├── conf.d │ └── my.cnf └── create-backup-sql.sh └── nginx ├── image-files └── etc │ └── nginx │ └── nginx.conf ├── index.html └── robots.txt /.gitignore: -------------------------------------------------------------------------------- 1 | 0. references/ 2 | **/trivy-result.json 3 | -------------------------------------------------------------------------------- /.trivyignore: -------------------------------------------------------------------------------- 1 | # Frontend-Nuxt 2 | ## glob-parent 3 | CVE-2020-28469 4 | 5 | ## trim-newlines 6 | CVE-2021-33623 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "eslint.enable": true, 6 | "eslint.alwaysShowStatus": true, 7 | "files.trimTrailingWhitespace": true, 8 | "files.exclude": {}, 9 | "files.insertFinalNewline": true, 10 | "search.exclude": { 11 | ".git": true, 12 | ".build": true, 13 | "**/.DS_Store": true, 14 | "**/.vscode": true, 15 | "**/coverage": true, 16 | "**/node_modules": true 17 | }, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": true 20 | }, 21 | "cSpell.words": [ 22 | "hadolint", 23 | "mailhog", 24 | "nuxt" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 chrisleekr 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 | -------------------------------------------------------------------------------- /api/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | 23 | coverage 24 | build 25 | -------------------------------------------------------------------------------- /api/.editorconfig: -------------------------------------------------------------------------------- 1 | indent_style = space 2 | indent_size = 2 3 | end_of_line = lf 4 | trim_trailing_whitespace = true 5 | insert_final_newline = true 6 | max_line_length = 120 7 | -------------------------------------------------------------------------------- /api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["airbnb", "prettier", "plugin:jest/recommended"], 3 | "plugins": ["prettier", "jest"], 4 | "rules": { 5 | "prettier/prettier": ["error"], 6 | "comma-dangle": ["error", "never"], 7 | "max-len": ["error", { "code": 120 }], 8 | "no-unused-vars": ["error", { "argsIgnorePattern": "^_" }] 9 | }, 10 | "settings": { 11 | "react": { 12 | "version": "999.999.999" 13 | } 14 | }, 15 | "overrides": [ 16 | { 17 | "files": ["migrations/**/*.js"], 18 | "rules": { 19 | "no-console": "off", 20 | "no-underscore-dangle": "off", 21 | "no-unused-vars": "off", 22 | "consistent-return": "off" 23 | } 24 | } 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | *.suo 17 | *.ntvs* 18 | *.njsproj 19 | *.sln 20 | *.sw? 21 | 22 | coverage 23 | build 24 | -------------------------------------------------------------------------------- /api/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /tests/unit/coverage/** 4 | -------------------------------------------------------------------------------- /api/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'none', 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | arrowParens: 'avoid', 11 | proseWrap: 'never', 12 | htmlWhitespaceSensitivity: 'strict', 13 | endOfLine: 'lf' 14 | }; 15 | -------------------------------------------------------------------------------- /api/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "eslint.enable": true, 6 | "eslint.alwaysShowStatus": true, 7 | "files.trimTrailingWhitespace": true, 8 | "files.exclude": {}, 9 | "files.insertFinalNewline": true, 10 | "search.exclude": { 11 | ".git": true, 12 | ".build": true, 13 | "**/.DS_Store": true, 14 | "**/.vscode": true, 15 | "**/coverage": true, 16 | "**/node_modules": true 17 | }, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": true 20 | }, 21 | "cSpell.words": ["mailhog"] 22 | } 23 | -------------------------------------------------------------------------------- /api/Dockerfile: -------------------------------------------------------------------------------- 1 | # development stage 2 | FROM node:16-alpine3.13 AS dev-stage 3 | 4 | RUN apk update && \ 5 | apk add --no-cache \ 6 | python3=3.8.10-r0 \ 7 | make=4.3-r0 \ 8 | g++=10.2.1_pre1-r3 \ 9 | mysql-client=10.5.15-r0 \ 10 | busybox=1.32.1-r8 \ 11 | libcrypto1.1=1.1.1o-r0 \ 12 | libssl1.1=1.1.1o-r0 \ 13 | ssl_client=1.32.1-r8 \ 14 | zlib=1.2.12-r1 15 | 16 | # Add configuration files 17 | COPY image-files/ / 18 | 19 | WORKDIR /srv 20 | 21 | COPY package*.json ./ 22 | 23 | # Upgrade npm due to vulnerabilities on packaged version 24 | RUN npm install -g npm@8.10.0 && \ 25 | npm install 26 | 27 | COPY . . 28 | 29 | EXPOSE 3000 30 | 31 | ENTRYPOINT [ "docker-entrypoint.sh" ] 32 | 33 | CMD [ "npm", "run", "dev" ] 34 | 35 | # build stage 36 | FROM dev-stage AS build-stage 37 | 38 | RUN npm install && \ 39 | npm run build 40 | 41 | # production stage 42 | FROM node:16-alpine3.13 AS production-stage 43 | 44 | RUN apk update && \ 45 | apk add --no-cache \ 46 | mysql-client=10.5.15-r0 \ 47 | busybox=1.32.1-r8 \ 48 | libcrypto1.1=1.1.1o-r0 \ 49 | libssl1.1=1.1.1o-r0 \ 50 | ssl_client=1.32.1-r8 \ 51 | zlib=1.2.12-r1 52 | 53 | # Add configuration files 54 | COPY image-files/ / 55 | 56 | WORKDIR /srv 57 | 58 | COPY --from=build-stage /srv /srv 59 | 60 | # Upgrade npm due to vulnerabilities on packaged version 61 | RUN npm install -g npm@8.10.0 && \ 62 | # Remove dev dependencies 63 | npm prune --omit=dev 64 | 65 | EXPOSE 3000 66 | 67 | ENTRYPOINT [ "docker-entrypoint.sh" ] 68 | 69 | CMD [ "node", "dist/server.js"] 70 | -------------------------------------------------------------------------------- /api/app/controllers/permissionController.js: -------------------------------------------------------------------------------- 1 | const permissionModel = require('../models/permissionModel'); 2 | const { logger } = require('../helpers/logger'); 3 | const { validateRequest, handleSuccess } = require('../helpers/response'); 4 | 5 | const moduleLogger = logger.child({ module: 'permissionController' }); 6 | 7 | const listPermissions = async (req, res) => { 8 | moduleLogger.info({}, 'listPermission called'); 9 | // Validate request 10 | const validationRequest = await validateRequest(req, res, {}); 11 | if (validationRequest !== null) { 12 | return validationRequest; 13 | } 14 | 15 | const searchOptions = { 16 | q: req.query.q || undefined 17 | }; 18 | 19 | const result = await permissionModel.findAll({ 20 | searchOptions 21 | }); 22 | return handleSuccess(res, '', result); 23 | }; 24 | 25 | module.exports = { 26 | listPermissions 27 | }; 28 | -------------------------------------------------------------------------------- /api/app/helpers/__tests__/utils.test.js: -------------------------------------------------------------------------------- 1 | const util = require('../util'); 2 | 3 | describe('utils', () => { 4 | let result; 5 | describe('getIPAddress', () => { 6 | describe('when x-forward-for is provided', () => { 7 | beforeEach(() => { 8 | result = util.getIPAddress({ 9 | headers: { 10 | 'x-forwarded-for': '123.123.123.123' 11 | } 12 | }); 13 | }); 14 | 15 | it('returns expected value', () => { 16 | expect(result).toBe('123.123.123.123'); 17 | }); 18 | }); 19 | 20 | describe('when x-forward-for is not provided', () => { 21 | beforeEach(() => { 22 | result = util.getIPAddress({ 23 | headers: {}, 24 | connection: { 25 | remoteAddress: '111.222.333.444' 26 | } 27 | }); 28 | }); 29 | 30 | it('returns expected value', () => { 31 | expect(result).toBe('111.222.333.444'); 32 | }); 33 | }); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /api/app/helpers/config.js: -------------------------------------------------------------------------------- 1 | const config = require('dotenv').config(); 2 | 3 | module.export = config; 4 | -------------------------------------------------------------------------------- /api/app/helpers/database.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const mysql = require('promise-mysql'); 3 | const { logger } = require('./logger'); 4 | 5 | const moduleLogger = logger.child({ module: 'database' }); 6 | 7 | const dbConfig = { 8 | connectionLimit: config.get('db.connectionLimit'), 9 | host: config.get('db.host'), 10 | port: config.get('db.port'), 11 | user: config.get('db.user'), 12 | password: config.get('db.password'), 13 | database: config.get('db.name'), 14 | debug: config.get('db.debug') === 'true' ? ['ComQueryPacket', 'RowDataPacket'] : false, 15 | timezone: (new Date().getTimezoneOffset() / 60) * -1 16 | }; 17 | moduleLogger.debug({ dbConfig }); 18 | 19 | let pool; 20 | 21 | const getPool = async () => { 22 | if (pool) { 23 | moduleLogger.info('Return existing pool connection'); 24 | return pool; 25 | } 26 | pool = await mysql.createPool(dbConfig); 27 | moduleLogger.info('Return new pool connection'); 28 | return pool; 29 | }; 30 | 31 | const currentTimestamp = { 32 | toSqlString: () => 'CURRENT_TIMESTAMP()' 33 | }; 34 | 35 | const fetchWithPagination = async (query, values, { page = 1, pageSize = 10 } = {}) => { 36 | const regexStart = Date.now(); 37 | 38 | const countQuery = query.replace(/(?<=SELECT)(\s.*?)+(?=FROM)/gim, ' COUNT(*) AS total '); 39 | const elapsedMs = Date.now() - regexStart; 40 | 41 | moduleLogger.debug({ countQuery, elapsedMs }, 'Replace query for counting with regex'); 42 | 43 | // Get total first 44 | const totalResult = await (await getPool()).query(countQuery, values); 45 | 46 | const pageNo = page > 0 ? page - 1 : 0; 47 | const offset = pageNo * pageSize; 48 | const rowQuery = `${query} LIMIT ${offset}, ${pageSize}`; 49 | const rowResult = await (await getPool()).query(rowQuery, values); 50 | 51 | const totalRows = totalResult[0].total || 0; 52 | const firstRowNo = totalRows - offset; 53 | 54 | // Since this return value will be output, make underscore variable 55 | return { 56 | rows: rowResult, 57 | pagination: { 58 | page, 59 | page_size: pageSize, 60 | total_rows: totalRows, 61 | first_row_no: firstRowNo 62 | } 63 | }; 64 | }; 65 | 66 | module.exports = { 67 | getPool, 68 | currentTimestamp, 69 | fetchWithPagination 70 | }; 71 | -------------------------------------------------------------------------------- /api/app/helpers/logger.js: -------------------------------------------------------------------------------- 1 | const bunyan = require('bunyan'); 2 | const packageJson = require('../../package.json'); 3 | 4 | const logger = bunyan.createLogger({ 5 | name: 'api', 6 | version: packageJson.version, 7 | streams: [{ stream: process.stdout, level: process.env.NODE_ENV !== 'test' ? bunyan.TRACE : bunyan.FATAL }] 8 | }); 9 | logger.info({ NODE_ENV: process.env.NODE_ENV }, 'API logger loaded'); 10 | 11 | module.exports = { bunyan, logger }; 12 | -------------------------------------------------------------------------------- /api/app/helpers/mail.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const _ = require('lodash'); 3 | const fs = require('fs'); 4 | const nodemailer = require('nodemailer'); 5 | 6 | const { logger } = require('./logger'); 7 | 8 | class Mail { 9 | constructor() { 10 | this.moduleLogger = logger.child({ module: 'mail' }); 11 | 12 | this.transportConfig = { 13 | host: config.get('smtp.host'), 14 | port: config.get('smtp.port'), 15 | secure: config.get('smtp.secure') 16 | }; 17 | 18 | if (config.get('smtp.secure') === true) { 19 | this.transportConfig.auth.user = config.get('smtp.authUser'); 20 | this.transportConfig.auth.pass = config.get('smtp.authPass'); 21 | } 22 | 23 | if (config.get('smtp.debug') === true) { 24 | this.transportConfig.debug = true; 25 | } 26 | if (config.get('smtp.logger') === true) { 27 | this.transportConfig.logger = true; 28 | } 29 | 30 | this.transporter = nodemailer.createTransport(this.transportConfig); 31 | 32 | this.moduleLogger.debug({ transportConfig: this.transportConfig }, 'mail class initialized'); 33 | } 34 | 35 | static replaceTemplateValues(content, templateValues) { 36 | let convertedContent = content; 37 | _.forEach(templateValues, (value, key) => { 38 | convertedContent = _.replace(convertedContent, new RegExp(`{${key}}`, 'g'), value); 39 | }); 40 | return convertedContent; 41 | } 42 | 43 | async sendEmail({ from, to, subject, templatePath, templateValues } = {}) { 44 | this.moduleLogger.debug({ from, to, subject, templatePath, templateValues }, 'sendEmail triggered'); 45 | 46 | fs.readFile(templatePath, { encoding: 'utf-8' }, (err, html) => { 47 | if (err) { 48 | this.moduleLogger.error(err, 'Error occurred while reading template file'); 49 | // should not throw error because it will crash the app 50 | return; 51 | } 52 | 53 | const renderedHtml = Mail.replaceTemplateValues(html, templateValues); 54 | this.moduleLogger.debug({ renderedHtml }, 'HTML file loaded for email, send now'); 55 | 56 | this.transporter.sendMail({ 57 | from: from || `"${config.get('email.fromName')}" <${config.get('email.fromAddress')}>`, 58 | to, 59 | subject, 60 | html: renderedHtml 61 | }); 62 | }); 63 | } 64 | } 65 | 66 | module.exports = new Mail(); 67 | -------------------------------------------------------------------------------- /api/app/helpers/util.js: -------------------------------------------------------------------------------- 1 | const getIPAddress = req => { 2 | return req.headers['x-forwarded-for'] || req.connection.remoteAddress; 3 | }; 4 | 5 | module.exports = { getIPAddress }; 6 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth.js: -------------------------------------------------------------------------------- 1 | const authLoginPost = require('./auth/loginPost'); 2 | const authRegisterPost = require('./auth/registerPost'); 3 | const authRegisterConfirmGet = require('./auth/registerConfirmGet'); 4 | const authPasswordResetRequestPost = require('./auth/passwordResetRequestPost'); 5 | const authPasswordResetVerifyGet = require('./auth/passwordResetVerifyGet'); 6 | const authPasswordResetPost = require('./auth/passwordResetPost'); 7 | const authRefreshTokenPost = require('./auth/refreshTokenPost'); 8 | 9 | module.exports = { 10 | authLoginPost, 11 | authRegisterPost, 12 | authRegisterConfirmGet, 13 | authPasswordResetRequestPost, 14 | authPasswordResetVerifyGet, 15 | authPasswordResetPost, 16 | authRefreshTokenPost 17 | }; 18 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/loginPost.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | username: { 3 | in: ['body'], 4 | isEmpty: { 5 | errorMessage: 'Username must be provided.', 6 | negated: true 7 | } 8 | }, 9 | password: { 10 | in: ['body'], 11 | isEmpty: { 12 | errorMessage: 'Password must be provided.', 13 | negated: true 14 | }, 15 | isLength: { 16 | errorMessage: 'Password should be at least 6 chars long.', 17 | options: { min: 6 } 18 | } 19 | } 20 | }; 21 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/passwordResetPost.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | key: { 6 | in: ['body'], 7 | isEmpty: { 8 | errorMessage: 'Key must be provided.', 9 | negated: true 10 | }, 11 | custom: { 12 | options: async value => { 13 | if (value === undefined) { 14 | return false; 15 | } 16 | // Check password reset token 17 | const user = await userModel.getOne({ 18 | searchOptions: { password_reset_token: value, status: userModel.userStatus.active } 19 | }); 20 | 21 | if (_.isEmpty(user)) { 22 | throw new Error('Key is invalid.'); 23 | } 24 | return true; 25 | } 26 | } 27 | }, 28 | password: { 29 | in: ['body'], 30 | isEmpty: { 31 | errorMessage: 'Password must be provided.', 32 | negated: true 33 | }, 34 | isLength: { 35 | errorMessage: 'Password should be at least 6 chars long.', 36 | options: { min: 6 } 37 | } 38 | } 39 | }; 40 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/passwordResetRequestPost.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | email: { 6 | in: ['body'], 7 | isEmpty: { 8 | errorMessage: 'Email must be provided.', 9 | negated: true 10 | }, 11 | isEmail: { 12 | errorMessage: 'Email must be valid. i.e. john@doe.com' 13 | }, 14 | normalizeEmail: true, 15 | isLength: { 16 | errorMessage: 'Email should be less than 255 chars long.', 17 | options: { max: 255 } 18 | }, 19 | custom: { 20 | options: async value => { 21 | // Check duplicated email 22 | const user = await userModel.getOne({ 23 | searchOptions: { email: value }, 24 | includeDeletedUser: false 25 | }); 26 | 27 | if (_.isEmpty(user)) { 28 | throw new Error('Email cannot be found.'); 29 | } 30 | return true; 31 | } 32 | } 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/passwordResetVerifyGet.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | key: { 6 | in: ['query'], 7 | isEmpty: { 8 | errorMessage: 'Key must be provided.', 9 | negated: true 10 | }, 11 | custom: { 12 | options: async value => { 13 | if (value === undefined) { 14 | return false; 15 | } 16 | // Check password reset token 17 | const user = await userModel.getOne({ 18 | searchOptions: { password_reset_token: value, status: userModel.userStatus.active } 19 | }); 20 | 21 | if (_.isEmpty(user)) { 22 | throw new Error('Key is invalid.'); 23 | } 24 | return true; 25 | } 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/refreshTokenPost.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | refreshToken: { 3 | in: ['body'], 4 | isEmpty: { 5 | errorMessage: 'RefreshToken must be provided.', 6 | negated: true 7 | } 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /api/app/routes/validations/auth/registerConfirmGet.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | key: { 6 | in: ['query'], 7 | isEmpty: { 8 | errorMessage: 'Key must be provided.', 9 | negated: true 10 | }, 11 | custom: { 12 | options: async value => { 13 | if (value === undefined) { 14 | return false; 15 | } 16 | // Check register confirmation key 17 | const user = await userModel.getOne({ 18 | searchOptions: { auth_key: value, status: userModel.userStatus.pending } 19 | }); 20 | 21 | if (_.isEmpty(user)) { 22 | throw new Error('Key is invalid.'); 23 | } 24 | return true; 25 | } 26 | } 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /api/app/routes/validations/me.js: -------------------------------------------------------------------------------- 1 | const meGet = require('./me/get'); 2 | const mePatch = require('./me/patch'); 3 | 4 | module.exports = { 5 | meGet, 6 | mePatch 7 | }; 8 | -------------------------------------------------------------------------------- /api/app/routes/validations/me/get.js: -------------------------------------------------------------------------------- 1 | module.exports = {}; 2 | -------------------------------------------------------------------------------- /api/app/routes/validations/me/patch.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | const { getTokenData } = require('../../../helpers/authentication'); 4 | 5 | module.exports = { 6 | first_name: { 7 | in: ['body'], 8 | isEmpty: { 9 | errorMessage: 'First name must be provided.', 10 | negated: true 11 | }, 12 | isLength: { 13 | errorMessage: 'First name should be less than 50 chars long.', 14 | options: { max: 50 } 15 | } 16 | }, 17 | last_name: { 18 | in: ['body'], 19 | isEmpty: { 20 | errorMessage: 'Last name must be provided.', 21 | negated: true 22 | }, 23 | isLength: { 24 | errorMessage: 'Last name should be less than 50 chars long.', 25 | options: { max: 50 } 26 | } 27 | }, 28 | password: { 29 | in: ['body'], 30 | isLength: { 31 | errorMessage: 'Password should be at least 6 chars long.', 32 | options: { min: 6 } 33 | }, 34 | optional: { options: { nullable: true, checkFalsy: true } } 35 | }, 36 | email: { 37 | in: ['body'], 38 | isEmpty: { 39 | errorMessage: 'Email must be provided.', 40 | negated: true 41 | }, 42 | isEmail: { 43 | errorMessage: 'Email must be valid. i.e. john@doe.com' 44 | }, 45 | normalizeEmail: true, 46 | isLength: { 47 | errorMessage: 'Email should be less than 255 chars long.', 48 | options: { max: 255 } 49 | }, 50 | custom: { 51 | options: async (value, { req }) => { 52 | if (value === undefined) { 53 | return false; 54 | } 55 | const tokenData = await getTokenData(req); 56 | if (!tokenData) { 57 | return false; 58 | } 59 | 60 | // Check duplicated email 61 | const user = await userModel.getOne({ 62 | searchOptions: { email: value, excludeId: tokenData.id }, 63 | includeDeletedUser: false 64 | }); 65 | 66 | if (_.isEmpty(user) === false) { 67 | throw new Error('Email is already in use.'); 68 | } 69 | return true; 70 | } 71 | } 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /api/app/routes/validations/permission.js: -------------------------------------------------------------------------------- 1 | const permissionListGet = require('./permission/listGet'); 2 | 3 | module.exports = { 4 | permissionListGet 5 | }; 6 | -------------------------------------------------------------------------------- /api/app/routes/validations/permission/listGet.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | q: { 3 | in: ['query'], 4 | optional: { options: { nullable: false } }, 5 | escape: true 6 | } 7 | }; 8 | -------------------------------------------------------------------------------- /api/app/routes/validations/setting.js: -------------------------------------------------------------------------------- 1 | const settingListGet = require('./setting/listGet'); 2 | const settingGet = require('./setting/get'); 3 | const settingPost = require('./setting/post'); 4 | const settingPatch = require('./setting/patch'); 5 | const settingDelete = require('./setting/delete'); 6 | 7 | module.exports = { 8 | settingListGet, 9 | settingGet, 10 | settingPost, 11 | settingPatch, 12 | settingDelete 13 | }; 14 | -------------------------------------------------------------------------------- /api/app/routes/validations/setting/delete.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const settingModel = require('../../../models/settingModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async settingId => { 13 | // Retrieve the setting 14 | const setting = await settingModel.getOne({ 15 | searchOptions: { id: settingId } 16 | }); 17 | 18 | // If requested setting does not exist, then return error 19 | if (_.isEmpty(setting)) { 20 | throw new Error('Setting does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/app/routes/validations/setting/get.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const settingModel = require('../../../models/settingModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async settingId => { 13 | // Retrieve the setting 14 | const setting = await settingModel.getOne({ 15 | searchOptions: { id: settingId } 16 | }); 17 | 18 | // If requested setting does not exist, then return error 19 | if (_.isEmpty(setting)) { 20 | throw new Error('Setting does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/app/routes/validations/setting/listGet.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | page: { 3 | in: ['query'], 4 | optional: { options: { nullable: false } }, 5 | isInt: { 6 | errorMessage: 'Page must be valid.' 7 | }, 8 | customSanitizer: { 9 | options: value => { 10 | if (value === undefined) { 11 | return 1; 12 | } 13 | let tmpValue = parseInt(value, 10); 14 | 15 | if (tmpValue < 1) { 16 | tmpValue = 1; 17 | } 18 | 19 | return tmpValue; 20 | } 21 | } 22 | }, 23 | page_size: { 24 | in: ['query'], 25 | optional: { options: { nullable: false } }, 26 | isInt: { 27 | errorMessage: 'Page size must be valid.', 28 | options: { max: 100 } 29 | }, 30 | customSanitizer: { 31 | options: value => { 32 | if (value === undefined) { 33 | return 1; 34 | } 35 | let tmpValue = parseInt(value, 10); 36 | 37 | if (tmpValue < 1) { 38 | tmpValue = 1; 39 | } 40 | 41 | return tmpValue; 42 | } 43 | } 44 | }, 45 | q: { 46 | in: ['query'], 47 | optional: { options: { nullable: false } }, 48 | escape: true 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo.js: -------------------------------------------------------------------------------- 1 | const todoListGet = require('./todo/listGet'); 2 | const todoListPost = require('./todo/listPost'); 3 | const todoGet = require('./todo/get'); 4 | const todoPost = require('./todo/post'); 5 | const todoPatch = require('./todo/patch'); 6 | const todoDelete = require('./todo/delete'); 7 | 8 | module.exports = { 9 | todoListGet, 10 | todoListPost, 11 | todoGet, 12 | todoPost, 13 | todoPatch, 14 | todoDelete 15 | }; 16 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/delete.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const todoModel = require('../../../models/todoModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async todoId => { 13 | // Retrieve the todo 14 | const todo = await todoModel.getOne({ 15 | searchOptions: { id: todoId } 16 | }); 17 | 18 | // If requested todo does not exist, then return error 19 | if (_.isEmpty(todo)) { 20 | throw new Error('Todo does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/get.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const todoModel = require('../../../models/todoModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async todoId => { 13 | // Retrieve the todo 14 | const todo = await todoModel.getOne({ 15 | searchOptions: { id: todoId } 16 | }); 17 | 18 | // If requested todo does not exist, then return error 19 | if (_.isEmpty(todo)) { 20 | throw new Error('Todo does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/listGet.js: -------------------------------------------------------------------------------- 1 | const todoModel = require('../../../models/todoModel'); 2 | 3 | module.exports = { 4 | page: { 5 | in: ['query'], 6 | optional: { options: { nullable: false } }, 7 | isInt: { 8 | errorMessage: 'Page must be valid.' 9 | }, 10 | customSanitizer: { 11 | options: value => { 12 | if (value === undefined) { 13 | return 1; 14 | } 15 | let tmpValue = parseInt(value, 10); 16 | 17 | if (tmpValue < 1) { 18 | tmpValue = 1; 19 | } 20 | 21 | return tmpValue; 22 | } 23 | } 24 | }, 25 | page_size: { 26 | in: ['query'], 27 | optional: { options: { nullable: false } }, 28 | isInt: { 29 | errorMessage: 'Page size must be valid.', 30 | options: { max: 100 } 31 | }, 32 | customSanitizer: { 33 | options: value => { 34 | if (value === undefined) { 35 | return 1; 36 | } 37 | let tmpValue = parseInt(value, 10); 38 | 39 | if (tmpValue < 1) { 40 | tmpValue = 1; 41 | } 42 | 43 | return tmpValue; 44 | } 45 | } 46 | }, 47 | q: { 48 | in: ['query'], 49 | optional: { options: { nullable: false } }, 50 | escape: true 51 | }, 52 | order_by: { 53 | in: ['query'], 54 | custom: { 55 | options: value => { 56 | if (value === undefined) { 57 | return true; 58 | } 59 | 60 | if (todoModel.todoOrderBy[value] === undefined) { 61 | throw new Error('Order by must be valid.'); 62 | } 63 | 64 | return true; 65 | } 66 | } 67 | } 68 | }; 69 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/listPost.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | todo: { 3 | in: ['body'] 4 | }, 5 | 'todo.*.id': { 6 | in: ['body'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number.' 9 | }, 10 | optional: { options: { nullable: true } } 11 | }, 12 | 'todo.*.name': { 13 | in: ['body'], 14 | isEmpty: { 15 | errorMessage: 'Todo must be provided.', 16 | negated: true 17 | }, 18 | isLength: { 19 | errorMessage: 'Todo should be less than 200 chars long.', 20 | options: { max: 200 } 21 | } 22 | }, 23 | 'todo.*.note': { 24 | in: ['body'], 25 | isLength: { 26 | errorMessage: 'Note should be less than 1000 chars long.', 27 | options: { max: 1000 } 28 | } 29 | } 30 | }; 31 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/patch.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const todoModel = require('../../../models/todoModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async todoId => { 13 | // Retrieve the todo 14 | const todo = await todoModel.getOne({ 15 | searchOptions: { id: todoId } 16 | }); 17 | 18 | // If requested todo does not exist, then return error 19 | if (_.isEmpty(todo)) { 20 | throw new Error('Todo does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | }, 26 | name: { 27 | in: ['body'], 28 | isLength: { 29 | errorMessage: 'Todo should be less than 200 chars long.', 30 | options: { max: 200 } 31 | } 32 | }, 33 | note: { 34 | in: ['body'], 35 | isLength: { 36 | errorMessage: 'Note should be less than 1000 chars long.', 37 | options: { max: 1000 } 38 | } 39 | }, 40 | state: { 41 | in: ['body'], 42 | custom: { 43 | options: value => { 44 | if (value === undefined) { 45 | return true; 46 | } 47 | 48 | const valid = _.includes(todoModel.todoState, value); 49 | if (!valid) { 50 | throw new Error('State must be valid.'); 51 | } 52 | 53 | return true; 54 | } 55 | } 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /api/app/routes/validations/todo/post.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const todoModel = require('../../../models/todoModel'); 3 | 4 | module.exports = { 5 | name: { 6 | in: ['body'], 7 | isEmpty: { 8 | errorMessage: 'Todo must be provided.', 9 | negated: true 10 | }, 11 | isLength: { 12 | errorMessage: 'Todo should be at least 200 chars long.', 13 | options: { max: 200 } 14 | } 15 | }, 16 | note: { 17 | in: ['body'], 18 | isLength: { 19 | errorMessage: 'Note should be less than 1000 chars long.', 20 | options: { max: 1000 } 21 | } 22 | }, 23 | state: { 24 | in: ['body'], 25 | isEmpty: { 26 | errorMessage: 'State must be provided.', 27 | negated: true 28 | }, 29 | custom: { 30 | options: value => { 31 | const valid = _.includes(todoModel.todoState, value); 32 | if (!valid) { 33 | throw new Error('State must be valid.'); 34 | } 35 | 36 | return true; 37 | } 38 | } 39 | } 40 | }; 41 | -------------------------------------------------------------------------------- /api/app/routes/validations/user.js: -------------------------------------------------------------------------------- 1 | const userListGet = require('./user/listGet'); 2 | const userGet = require('./user/get'); 3 | const userPost = require('./user/post'); 4 | const userPatch = require('./user/patch'); 5 | const userDelete = require('./user/delete'); 6 | 7 | module.exports = { 8 | userListGet, 9 | userGet, 10 | userPost, 11 | userPatch, 12 | userDelete 13 | }; 14 | -------------------------------------------------------------------------------- /api/app/routes/validations/user/delete.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async (userId, { req }) => { 13 | // Retrieve the user 14 | const user = await userModel.getOne({ 15 | searchOptions: { id: userId, roles: userModel.getUserRoles(req.params.roleType) } 16 | }); 17 | 18 | // If requested user does not exist, then return error 19 | if (_.isEmpty(user)) { 20 | throw new Error('User does not exist in the database.'); 21 | } 22 | 23 | if (+userId === 1) { 24 | throw new Error('Root administrator cannot be deleted.'); 25 | } 26 | 27 | return true; 28 | } 29 | } 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /api/app/routes/validations/user/get.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const userModel = require('../../../models/userModel'); 3 | 4 | module.exports = { 5 | id: { 6 | in: ['params'], 7 | isNumeric: { 8 | errorMessage: 'ID must be number', 9 | options: { no_symbols: true } 10 | }, 11 | custom: { 12 | options: async (userId, { req }) => { 13 | // Retrieve the user 14 | const user = await userModel.getOne({ 15 | searchOptions: { id: userId, roles: userModel.getUserRoles(req.params.roleType) } 16 | }); 17 | 18 | // If requested user does not exist, then return error 19 | if (_.isEmpty(user)) { 20 | throw new Error('User does not exist in the database.'); 21 | } 22 | return true; 23 | } 24 | } 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /api/app/routes/validations/user/listGet.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | page: { 3 | in: ['query'], 4 | optional: { options: { nullable: false } }, 5 | isInt: { 6 | errorMessage: 'Page must be valid.' 7 | }, 8 | customSanitizer: { 9 | options: value => { 10 | if (value === undefined) { 11 | return 1; 12 | } 13 | let tmpValue = parseInt(value, 10); 14 | 15 | if (tmpValue < 1) { 16 | tmpValue = 1; 17 | } 18 | 19 | return tmpValue; 20 | } 21 | } 22 | }, 23 | page_size: { 24 | in: ['query'], 25 | optional: { options: { nullable: false } }, 26 | isInt: { 27 | errorMessage: 'Page size must be valid.', 28 | options: { max: 100 } 29 | }, 30 | customSanitizer: { 31 | options: value => { 32 | if (value === undefined) { 33 | return 1; 34 | } 35 | let tmpValue = parseInt(value, 10); 36 | 37 | if (tmpValue < 1) { 38 | tmpValue = 1; 39 | } 40 | 41 | return tmpValue; 42 | } 43 | } 44 | }, 45 | q: { 46 | in: ['query'], 47 | optional: { options: { nullable: false } }, 48 | escape: true 49 | } 50 | }; 51 | -------------------------------------------------------------------------------- /api/app/server.js: -------------------------------------------------------------------------------- 1 | const config = require('config'); 2 | const express = require('express'); 3 | const cors = require('cors'); 4 | const helmet = require('helmet'); 5 | const compression = require('compression'); 6 | const bodyParser = require('body-parser'); 7 | const bunyanMiddleware = require('bunyan-middleware'); 8 | const { logger } = require('./helpers/logger'); 9 | 10 | const app = express(); 11 | 12 | const port = config.get('port') || 3000; 13 | 14 | app.set('trust proxy', true); 15 | app.use(helmet()); 16 | app.use(cors()); 17 | app.use(compression()); 18 | app.use( 19 | bunyanMiddleware({ 20 | headerName: 'X-Request-Id', 21 | propertyName: 'reqId', 22 | logName: 'reqId', 23 | obscureHeaders: ['authorization'], 24 | logger, 25 | additionalRequestFinishData: (_req, _res) => ({}) 26 | }) 27 | ); 28 | 29 | app.use(bodyParser.urlencoded({ extended: true })); 30 | app.use(bodyParser.json()); 31 | 32 | require('./routes/index')(app); 33 | 34 | // catch 404 and forward to error handler 35 | app.get('*', (_req, res) => { 36 | res.send({ success: false, status: 404, message: 'Page not found.', data: {} }, 404); 37 | }); 38 | 39 | const server = app.listen(port); 40 | 41 | logger.info(`API server started on: ${port}`); 42 | 43 | module.exports = { app, server }; 44 | -------------------------------------------------------------------------------- /api/config/custom-environment-variables.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": { 3 | "__name": "PORT", 4 | "__format": "number" 5 | }, 6 | "tz": "TZ", 7 | "bcryptSaltingRound": { 8 | "__name": "BCRYPT_SALTING_ROUND", 9 | "__format": "number" 10 | }, 11 | "apiUrl": "API_URL", 12 | "backendUrl": "BACKEND_URL", 13 | "frontendUrl": "FRONTEND_URL", 14 | "db": { 15 | "host": "DB_HOST", 16 | "port": { 17 | "__name": "DB_PORT", 18 | "__format": "number" 19 | }, 20 | "user": "DB_USER", 21 | "password": "DB_PASSWORD", 22 | "name": "DB_NAME", 23 | "debug": { 24 | "__name": "DB_DEBUG", 25 | "__format": "boolean" 26 | }, 27 | "connectionLimit": { 28 | "__name": "DB_CONNECTION_LIMIT", 29 | "__format": "number" 30 | } 31 | }, 32 | "smtp": { 33 | "host": "SMTP_HOST", 34 | "port": { 35 | "__name": "SMTP_PORT", 36 | "__format": "number" 37 | }, 38 | "secure": { 39 | "__name": "SMTP_SECURE", 40 | "__format": "boolean" 41 | }, 42 | "authUser": "SMTP_AUTH_USER", 43 | "authPass": "SMTP_AUTH_PASS", 44 | "debug": { 45 | "__name": "SMTP_DEBUG", 46 | "__format": "boolean" 47 | }, 48 | "logger": { 49 | "__name": "SMTP_LOGGER", 50 | "__format": "boolean" 51 | } 52 | }, 53 | "email": { 54 | "fromName": "EMAIL_FROM_NAME", 55 | "fromAddress": "EMAIL_FROM_ADDRESS" 56 | }, 57 | "jwt": { 58 | "secretKey": "JWT_SECRET_KEY", 59 | "expiresIn": "JWT_EXPIRES_IN", 60 | "refreshSecretKey": "JWT_REFRESH_SECRET_KEY", 61 | "refreshExpiresIn": "JWT_REFRESH_EXPIRES_IN" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /api/config/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "port": 3000, 3 | "tz": "Australia/Melbourne", 4 | "bcryptSaltingRound": 10, 5 | "apiUrl": "http://localhost/api", 6 | "backendUrl": "http://localhost/backend", 7 | "frontendUrl": "http://localhost/frontend-nuxt", 8 | "db": { 9 | "host": "mysql", 10 | "port": 3306, 11 | "user": "root", 12 | "password": "", 13 | "name": "boilerplate", 14 | "debug": true, 15 | "connectionLimit": 10 16 | }, 17 | "smtp": { 18 | "host": "mailhog", 19 | "port": 1025, 20 | "secure": false, 21 | "authUser": "", 22 | "authPass": "", 23 | "debug": true, 24 | "logger": true 25 | }, 26 | "email": { 27 | "fromName": "Support", 28 | "fromAddress": "support@boilerplate.local" 29 | }, 30 | "jwt": { 31 | "secretKey": "", 32 | "expiresIn": "1w", 33 | "refreshSecretKey": "", 34 | "refreshExpiresIn": "1w" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /api/config/test.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /api/database.json: -------------------------------------------------------------------------------- 1 | { 2 | "dev": { 3 | "driver": "mysql", 4 | "host": { "ENV": "DB_HOST" }, 5 | "database": { "ENV": "DB_NAME" }, 6 | "user": { "ENV": "DB_USER" }, 7 | "password": { "ENV": "DB_PASSWORD" }, 8 | "multipleStatements": true 9 | }, 10 | "test": { 11 | "driver": "mysql", 12 | "host": { "ENV": "DB_HOST" }, 13 | "database": { "ENV": "DB_NAME" }, 14 | "user": { "ENV": "DB_USER" }, 15 | "password": { "ENV": "DB_PASSWORD" }, 16 | "multipleStatements": true 17 | }, 18 | "prod": { 19 | "driver": "mysql", 20 | "host": { "ENV": "DB_HOST" }, 21 | "database": { "ENV": "DB_NAME" }, 22 | "user": { "ENV": "DB_USER" }, 23 | "password": { "ENV": "DB_PASSWORD" }, 24 | "multipleStatements": true 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /api/image-files/usr/local/bin/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo " _____ __________.___" 4 | echo " / _ \\______ \ |" 5 | echo " / /_\ \| ___/ |" 6 | echo "/ | \ | | |" 7 | echo "\____|__ /____| |___|" 8 | echo " \/" 9 | 10 | set -e 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /api/image-files/usr/local/bin/migration.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo " _____ .__ __ .__ " 4 | echo " / \ |__| ________________ _/ |_|__| ____ ____ " 5 | echo " / \ / \| |/ ___\_ __ \__ \\ __\ |/ _ \ / \ " 6 | echo "/ Y \ / /_/ > | \// __ \| | | ( <_> ) | \ " 7 | echo "\____|__ /__\___ /|__| (____ /__| |__|\____/|___| / " 8 | echo " \/ /_____/ \/ \/ " 9 | 10 | set -e 11 | 12 | printf "Checking database connection...\n\n" 13 | mysql_ready() { 14 | mysqladmin ping --host="$DB_HOST" --user="$DB_USER" --password="$DB_PASSWORD" > /dev/null 2>&1 15 | } 16 | 17 | while ! (mysql_ready) 18 | do 19 | sleep 3 20 | echo "Waiting for database connection ..." 21 | done 22 | 23 | echo "Database is ready..." 24 | 25 | echo "Starting migration..." 26 | npm run migrate:up 27 | echo "Finished migration..." 28 | 29 | -------------------------------------------------------------------------------- /api/jest.setup.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | /* eslint-disable no-await-in-loop */ 3 | const { getPool } = require('./app/helpers/database'); 4 | 5 | // Override port for supertest 6 | process.env.PORT = 3001; 7 | 8 | // eslint-disable-next-line no-promise-executor-return 9 | const timeout = () => new Promise(resolve => setTimeout(resolve, 3000)); 10 | 11 | const isDatabaseReady = async () => { 12 | console.log('Checking database is ready or not before running test...'); 13 | const limit = 20; 14 | 15 | for (let count = 1; count < limit; count += 1) { 16 | console.log(`Checking ${count} time${count >= 1 ? 's' : ''}`); 17 | try { 18 | const user = await ( 19 | await getPool() 20 | ).query( 21 | ` 22 | SELECT 23 | * 24 | FROM user 25 | LIMIT 1 26 | ` 27 | ); 28 | 29 | if (user[0]) { 30 | console.log(`Database is connected, start testing...`); 31 | 32 | (await getPool()).end(); 33 | break; 34 | } else { 35 | console.log(`Database is not yet connected, wait for a second and check again...`); 36 | await timeout(); 37 | } 38 | } catch (e) { 39 | console.log(`Error occurred. Database is not yet connected, wait for a second and check again...`); 40 | await timeout(); 41 | } 42 | } 43 | }; 44 | 45 | module.exports = async () => { 46 | await isDatabaseReady(); 47 | }; 48 | -------------------------------------------------------------------------------- /api/migrations/20201116105522-initial.js: -------------------------------------------------------------------------------- 1 | let dbm; 2 | let type; 3 | let seed; 4 | const fs = require('fs'); 5 | const path = require('path'); 6 | 7 | let Promise; 8 | 9 | /** 10 | * We receive the dbmigrate dependency from dbmigrate initially. 11 | * This enables us to not have to rely on NODE_PATH. 12 | */ 13 | exports.setup = (options, seedLink) => { 14 | dbm = options.dbmigrate; 15 | type = dbm.dataType; 16 | seed = seedLink; 17 | Promise = options.Promise; 18 | }; 19 | 20 | exports.up = db => { 21 | const filePath = path.join(__dirname, 'sqls', '20201116105522-initial-up.sql'); 22 | return new Promise((resolve, reject) => { 23 | fs.readFile(filePath, { encoding: 'utf-8' }, (err, data) => { 24 | if (err) return reject(err); 25 | console.log(`received data: ${data}`); 26 | 27 | resolve(data); 28 | }); 29 | }).then(data => db.runSql(data)); 30 | }; 31 | 32 | exports.down = db => { 33 | const filePath = path.join(__dirname, 'sqls', '20201116105522-initial-down.sql'); 34 | return new Promise((resolve, reject) => { 35 | fs.readFile(filePath, { encoding: 'utf-8' }, (err, data) => { 36 | if (err) return reject(err); 37 | console.log(`received data: ${data}`); 38 | 39 | resolve(data); 40 | }); 41 | }).then(data => db.runSql(data)); 42 | }; 43 | 44 | exports._meta = { 45 | version: 1 46 | }; 47 | -------------------------------------------------------------------------------- /api/migrations/20220303121546-create-user-auth.js: -------------------------------------------------------------------------------- 1 | let dbm; 2 | let type; 3 | let seed; 4 | 5 | let Promise; 6 | 7 | /** 8 | * We receive the dbmigrate dependency from dbmigrate initially. 9 | * This enables us to not have to rely on NODE_PATH. 10 | */ 11 | exports.setup = (options, seedLink) => { 12 | dbm = options.dbmigrate; 13 | type = dbm.dataType; 14 | seed = seedLink; 15 | Promise = options.Promise; 16 | }; 17 | 18 | exports.up = db => 19 | // Create new table user_auth 20 | db 21 | .createTable('user_auth', { 22 | id: { type: 'bigint', length: 11, primaryKey: true, autoIncrement: true }, 23 | user_id: { 24 | type: 'bigint', 25 | length: 11, 26 | foreignKey: { 27 | name: 'user_user_id_fk', 28 | table: 'user', 29 | rules: { 30 | onDelete: 'CASCADE', 31 | onUpdate: 'RESTRICT' 32 | }, 33 | mapping: 'id' 34 | } 35 | }, 36 | auth_key: { type: 'string', length: 255, notNull: true }, 37 | auth_key_expired_at: { type: 'timestamp', notNull: true }, 38 | refresh_auth_key: { type: 'string', length: 255, notNull: true }, 39 | refresh_auth_key_expired_at: { type: 'timestamp', notNull: true }, 40 | status: { type: 'tinyint', defaultValue: 1 }, 41 | // eslint-disable-next-line no-new-wrappers 42 | created_at: { type: 'timestamp', defaultValue: new String('CURRENT_TIMESTAMP') }, 43 | updated_at: { type: 'timestamp' } 44 | }) 45 | .then(() => { 46 | db.runSql( 47 | // eslint-disable-next-line max-len 48 | 'ALTER TABLE `user_auth` CHANGE COLUMN `updated_at` `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP AFTER `created_at`' 49 | ); 50 | }); 51 | 52 | exports.down = db => db.dropTable('user_auth'); 53 | 54 | exports._meta = { 55 | version: 1 56 | }; 57 | -------------------------------------------------------------------------------- /api/migrations/sqls/20201116105522-initial-down.sql: -------------------------------------------------------------------------------- 1 | /* Replace with your SQL commands */ 2 | 3 | DROP TABLE IF EXISTS `permission`; 4 | 5 | DROP TABLE IF EXISTS `permission_user`; 6 | 7 | DROP TABLE IF EXISTS `setting`; 8 | 9 | DROP TABLE IF EXISTS `todo`; 10 | 11 | DROP TABLE IF EXISTS `user`; 12 | -------------------------------------------------------------------------------- /api/webpack.config.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | 4 | const { NODE_ENV = 'production' } = process.env; 5 | 6 | const nodeModules = {}; 7 | fs.readdirSync('node_modules') 8 | .filter(x => ['.bin'].indexOf(x) === -1) 9 | .forEach(mod => { 10 | nodeModules[mod] = `commonjs ${mod}`; 11 | }); 12 | 13 | module.exports = { 14 | entry: './app/server.js', 15 | target: 'node', 16 | mode: NODE_ENV, 17 | output: { 18 | filename: 'server.js', 19 | path: path.resolve(__dirname, 'dist') 20 | }, 21 | resolve: { 22 | extensions: ['.js'] 23 | }, 24 | module: { 25 | rules: [ 26 | { 27 | // Transpiles ES6-8 into ES5 28 | loader: 'babel-loader', 29 | test: /\.js$/, 30 | exclude: /node_modules/, 31 | options: { 32 | plugins: ['lodash'], 33 | presets: [['@babel/env', { targets: { node: 13 } }]] 34 | } 35 | } 36 | ] 37 | }, 38 | externals: nodeModules 39 | }; 40 | -------------------------------------------------------------------------------- /backend/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /backend/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | Dockerfile 27 | /public/static/config.json 28 | coverage -------------------------------------------------------------------------------- /backend/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /backend/.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | /*.config.js 6 | /test/unit/coverage/ 7 | -------------------------------------------------------------------------------- /backend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ['plugin:vue/essential', '@vue/airbnb', 'prettier'], 7 | rules: { 8 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 9 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | 'comma-dangle': ['error', 'never'], 11 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 12 | 'no-shadow': 'off' 13 | }, 14 | parserOptions: { 15 | parser: 'babel-eslint' 16 | }, 17 | settings: { 18 | 'import/resolver': { 19 | node: { 20 | extensions: ['.js', '.jsx', '.vue'] 21 | } 22 | } 23 | }, 24 | overrides: [ 25 | { 26 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 27 | env: { 28 | jest: true 29 | } 30 | } 31 | ] 32 | }; 33 | -------------------------------------------------------------------------------- /backend/.gitattributes: -------------------------------------------------------------------------------- 1 | # Fix end-of-lines in Git versions older than 2.10 2 | # https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248 3 | * text=auto eol=lf 4 | 5 | # === 6 | # Binary Files (don't diff, don't fix line endings) 7 | # === 8 | 9 | # Images 10 | *.png binary 11 | *.jpg binary 12 | *.jpeg binary 13 | *.gif binary 14 | *.ico binary 15 | *.tiff binary 16 | 17 | # Fonts 18 | *.oft binary 19 | *.ttf binary 20 | *.eot binary 21 | *.woff binary 22 | *.woff2 binary 23 | 24 | # Videos 25 | *.mov binary 26 | *.mp4 binary 27 | *.webm binary 28 | *.ogg binary 29 | *.mpg binary 30 | *.3gp binary 31 | *.avi binary 32 | *.wmv binary 33 | *.flv binary 34 | *.asf binary 35 | 36 | # Audio 37 | *.mp3 binary 38 | *.wav binary 39 | *.flac binary 40 | 41 | # Compressed 42 | *.gz binary 43 | *.zip binary 44 | *.7z binary 45 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | coverage 26 | -------------------------------------------------------------------------------- /backend/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # === 4 | # Rule customizations for markdownlint go here 5 | # https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md 6 | # === 7 | 8 | # Disable line length restrictions, because editor soft-wrapping is being 9 | # used instead. 10 | line-length: false 11 | 12 | # === 13 | # Prettier overrides 14 | # === 15 | 16 | no-multiple-blanks: false 17 | list-marker-space: false 18 | -------------------------------------------------------------------------------- /backend/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /tests/unit/coverage/** 4 | -------------------------------------------------------------------------------- /backend/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'none', 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | arrowParens: 'avoid', 11 | proseWrap: 'never', 12 | htmlWhitespaceSensitivity: 'strict', 13 | endOfLine: 'lf' 14 | }; 15 | -------------------------------------------------------------------------------- /backend/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-css-modules", 5 | "stylelint-config-prettier", 6 | "stylelint-config-recess-order", 7 | "stylelint-scss", 8 | "stylelint-config-recommended-vue" 9 | ], 10 | "rules": { 11 | "alpha-value-notation": ["percentage", { "exceptProperties": ["opacity"] }] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /backend/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "eslint.enable": true, 6 | "eslint.alwaysShowStatus": true, 7 | "files.trimTrailingWhitespace": true, 8 | "files.exclude": {}, 9 | "files.insertFinalNewline": true, 10 | "search.exclude": { 11 | ".git": true, 12 | ".build": true, 13 | "**/.DS_Store": true, 14 | "**/.vscode": true, 15 | "**/coverage": true, 16 | "**/node_modules": true 17 | }, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": true 20 | }, 21 | "cSpell.words": [] 22 | } 23 | -------------------------------------------------------------------------------- /backend/Dockerfile: -------------------------------------------------------------------------------- 1 | # initial stage 2 | FROM node:16-buster AS initial-stage 3 | 4 | RUN apt-get update -y && \ 5 | apt-get install -yq --no-install-recommends \ 6 | cmake=3.13.4-1 \ 7 | build-essential=12.6 \ 8 | libpng-dev=1.6.36-6 \ 9 | libjpeg-dev=1:1.5.2-2+deb10u1 \ 10 | gifsicle=1.91-5 \ 11 | xvfb=2:1.20.4-1+deb10u4 \ 12 | libgtk-3-dev=3.24.5-1 \ 13 | libnotify-dev=0.7.7-4 \ 14 | libgconf-2-4=3.2.6-5 \ 15 | libnss3=2:3.42.1-1+deb10u5 \ 16 | libxss1=1:1.2.3-1 \ 17 | libasound2=1.1.8-1 && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | WORKDIR /srv 21 | 22 | COPY package*.json ./ 23 | 24 | RUN npm install 25 | 26 | # build stage 27 | FROM initial-stage AS build-stage 28 | 29 | ARG NODE_ENV 30 | ARG BASE_URL 31 | ENV NODE_ENV=${NODE_ENV} 32 | ENV BASE_URL=${BASE_URL} 33 | 34 | # Add configuration files 35 | COPY image-files/ / 36 | 37 | WORKDIR /srv 38 | 39 | COPY . . 40 | 41 | RUN npm run build --mode=production 42 | 43 | ENTRYPOINT [ "docker-entrypoint.dev.sh" ] 44 | 45 | # production stage 46 | FROM nginx:stable-alpine AS production-stage 47 | 48 | RUN apk update && \ 49 | apk add --no-cache \ 50 | xz-libs=5.2.5-r1 \ 51 | freetype=2.10.4-r2 52 | 53 | # Add configuration files 54 | COPY image-files/ / 55 | 56 | COPY --from=build-stage /srv/dist /srv 57 | 58 | EXPOSE 80 59 | 60 | ENTRYPOINT [ "docker-entrypoint.sh" ] 61 | 62 | CMD ["nginx", "-g", "daemon off;"] 63 | -------------------------------------------------------------------------------- /backend/README.md: -------------------------------------------------------------------------------- 1 | # Backend for Node.js + Vue.js boilerplate 2 | 3 | Vue.js, Vuex, Vue Router, Vue Draggable, Vuelidate, BootstrapVue, Jest, Cypress 4 | 5 | ## How to start for development 6 | 7 | ```bash 8 | npm run serve 9 | ``` 10 | 11 | Opent the browser and go to [http://localhost:8081](http://localhost:8081) 12 | 13 | ## Features & Pages 14 | 15 | - Authentication with JSON Web Token (JWT) 16 | - Permission based navigation 17 | - Pages 18 | - Login 19 | - Dashboard 20 | - User Management 21 | - Staff Management 22 | - Todo Management 23 | - Setting Management 24 | 25 | ## Todo 26 | 27 | - [x] Unit tests 28 | - [x] E2E tests 29 | - [ ] File uploader 30 | -------------------------------------------------------------------------------- /backend/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | }; 4 | -------------------------------------------------------------------------------- /backend/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /backend/image-files/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | sendfile on; 16 | keepalive_timeout 65; 17 | 18 | server { 19 | listen 80; 20 | sendfile on; 21 | default_type application/octet-stream; 22 | 23 | gzip on; 24 | gzip_http_version 1.1; 25 | gzip_disable "MSIE [1-6]\."; 26 | gzip_min_length 256; 27 | gzip_vary on; 28 | gzip_proxied expired no-cache no-store private auth; 29 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 30 | gzip_comp_level 9; 31 | 32 | root /srv/; 33 | 34 | location ^~ / { 35 | expires -1; 36 | add_header Pragma "no-cache"; 37 | add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0"; 38 | 39 | try_files $uri $uri/ /index.html = 404; 40 | } 41 | 42 | #error_page 404 /404.html; 43 | 44 | # redirect server error pages to the static page /50x.html 45 | # 46 | 47 | error_page 500 502 503 504 /50x.html; 48 | location = /50x.html { 49 | root /srv; 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /backend/image-files/usr/local/bin/docker-entrypoint.dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | mkdir -p /srv/static 6 | 7 | cat < /srv/public/static/config.json 8 | { 9 | "apiUrl": "$API_URL", 10 | "format": { 11 | "timeZone": "Australia/Melbourne", 12 | "dateTime": "YYYY-MM-DD HH:mm:ss", 13 | "pickerDateTime": "yyyy-MM-dd HH:mm" 14 | } 15 | } 16 | EOT 17 | 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /backend/image-files/usr/local/bin/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | mkdir -p /srv/static 6 | 7 | cat < /srv/static/config.json 8 | { 9 | "apiUrl": "$API_URL", 10 | "format": { 11 | "timeZone": "Australia/Melbourne", 12 | "dateTime": "YYYY-MM-DD HH:mm:ss", 13 | "pickerDateTime": "yyyy-MM-dd HH:mm" 14 | } 15 | } 16 | EOT 17 | 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /backend/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest', 3 | resetMocks: true, 4 | collectCoverage: true, 5 | collectCoverageFrom: [ 6 | 'src/**/*.{js,vue}', 7 | '!**/node_modules/**', 8 | '!**/tests/**', 9 | '!**/coverage/**', 10 | '!src/main.js', 11 | '!src/registerServiceWorker.js' 12 | ] 13 | }; 14 | -------------------------------------------------------------------------------- /backend/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /backend/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['npm run lint:eslint', 'npm run lint:prettier', 'git add', 'npm run test:unit'], 3 | '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['npm run lint:prettier', 'git add'], 4 | 'package.json': ['npm run lint:prettier', 'git add'], 5 | '*.vue': ['npm run lint:eslint', 'npm run lint:stylelint', 'npm run lint:prettier', 'git add', 'npm run test:unit'], 6 | '*.scss': ['npm run lint:stylelint', 'npm run lint:prettier', 'git add'], 7 | '*.md': ['npm run lint:markdownlint', 'npm run lint:prettier', 'git add'], 8 | '*.{png,jpeg,jpg,gif,svg}': ['imagemin-lint-staged', 'git add'] 9 | }; 10 | -------------------------------------------------------------------------------- /backend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /backend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/favicon.ico -------------------------------------------------------------------------------- /backend/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /backend/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /backend/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /backend/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /backend/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /backend/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /backend/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /backend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Backend 9 | 10 | 11 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /backend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "backend", 3 | "short_name": "backend", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "./index.html", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /backend/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /backend/public/static/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiUrl": "http://localhost/api", 3 | "format": { 4 | "timeZone": "Australia/Melbourne", 5 | "dateTime": "YYYY-MM-DD HH:mm:ss", 6 | "pickerDateTime": "yyyy-MM-dd HH:mm" 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /backend/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 23 | 24 | 36 | -------------------------------------------------------------------------------- /backend/src/assets/css/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-y: scroll; 3 | } 4 | 5 | .page-title { 6 | padding-bottom: 0.5rem; 7 | border-bottom: 1px solid #d9d9d9; 8 | display: block; 9 | } 10 | 11 | .navbar-dark .navbar-nav .nav-link.router-link-exact-active { 12 | color: #fff; 13 | font-weight: bold; 14 | } 15 | 16 | .spinner { 17 | /* Spinner size and color */ 18 | width: 1rem; 19 | height: 1rem; 20 | border-top-color: #444; 21 | border-left-color: #444; 22 | 23 | /* Additional spinner styles */ 24 | animation: spinner 400ms linear infinite; 25 | border-bottom-color: transparent; 26 | border-right-color: transparent; 27 | border-style: solid; 28 | border-width: 2px; 29 | border-radius: 50%; 30 | box-sizing: border-box; 31 | display: inline-block; 32 | vertical-align: middle; 33 | } 34 | 35 | /* Animation styles */ 36 | @keyframes spinner { 37 | 0% { 38 | transform: rotate(0deg); 39 | } 40 | 100% { 41 | transform: rotate(360deg); 42 | } 43 | } 44 | 45 | /* Optional — create your own variations! */ 46 | .spinner-large { 47 | width: 5rem; 48 | height: 5rem; 49 | border-width: 6px; 50 | } 51 | 52 | .spinner-slow { 53 | animation: spinner 1s linear infinite; 54 | } 55 | 56 | .spinner-white { 57 | border-top-color: #fff; 58 | border-left-color: #fff; 59 | } 60 | 61 | .spinner-blue { 62 | border-top-color: #09d; 63 | border-left-color: #09d; 64 | } 65 | 66 | .page-title { 67 | font-size: 1.5rem; 68 | font-weight: bold; 69 | } 70 | 71 | .message-col { 72 | white-space: pre; 73 | } 74 | 75 | .todo-pending-list { 76 | color: #404040; 77 | } 78 | 79 | .todo-ongoing-list { 80 | color: #000000; 81 | font-weight: bold; 82 | } 83 | 84 | .todo-completed-list { 85 | color: #7b7b7b; 86 | text-decoration: line-through; 87 | } 88 | 89 | .todo-completed-span { 90 | text-decoration: line-through; 91 | } 92 | 93 | .todo-completed-list { 94 | color: #777; 95 | } 96 | 97 | .no-list-item { 98 | font-style: italic; 99 | font-size: 0.9rem; 100 | color: #777; 101 | } 102 | 103 | .span-block { 104 | display: block; 105 | } 106 | 107 | .span-help-text { 108 | font-size: 0.7rem; 109 | } 110 | -------------------------------------------------------------------------------- /backend/src/assets/images/bootstrap-vue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/src/assets/images/bootstrap-vue.png -------------------------------------------------------------------------------- /backend/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/backend/src/assets/images/logo.png -------------------------------------------------------------------------------- /backend/src/components/FooterBar.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 17 | -------------------------------------------------------------------------------- /backend/src/components/TodoBox.vue: -------------------------------------------------------------------------------- 1 | 39 | 40 | 57 | -------------------------------------------------------------------------------- /backend/src/helper/utils.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import moment from 'moment'; 3 | 4 | const toQueryStrings = params => 5 | Object.keys(params) 6 | .map(key => `${key}=${params[key]}`) 7 | .join('&'); 8 | 9 | const validateDateTime = value => { 10 | if (_.isEmpty(value)) { 11 | return true; 12 | } 13 | 14 | return moment.parseZone(value).isValid(); 15 | }; 16 | 17 | export default { 18 | toQueryStrings, 19 | validateDateTime 20 | }; 21 | -------------------------------------------------------------------------------- /backend/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueMeta from 'vue-meta'; 3 | import Vuelidate from 'vuelidate'; 4 | import BootstrapVue from 'bootstrap-vue'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | import 'bootstrap-vue/dist/bootstrap-vue.css'; 7 | import VueSweetalert2 from 'vue-sweetalert2'; 8 | import '@sweetalert2/theme-bootstrap-4/bootstrap-4.scss'; 9 | import { library } from '@fortawesome/fontawesome-svg-core'; 10 | import { 11 | faPortrait, 12 | faTrash, 13 | faPlus, 14 | faCheckSquare, 15 | faSquare, 16 | faUser, 17 | faTachometerAlt, 18 | faCog, 19 | faUserCircle, 20 | faSignOutAlt, 21 | faEdit, 22 | faTrashAlt, 23 | faLongArrowAltLeft, 24 | faSave, 25 | faListAlt 26 | } from '@fortawesome/free-solid-svg-icons'; 27 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 28 | import { Datetime } from 'vue-datetime'; 29 | import 'vue-datetime/dist/vue-datetime.css'; 30 | import ConfigService from '@/services/configService'; 31 | 32 | import App from './App.vue'; 33 | import router from './router'; 34 | import store from './store'; 35 | import './registerServiceWorker'; 36 | 37 | import SimpleLayout from './views/Layout/SimpleLayout.vue'; 38 | import BackendLayout from './views/Layout/BackendLayout.vue'; 39 | 40 | import setupInterceptors from './services/setupInterceptors'; 41 | 42 | Vue.config.productionTip = false; 43 | 44 | Vue.use(VueMeta, { 45 | // optional pluginOptions 46 | refreshOnceOnNavigation: true 47 | }); 48 | Vue.use(Vuelidate); 49 | Vue.use(BootstrapVue); 50 | Vue.use(VueSweetalert2, {}); 51 | Vue.component('datetime', Datetime); 52 | 53 | library.add( 54 | faPortrait, 55 | faTrash, 56 | faTrashAlt, 57 | faPlus, 58 | faCheckSquare, 59 | faSquare, 60 | faUser, 61 | faTachometerAlt, 62 | faCog, 63 | faUserCircle, 64 | faSignOutAlt, 65 | faEdit, 66 | faLongArrowAltLeft, 67 | faSave, 68 | faListAlt 69 | ); 70 | Vue.component('font-awesome-icon', FontAwesomeIcon); 71 | Vue.component('simple-layout', SimpleLayout); 72 | Vue.component('backend-layout', BackendLayout); 73 | 74 | ConfigService.loadConfig().then(() => { 75 | setupInterceptors(store); 76 | 77 | new Vue({ 78 | router, 79 | store, 80 | render: h => h(App) 81 | }).$mount('#app'); 82 | }); 83 | -------------------------------------------------------------------------------- /backend/src/model/setting.js: -------------------------------------------------------------------------------- 1 | export default class Setting { 2 | static metaTypes = { 3 | select: 'select', 4 | number: 'number', 5 | text: 'text' 6 | }; 7 | 8 | static isPublicTypes = { 9 | public: 1, 10 | private: 0 11 | }; 12 | 13 | constructor({ rowNum, id, metaKey, metaName, metaType, metaDesc, metaAttribute, metaValue, isPublic }) { 14 | this.rowNum = rowNum || null; 15 | this.id = id; 16 | this.metaKey = metaKey; 17 | this.metaName = metaName; 18 | this.metaDesc = metaDesc; 19 | this.metaType = metaType; 20 | this.metaAttribute = metaAttribute; 21 | this.metaValue = metaValue; 22 | this.isPublic = isPublic; 23 | 24 | this.expand(); 25 | } 26 | 27 | expand() { 28 | this.settingName = `${this.metaName} (${this.metaKey})
${this.metaDesc}`; 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /backend/src/model/user.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment'; 2 | 3 | export default class User { 4 | static userEnabled = { 5 | active: 1, 6 | disabled: 0 7 | }; 8 | 9 | static userRole = { 10 | administrator: 99, 11 | staff: 50, 12 | user: 1 13 | }; 14 | 15 | constructor({ 16 | rowNum, 17 | id, 18 | username, 19 | firstName, 20 | lastName, 21 | email, 22 | confirmedAt, 23 | registrationIp, 24 | lastLoginAt, 25 | lastLoginIp, 26 | blockedAt, 27 | role, 28 | roleName, 29 | permissions, 30 | enabled, 31 | enabledName 32 | }) { 33 | this.rowNum = rowNum || null; 34 | this.id = id; 35 | this.username = username; 36 | this.firstName = firstName; 37 | this.lastName = lastName; 38 | this.email = email; 39 | this.confirmedAt = confirmedAt; 40 | this.registrationIp = registrationIp; 41 | this.lastLoginAt = lastLoginAt; 42 | this.lastLoginIp = lastLoginIp; 43 | this.blockedAt = blockedAt; 44 | this.role = role; 45 | this.roleName = roleName; 46 | this.permissions = permissions; 47 | this.enabled = enabled; 48 | this.enabledName = enabledName; 49 | 50 | this.expand(); 51 | } 52 | 53 | expand() { 54 | let confirmedAtFormatted = null; 55 | 56 | if (moment(this.confirmedAt).isValid()) { 57 | confirmedAtFormatted = moment(this.confirmedAt).from(); 58 | } else { 59 | confirmedAtFormatted = null; 60 | } 61 | this.confirmedAtFormatted = confirmedAtFormatted; 62 | 63 | let lastLoginAtFormatted = null; 64 | 65 | if (moment(this.lastLoginAt).isValid()) { 66 | lastLoginAtFormatted = moment(this.lastLoginAt).from(); 67 | } else { 68 | lastLoginAtFormatted = null; 69 | } 70 | 71 | this.lastLoginAtFormatted = lastLoginAtFormatted; 72 | 73 | let blockedAtFormatted = null; 74 | 75 | if (moment(this.blockedAt).isValid()) { 76 | blockedAtFormatted = moment(this.blockedAt).from(); 77 | } else { 78 | blockedAtFormatted = null; 79 | } 80 | 81 | this.blockedAtFormatted = blockedAtFormatted; 82 | } 83 | 84 | get fullName() { 85 | return `${this.firstName}, ${this.lastName}`; 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /backend/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log('App is being served from cache by a service worker.\nFor more details, visit https://goo.gl/AFskqB'); 9 | }, 10 | registered() { 11 | console.log('Service worker has been registered.'); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updatefound() { 17 | console.log('New content is downloading.'); 18 | }, 19 | updated() { 20 | console.log('New content is available; please refresh.'); 21 | }, 22 | offline() { 23 | console.log('No internet connection found. App is running in offline mode.'); 24 | }, 25 | error(error) { 26 | console.error('Error during service worker registration:', error); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /backend/src/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const instance = axios.create({ 4 | headers: { 5 | 'Content-Type': 'application/json' 6 | } 7 | }); 8 | export default instance; 9 | -------------------------------------------------------------------------------- /backend/src/services/authService.js: -------------------------------------------------------------------------------- 1 | import configService from '@/services/configService'; 2 | import api from './api'; 3 | 4 | export default { 5 | async login(username, password) { 6 | return api 7 | .post(`${configService.get('apiUrl')}/staff/login`, { 8 | username, 9 | password 10 | }) 11 | .then(response => response.data); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/services/configService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as vueConfig from '../../vue.config'; 3 | 4 | class ConfigService { 5 | constructor() { 6 | this.config = {}; 7 | } 8 | 9 | async loadConfig() { 10 | const response = await axios.get(`${vueConfig.publicPath}static/config.json`); 11 | this.config = response.data; 12 | } 13 | 14 | set(key, value) { 15 | this.config[key] = value; 16 | } 17 | 18 | get(key) { 19 | return this.config[key]; 20 | } 21 | } 22 | 23 | export default new ConfigService(); 24 | -------------------------------------------------------------------------------- /backend/src/services/permissionService.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import configService from '@/services/configService'; 3 | import utils from '@/helper/utils'; 4 | 5 | import api from './api'; 6 | 7 | export default { 8 | async list({ query = {} } = {}) { 9 | const pickedQuery = _.pick(query, ['page', 'page_size', 'q']); 10 | let url = `${configService.get('apiUrl')}/permission`; 11 | if (pickedQuery.length) { 12 | url += `?${utils.toQueryStrings(pickedQuery)}`; 13 | } 14 | 15 | return api.get(url, {}).then(response => response.data); 16 | } 17 | }; 18 | -------------------------------------------------------------------------------- /backend/src/services/settingService.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import configService from '@/services/configService'; 3 | import utils from '@/helper/utils'; 4 | import api from './api'; 5 | 6 | export default { 7 | async list({ query = {} } = {}) { 8 | const pickedQuery = _.pick(query, ['page', 'page_size', 'q']); 9 | let url = `${configService.get('apiUrl')}/setting`; 10 | if (pickedQuery.length) { 11 | url += `?${utils.toQueryStrings(pickedQuery)}`; 12 | } 13 | 14 | return api.get(url, {}).then(response => response.data); 15 | }, 16 | 17 | async getOne({ settingId }) { 18 | return api.get(`${configService.get('apiUrl')}/setting/${settingId}`, {}).then(response => response.data); 19 | }, 20 | 21 | async postOne({ setting } = {}) { 22 | return api.post(`${configService.get('apiUrl')}/setting`, setting).then(response => response.data); 23 | }, 24 | 25 | async patchOne({ settingId, newSetting }) { 26 | return api.patch(`${configService.get('apiUrl')}/setting/${settingId}`, newSetting).then(response => response.data); 27 | }, 28 | 29 | async deleteOne({ settingId }) { 30 | return api.delete(`/setting/${settingId}`).then(response => response.data); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /backend/src/services/setupInterceptors.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import axiosInstance from './api'; 3 | import configService from './configService'; 4 | 5 | const setupInterceptors = store => { 6 | axiosInstance.interceptors.request.use( 7 | config => { 8 | const token = localStorage.getItem('backend-auth-key') || ''; 9 | if (token) { 10 | // eslint-disable-next-line no-param-reassign 11 | config.headers.Authorization = token; 12 | } 13 | return config; 14 | }, 15 | error => Promise.reject(error) 16 | ); 17 | axiosInstance.interceptors.response.use( 18 | res => res, 19 | async err => { 20 | const originalConfig = err.config; 21 | if (originalConfig.url !== `${configService.get('apiUrl')}/staff/login` && err.response) { 22 | // Access Token was expired 23 | if (err.response.status === 401 && !originalConfig._retry) { 24 | originalConfig._retry = true; 25 | try { 26 | const response = await axiosInstance.post(`${configService.get('apiUrl')}/refresh-token`, { 27 | refreshToken: localStorage.getItem('backend-refresh-auth-key') || '' 28 | }); 29 | const { data } = response.data; 30 | 31 | store.commit('auth/loginSuccess', { authKey: data.auth_key, refreshAuthKey: data.refresh_auth_key }); 32 | 33 | return axiosInstance(originalConfig); 34 | } catch (error) { 35 | return Promise.reject(error); 36 | } 37 | } 38 | } 39 | return Promise.reject(err); 40 | } 41 | ); 42 | }; 43 | export default setupInterceptors; 44 | -------------------------------------------------------------------------------- /backend/src/services/todoService.js: -------------------------------------------------------------------------------- 1 | import configService from '@/services/configService'; 2 | import api from './api'; 3 | 4 | export default { 5 | async list({ state = undefined } = {}) { 6 | let url = `${configService.get('apiUrl')}/todo`; 7 | if (state) { 8 | url += `/${state}`; 9 | } 10 | 11 | return api.get(url, {}).then(response => response.data); 12 | } 13 | }; 14 | -------------------------------------------------------------------------------- /backend/src/services/userService.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import configService from '@/services/configService'; 3 | import utils from '@/helper/utils'; 4 | import api from './api'; 5 | 6 | export default { 7 | async list({ type = 'user', query = {} } = {}) { 8 | const pickedQuery = _.pick(query, ['page', 'page_size', 'q']); 9 | let url = `${configService.get('apiUrl')}/${type}`; 10 | if (pickedQuery.length) { 11 | url += `?${utils.toQueryStrings(pickedQuery)}`; 12 | } 13 | 14 | return api.get(url, {}).then(response => response.data); 15 | }, 16 | 17 | async getOne({ type = 'user', userId }) { 18 | return api.get(`${configService.get('apiUrl')}/${type}/${userId}`, {}).then(response => response.data); 19 | }, 20 | 21 | async postOne({ type = 'user', user } = {}) { 22 | return api.post(`${configService.get('apiUrl')}/${type}`, user).then(response => response.data); 23 | }, 24 | 25 | async patchOne({ type = 'user', userId, newUser }) { 26 | return api.patch(`${configService.get('apiUrl')}/${type}/${userId}`, newUser).then(response => response.data); 27 | }, 28 | 29 | async deleteOne({ type = 'user', userId }) { 30 | return api.delete(`${configService.get('apiUrl')}/${type}/${userId}`).then(response => response.data); 31 | } 32 | }; 33 | -------------------------------------------------------------------------------- /backend/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | import common from './modules/common'; 5 | import auth from './modules/auth'; 6 | import alert from './modules/alert'; 7 | import user from './modules/user'; 8 | import todo from './modules/todo'; 9 | import permission from './modules/permission'; 10 | import setting from './modules/setting'; 11 | 12 | Vue.use(Vuex); 13 | 14 | export default new Vuex.Store({ 15 | modules: { 16 | common, 17 | auth, 18 | alert, 19 | user, 20 | todo, 21 | permission, 22 | setting 23 | }, 24 | strict: true 25 | }); 26 | -------------------------------------------------------------------------------- /backend/src/store/modules/common.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const state = {}; 4 | 5 | const actions = { 6 | handleServiceException({ dispatch, commit }, { e, router = null }) { 7 | if (e.response) { 8 | const { data, status } = e.response.data; 9 | 10 | let errorMessages = []; 11 | if (status === 422) { 12 | errorMessages = _.reduce( 13 | data, 14 | (errorMessages, tmpData) => { 15 | errorMessages.push(tmpData.msg); 16 | return errorMessages; 17 | }, 18 | [] 19 | ); 20 | 21 | commit('alert/setMessage', { type: 'error', message: _.join(errorMessages, '\r\n') }, { root: true }); 22 | } else if (status === 403) { 23 | dispatch('auth/sessionExpired', { router }, { root: true }); 24 | } 25 | } else { 26 | dispatch('alert/error', { showType: 'toast', title: 'Error', text: e.message }, { root: true }); 27 | throw new Error(e); 28 | } 29 | } 30 | }; 31 | 32 | const getters = {}; 33 | 34 | const mutations = {}; 35 | 36 | export default { 37 | namespaced: true, 38 | state, 39 | getters, 40 | actions, 41 | mutations 42 | }; 43 | -------------------------------------------------------------------------------- /backend/src/store/modules/permission.js: -------------------------------------------------------------------------------- 1 | import permissionService from '@/services/permissionService'; 2 | 3 | const state = { 4 | permissions: null, 5 | loading: false 6 | }; 7 | 8 | const actions = { 9 | list({ dispatch, commit }, { router }) { 10 | dispatch('alert/clear', {}, { root: true }); 11 | commit('startRequest'); 12 | 13 | permissionService 14 | .list({ router }) 15 | .then(response => { 16 | commit('setPermissions', { permissions: response.data }); 17 | }) 18 | .catch(e => { 19 | commit('requestFailed'); 20 | dispatch('common/handleServiceException', { e, router }, { root: true }); 21 | }); 22 | } 23 | }; 24 | 25 | const getters = {}; 26 | 27 | const mutations = { 28 | startRequest(state) { 29 | state.loading = true; 30 | }, 31 | requestFailed(state) { 32 | state.loading = false; 33 | }, 34 | setPermissions(state, { permissions }) { 35 | state.loading = false; 36 | state.permissions = permissions; 37 | } 38 | }; 39 | 40 | export default { 41 | namespaced: true, 42 | state, 43 | getters, 44 | actions, 45 | mutations 46 | }; 47 | -------------------------------------------------------------------------------- /backend/src/store/modules/todo.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | import todoService from '@/services/todoService'; 3 | 4 | const state = { 5 | todo: null, 6 | pagination: null, 7 | loading: false, 8 | isDragging: false 9 | }; 10 | 11 | const actions = { 12 | list({ dispatch, commit }, { state = undefined, router }) { 13 | dispatch('alert/clear', {}, { root: true }); 14 | commit('startRequest'); 15 | 16 | todoService 17 | .list({ state }) 18 | .then(response => { 19 | commit('setTodo', { todo: response.data.rows, pagination: response.data.pagination }); 20 | }) 21 | .catch(e => { 22 | commit('requestFailed'); 23 | dispatch('common/handleServiceException', { e, router }, { root: true }); 24 | }); 25 | } 26 | }; 27 | 28 | const getters = { 29 | pendingTodoList: state => _.filter(state.todo, todo => todo.state === 'pending'), 30 | ongoingTodoList: state => _.filter(state.todo, todo => todo.state === 'ongoing'), 31 | completedTodoList: state => _.filter(state.todo, todo => todo.state === 'completed'), 32 | archivedTodoList: state => _.filter(state.todo, todo => todo.state === 'archived') 33 | }; 34 | 35 | const mutations = { 36 | startRequest(state) { 37 | state.loading = true; 38 | }, 39 | requestFailed(state) { 40 | state.loading = false; 41 | }, 42 | setTodo(state, { todo, pagination }) { 43 | state.loading = false; 44 | state.todo = todo; 45 | state.pagination = pagination; 46 | } 47 | }; 48 | 49 | export default { 50 | namespaced: true, 51 | state, 52 | getters, 53 | actions, 54 | mutations 55 | }; 56 | -------------------------------------------------------------------------------- /backend/src/views/Account/Account.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 56 | -------------------------------------------------------------------------------- /backend/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 13 | 14 | 29 | -------------------------------------------------------------------------------- /backend/src/views/Layout/BackendLayout.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /backend/src/views/Layout/SimpleLayout.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 14 | -------------------------------------------------------------------------------- /backend/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 22 | 23 | 54 | -------------------------------------------------------------------------------- /backend/src/views/Logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /backend/src/views/Staff/StaffForm.vue: -------------------------------------------------------------------------------- 1 | 15 | 16 | 79 | -------------------------------------------------------------------------------- /backend/src/views/Todo/Todo.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 50 | -------------------------------------------------------------------------------- /backend/src/views/User/UserForm.vue: -------------------------------------------------------------------------------- 1 | 14 | 15 | 72 | -------------------------------------------------------------------------------- /backend/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | mocha: true, 5 | 'cypress/globals': true 6 | }, 7 | rules: { 8 | strict: 'off' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /backend/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | 3 | // if you need a custom webpack configuration you can uncomment the following import 4 | // and then use the `file:preprocessor` event 5 | // as explained in the cypress docs 6 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples 7 | 8 | /* eslint-disable import/no-extraneous-dependencies, global-require, arrow-body-style */ 9 | // const webpack = require('@cypress/webpack-preprocessor') 10 | 11 | module.exports = (on, config) => { 12 | // on('file:preprocessor', webpack({ 13 | // webpackOptions: require('@vue/cli-service/webpack.config'), 14 | // watchOptions: {} 15 | // })) 16 | 17 | return Object.assign({}, config, { 18 | fixturesFolder: 'tests/e2e/fixtures', 19 | integrationFolder: 'tests/e2e/specs', 20 | screenshotsFolder: 'tests/e2e/screenshots', 21 | videosFolder: 'tests/e2e/videos', 22 | supportFile: 'tests/e2e/support/index.js' 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /backend/tests/e2e/specs/login.js: -------------------------------------------------------------------------------- 1 | describe('Login', () => { 2 | beforeEach(() => { 3 | cy.visit('/'); 4 | }); 5 | 6 | afterEach(() => { 7 | // Emulate logout 8 | cy.clearCookies(); 9 | }); 10 | 11 | it('displays form validation', () => { 12 | cy.get('[data-cy="login-username"]').clear(); 13 | cy.get('[data-cy="login-password"]').clear(); 14 | 15 | cy.get('[data-cy="login-form"]').submit(); 16 | 17 | cy.get('[data-cy="login-username-invalid"]').should('be.visible'); 18 | 19 | cy.get('[data-cy="login-password-invalid"]').should('be.visible'); 20 | }); 21 | 22 | it('displays error with invalid credentials', () => { 23 | cy.get('[data-cy="login-username"]').type('invalid-user'); 24 | cy.get('[data-cy="login-password"]').type('invalid-password'); 25 | 26 | cy.get('[data-cy="login-form"]').submit(); 27 | 28 | cy.get('[data-cy="login-username-invalid"]').should('not.be.visible'); 29 | 30 | cy.get('[data-cy="login-password-invalid"]').should('not.be.visible'); 31 | 32 | cy.get('[data-cy="login-error-message"]').should('contain', 'Your username or password is incorrect.'); 33 | }); 34 | 35 | it('logins successfully with valid credentials', () => { 36 | cy.get('[data-cy="login-username"]').type('staff'); 37 | cy.get('[data-cy="login-password"]').type('123456'); 38 | 39 | cy.get('[data-cy="login-form"]').submit(); 40 | 41 | cy.get('[data-cy="nav-bar-welcome-text"] a').should('contain', 'Welcome, Staff'); 42 | }); 43 | }); 44 | -------------------------------------------------------------------------------- /backend/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | -------------------------------------------------------------------------------- /backend/tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /backend/tests/unit/App.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 2 | 3 | import BootstrapVue from 'bootstrap-vue'; 4 | import App from '@/App.vue'; 5 | 6 | // create an extended `Vue` constructor 7 | const localVue = createLocalVue(); 8 | 9 | localVue.use(BootstrapVue); 10 | 11 | const SimpleLayoutStub = { 12 | name: 'simple-layout', 13 | template: '
', 14 | props: [] 15 | }; 16 | 17 | const RouterViewStub = { 18 | name: 'router-view', 19 | template: '
', 20 | props: [] 21 | }; 22 | 23 | describe('App.vue', () => { 24 | let wrapper; 25 | beforeEach(() => { 26 | const $route = { 27 | path: '/login', 28 | name: 'login', 29 | component: () => import('../../src/views/Login.vue'), 30 | meta: { 31 | layout: 'simple-layout' 32 | } 33 | }; 34 | wrapper = shallowMount(App, { 35 | localVue, 36 | mocks: { 37 | $route 38 | }, 39 | stubs: { 40 | RouterView: RouterViewStub, 41 | SimpleLayout: SimpleLayoutStub 42 | } 43 | }); 44 | }); 45 | 46 | it('renders router-view component', () => { 47 | expect(wrapper.findComponent(RouterViewStub).exists()).toBeTruthy(); 48 | }); 49 | }); 50 | -------------------------------------------------------------------------------- /backend/tests/unit/components/FooterBar.spec.js: -------------------------------------------------------------------------------- 1 | import { shallowMount } from '@vue/test-utils'; 2 | import FooterBar from '@/components/FooterBar.vue'; 3 | 4 | describe('FooterBar.vue', () => { 5 | let wrapper; 6 | 7 | beforeEach(() => { 8 | wrapper = shallowMount(FooterBar); 9 | }); 10 | 11 | it('renders footer', () => { 12 | expect(wrapper.find('.footer').exists()).toBeTruthy(); 13 | }); 14 | }); 15 | -------------------------------------------------------------------------------- /backend/tests/unit/helper/utils.spec.js: -------------------------------------------------------------------------------- 1 | import utils from '@/helper/utils'; 2 | 3 | describe('utils.js', () => { 4 | let result; 5 | describe('toQueryStrings', () => { 6 | beforeEach(() => { 7 | result = utils.toQueryStrings({ test1: 'value1', test2: 'value2' }); 8 | }); 9 | 10 | it('returns expected value', () => { 11 | expect(result).toBe('test1=value1&test2=value2'); 12 | }); 13 | }); 14 | 15 | describe('validateDateTime', () => { 16 | describe('empty value', () => { 17 | beforeEach(async () => { 18 | result = await utils.validateDateTime(''); 19 | }); 20 | 21 | it('returns expected value', () => { 22 | expect(result).toBeTruthy(); 23 | }); 24 | }); 25 | 26 | describe('value is invalid', () => { 27 | beforeEach(async () => { 28 | result = await utils.validateDateTime('invalid datetime'); 29 | }); 30 | 31 | it('returns expected value', () => { 32 | expect(result).toBeFalsy(); 33 | }); 34 | }); 35 | 36 | describe('value is valid', () => { 37 | beforeEach(async () => { 38 | result = await utils.validateDateTime('2019-12-12 00:11:22'); 39 | }); 40 | 41 | it('returns expected value', () => { 42 | expect(result).toBeTruthy(); 43 | }); 44 | }); 45 | }); 46 | }); 47 | -------------------------------------------------------------------------------- /backend/tests/unit/services/authService.spec.js: -------------------------------------------------------------------------------- 1 | import api from '@/services/api'; 2 | import configService from '@/services/configService'; 3 | import authService from '@/services/authService'; 4 | 5 | jest.mock('@/services/api'); 6 | 7 | describe('authService.js', () => { 8 | let result; 9 | let error; 10 | 11 | beforeEach(() => { 12 | configService.get = jest.fn(key => key); 13 | }); 14 | 15 | describe('login', () => { 16 | describe('when result is returned', () => { 17 | beforeEach(async () => { 18 | api.post.mockResolvedValue({ data: { some: 'value' } }); 19 | result = await authService.login('username', 'password'); 20 | }); 21 | 22 | it('triggers api.post', () => { 23 | expect(api.post).toHaveBeenCalledWith(`apiUrl/staff/login`, { username: 'username', password: 'password' }); 24 | }); 25 | 26 | it('return expected value', () => { 27 | expect(result).toStrictEqual({ some: 'value' }); 28 | }); 29 | }); 30 | 31 | describe('when throw exception', () => { 32 | beforeEach(async () => { 33 | api.post.mockRejectedValue(new Error('something happened')); 34 | 35 | try { 36 | result = await authService.login('username', 'password'); 37 | } catch (e) { 38 | error = e; 39 | } 40 | }); 41 | 42 | it('throws exception', () => { 43 | expect(error).toStrictEqual(new Error('something happened')); 44 | }); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /backend/tests/unit/views/Home.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 2 | 3 | import BootstrapVue from 'bootstrap-vue'; 4 | 5 | import Home from '@/views/Home.vue'; 6 | 7 | // create an extended `Vue` constructor 8 | const localVue = createLocalVue(); 9 | 10 | localVue.use(BootstrapVue); 11 | 12 | describe('Home.vue', () => { 13 | let wrapper; 14 | beforeEach(() => { 15 | wrapper = shallowMount(Home, { 16 | localVue 17 | }); 18 | }); 19 | 20 | it('renders', () => { 21 | expect(wrapper.find('.page-home').exists()).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /backend/vue.config.js: -------------------------------------------------------------------------------- 1 | // vue.config.js 2 | 3 | module.exports = { 4 | publicPath: process.env.BASE_URL || '/', 5 | configureWebpack: { 6 | performance: { 7 | hints: 'warning', 8 | maxAssetSize: 2048576, 9 | maxEntrypointSize: 2048576 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /frontend-nuxt/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /frontend-nuxt/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | 6 | # local env files 7 | .env.local 8 | .env.*.local 9 | 10 | # Log files 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Editor directories and files 16 | .idea 17 | .vscode 18 | *.suo 19 | *.ntvs* 20 | *.njsproj 21 | *.sln 22 | *.sw? 23 | 24 | Dockerfile 25 | coverage 26 | .git 27 | .nuxt 28 | .env 29 | -------------------------------------------------------------------------------- /frontend-nuxt/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /frontend-nuxt/.env.dist: -------------------------------------------------------------------------------- 1 | BASE_URL=/ 2 | API_URL=http://localhost/api 3 | FORMAT_DATETIME="YYYY-MM-DD HH:MM:SS" 4 | -------------------------------------------------------------------------------- /frontend-nuxt/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | browser: true, 5 | node: true 6 | }, 7 | parserOptions: { 8 | parser: '@babel/eslint-parser', 9 | requireConfigFile: false 10 | }, 11 | extends: [ 12 | '@nuxtjs', 13 | 'prettier', 14 | 'plugin:prettier/recommended', 15 | 'plugin:nuxt/recommended' 16 | ], 17 | plugins: ['prettier'], 18 | // add your custom rules here 19 | rules: { 20 | 'nuxt/no-cjs-in-config': 'off', 21 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }] 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend-nuxt/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | /logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | .env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE / Editor 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | 86 | # macOS 87 | .DS_Store 88 | 89 | # Vim swap files 90 | *.swp 91 | 92 | .env 93 | -------------------------------------------------------------------------------- /frontend-nuxt/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "arrowParens": "always", 4 | "singleQuote": true, 5 | "trailingComma": "none" 6 | } 7 | -------------------------------------------------------------------------------- /frontend-nuxt/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "eslint.enable": true, 6 | "eslint.alwaysShowStatus": true, 7 | "files.trimTrailingWhitespace": true, 8 | "files.exclude": {}, 9 | "files.insertFinalNewline": true, 10 | "typescript.preferences.importModuleSpecifier": "relative", 11 | "search.exclude": { 12 | ".git": true, 13 | ".build": true, 14 | "**/.DS_Store": true, 15 | "**/.vscode": true, 16 | "**/coverage": true, 17 | "**/node_modules": true 18 | }, 19 | "editor.codeActionsOnSave": { 20 | "source.fixAll.eslint": true, 21 | "source.organizeImports": false 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /frontend-nuxt/Dockerfile: -------------------------------------------------------------------------------- 1 | # initial stage 2 | FROM node:16-alpine3.13 AS initial-stage 3 | 4 | RUN apk --no-cache add autoconf=2.69-r3 \ 5 | automake=1.16.3-r0 \ 6 | gcc=10.2.1_pre1-r3 \ 7 | make=4.3-r0 \ 8 | g++=10.2.1_pre1-r3 \ 9 | zlib-dev=1.2.12-r1 10 | 11 | WORKDIR /srv 12 | 13 | COPY package*.json ./ 14 | 15 | RUN npm install 16 | 17 | # build stage 18 | FROM initial-stage AS build-stage 19 | 20 | ARG BASE_URL 21 | ARG API_URL 22 | 23 | ENV BASE_URL=${BASE_URL} 24 | ENV API_URL=${API_URL} 25 | 26 | WORKDIR /srv 27 | 28 | # Add configuration files 29 | COPY image-files/ / 30 | 31 | RUN chmod +x /usr/local/bin/create-env.sh 32 | ENV PATH /usr/local/bin:$PATH 33 | 34 | COPY . . 35 | 36 | RUN create-env.sh && \ 37 | npm run build 38 | 39 | FROM node:16-alpine AS production-stage 40 | 41 | # Add configuration files 42 | COPY image-files/ / 43 | 44 | RUN chmod +x /usr/local/bin/create-env.sh 45 | ENV PATH /usr/local/bin:$PATH 46 | 47 | WORKDIR /srv 48 | 49 | COPY --from=build-stage /srv /srv 50 | 51 | ENV HOST 0.0.0.0 52 | EXPOSE 3000 53 | 54 | # Upgrade npm due to vulnerabilities on packaged version 55 | RUN npm install -g npm@8.10.0 56 | 57 | ENTRYPOINT [ "docker-entrypoint.sh" ] 58 | 59 | CMD ["npm", "run", "prod"] 60 | -------------------------------------------------------------------------------- /frontend-nuxt/README.md: -------------------------------------------------------------------------------- 1 | # Frontend for Node.js + Vue.js boilerplate 2 | 3 | > Nuxt.js, Vue.js, Vuex, Vuelidate, BootstrapVue, Jest 4 | 5 | Nuxt.js supports Server Side Rendering(SSR) and Static Site Generation (SSG) for SEO friendly website using Vue.js. 6 | 7 | ## Build Setup 8 | 9 | ```bash 10 | # install dependencies 11 | $ npm install 12 | 13 | # serve with hot reload at localhost:3000 14 | $ npm run dev 15 | 16 | # build for production and launch server 17 | $ npm run build 18 | $ npm run start 19 | 20 | # generate static project 21 | $ npm run generate 22 | ``` 23 | 24 | For detailed explanation on how things work, check out [Nuxt.js docs](https://nuxtjs.org). 25 | 26 | ## Pages 27 | 28 | - [x] Main page 29 | - [x] Login 30 | - [x] Register 31 | - [x] Register confirm 32 | - [x] Forget my password 33 | - [x] My account 34 | - [x] Update my account 35 | - [x] Todo 36 | 37 | ## Todo 38 | 39 | - [x] Unit tests 40 | - [ ] E2E tests 41 | - [ ] File upload 42 | -------------------------------------------------------------------------------- /frontend-nuxt/assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /frontend-nuxt/assets/css/custom.scss: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-y: scroll; 3 | } 4 | 5 | #app { 6 | padding-top: 4.5rem; 7 | font-family: Avenir, Helvetica, Arial, sans-serif; 8 | -webkit-font-smoothing: antialiased; 9 | -moz-osx-font-smoothing: grayscale; 10 | color: #2c3e50; 11 | } 12 | 13 | .spinner { 14 | /* Spinner size and color */ 15 | box-sizing: border-box; 16 | display: inline-block; 17 | width: 1rem; 18 | height: 1rem; 19 | vertical-align: middle; 20 | border-color: #444 #444 transparent transparent; 21 | border-style: solid; 22 | border-width: 2px; 23 | border-radius: 50%; 24 | animation: spinner 400ms linear infinite; 25 | } 26 | 27 | /* Animation styles */ 28 | @keyframes spinner { 29 | 0% { 30 | transform: rotate(0deg); 31 | } 32 | 33 | 100% { 34 | transform: rotate(360deg); 35 | } 36 | } 37 | 38 | /* Optional — create your own variations! */ 39 | .spinner-large { 40 | width: 5rem; 41 | height: 5rem; 42 | border-width: 6px; 43 | } 44 | 45 | .spinner-slow { 46 | animation: spinner 1s linear infinite; 47 | } 48 | 49 | .spinner-white { 50 | border-top-color: #fff; 51 | border-left-color: #fff; 52 | } 53 | 54 | .spinner-blue { 55 | border-top-color: #09d; 56 | border-left-color: #09d; 57 | } 58 | 59 | .page-title { 60 | font-size: 1.5rem; 61 | font-weight: bold; 62 | } 63 | -------------------------------------------------------------------------------- /frontend-nuxt/components/FooterBar.vue: -------------------------------------------------------------------------------- 1 | 46 | 47 | 52 | -------------------------------------------------------------------------------- /frontend-nuxt/components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | The components directory contains your Vue.js Components. 6 | 7 | _Nuxt.js doesn't supercharge these components._ 8 | -------------------------------------------------------------------------------- /frontend-nuxt/components/TodoAddBox.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 86 | -------------------------------------------------------------------------------- /frontend-nuxt/image-files/usr/local/bin/create-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | 6 | cat < /srv/.env 7 | BASE_URL=${BASE_URL:=/} 8 | API_URL=${API_URL:=http://localhost/api} 9 | FORMAT_DATETIME=${FORMAT_DATETIME:="YYYY-MM-DD HH:MM:SS"} 10 | EOT 11 | 12 | exec "$@" 13 | -------------------------------------------------------------------------------- /frontend-nuxt/image-files/usr/local/bin/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | /usr/local/bin/create-env.sh 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /frontend-nuxt/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: ['js', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.js$': 'babel-jest', 10 | '.*\\.(vue)$': 'vue-jest' 11 | }, 12 | collectCoverage: true, 13 | collectCoverageFrom: [ 14 | '/components/**/*.vue', 15 | '/pages/**/*.vue', 16 | '/services/**/*.js', 17 | '/store/**/*.js' 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /frontend-nuxt/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "~/*": ["./*"], 6 | "@/*": ["./*"], 7 | "~~/*": ["./*"], 8 | "@@/*": ["./*"] 9 | } 10 | }, 11 | "exclude": ["node_modules", ".nuxt", "dist"] 12 | } 13 | -------------------------------------------------------------------------------- /frontend-nuxt/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /frontend-nuxt/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 23 | -------------------------------------------------------------------------------- /frontend-nuxt/layouts/error.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 43 | -------------------------------------------------------------------------------- /frontend-nuxt/middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /frontend-nuxt/middleware/check-auth.js: -------------------------------------------------------------------------------- 1 | import { cookieAuthKey, cookieRefreshAuthKey } from '@/store/auth' 2 | 3 | export default function ({ app, store }) { 4 | const authKey = app.$cookies.get(cookieAuthKey) 5 | const refreshAuthKey = app.$cookies.get(cookieRefreshAuthKey) 6 | store.dispatch('auth/updateAuthKey', { authKey, refreshAuthKey }) 7 | } 8 | -------------------------------------------------------------------------------- /frontend-nuxt/middleware/guest-only.js: -------------------------------------------------------------------------------- 1 | export default function ({ store, redirect }) { 2 | if (store.getters['auth/isLoggedIn']() === true) { 3 | return redirect('/') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend-nuxt/middleware/require-auth.js: -------------------------------------------------------------------------------- 1 | export default function ({ store, redirect }) { 2 | if (store.getters['auth/isLoggedIn']() === false) { 3 | return redirect('/') 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/account/index.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 66 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/find-password.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 59 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/index.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/login.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 63 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/page.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 43 | -------------------------------------------------------------------------------- /frontend-nuxt/pages/register.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 59 | -------------------------------------------------------------------------------- /frontend-nuxt/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /frontend-nuxt/plugins/axios.js: -------------------------------------------------------------------------------- 1 | import { cookieAuthKey, cookieRefreshAuthKey } from '~/store/auth' 2 | 3 | export default function ({ $axios, store }) { 4 | $axios.onRequest((config) => { 5 | const token = localStorage.getItem(cookieAuthKey) || '' 6 | if (token) { 7 | config.headers.Authorization = token 8 | } 9 | return config 10 | }) 11 | 12 | $axios.onResponseError(async (err) => { 13 | const originalConfig = err.config 14 | if ( 15 | originalConfig.url !== `${process.env.API_URL}/use/login` && 16 | err.response 17 | ) { 18 | // Access Token was expired 19 | if (err.response.status === 401 && !originalConfig._retry) { 20 | originalConfig._retry = true 21 | try { 22 | const response = await $axios.post( 23 | `${process.env.API_URL}/refresh-token`, 24 | { 25 | refreshToken: localStorage.getItem(cookieRefreshAuthKey) || '' 26 | } 27 | ) 28 | const { data } = response.data 29 | 30 | originalConfig.headers.Authorization = data.auth_key 31 | 32 | store.commit('auth/loginSuccess', { 33 | authKey: data.auth_key, 34 | refreshAuthKey: data.refresh_auth_key 35 | }) 36 | 37 | return Promise.resolve($axios(originalConfig)) 38 | } catch (error) { 39 | return Promise.reject(error) 40 | } 41 | } 42 | } 43 | return Promise.reject(err) 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /frontend-nuxt/plugins/vuelidate.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuelidate from 'vuelidate' 3 | Vue.use(Vuelidate) 4 | -------------------------------------------------------------------------------- /frontend-nuxt/server/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express') 2 | const consola = require('consola') 3 | const { Nuxt, Builder } = require('nuxt') 4 | const app = express() 5 | 6 | // Import and Set Nuxt.js options 7 | const config = require('../nuxt.config.js') 8 | config.dev = process.env.NODE_ENV !== 'production' 9 | 10 | async function start() { 11 | // Init Nuxt.js 12 | const nuxt = new Nuxt(config) 13 | 14 | const { host, port } = nuxt.options.server 15 | 16 | await nuxt.ready() 17 | // Build only in dev mode 18 | if (config.dev) { 19 | const builder = new Builder(nuxt) 20 | await builder.build() 21 | } 22 | 23 | // Give nuxt middleware to express 24 | app.use(nuxt.render) 25 | 26 | // Listen the server 27 | app.listen(port, host) 28 | consola.ready({ 29 | message: `Server listening on http://${host}:${port}`, 30 | badge: true 31 | }) 32 | } 33 | start() 34 | -------------------------------------------------------------------------------- /frontend-nuxt/services/authService.js: -------------------------------------------------------------------------------- 1 | export default { 2 | passwordReset($axios, { key, password }) { 3 | return $axios 4 | .post(`${process.env.API_URL}/user/password-reset`, { 5 | key, 6 | password 7 | }) 8 | .then((response) => response.data) 9 | }, 10 | 11 | passwordResetRequest($axios, { email }) { 12 | return $axios 13 | .post(`${process.env.API_URL}/user/password-reset-request`, { 14 | email 15 | }) 16 | .then((response) => response.data) 17 | }, 18 | 19 | register($axios, { username, email, password, firstName, lastName }) { 20 | return $axios 21 | .post(`${process.env.API_URL}/user/register`, { 22 | username, 23 | email, 24 | password, 25 | first_name: firstName, 26 | last_name: lastName 27 | }) 28 | .then((response) => response.data) 29 | }, 30 | 31 | login($axios, { username, password }) { 32 | return $axios 33 | .post(`${process.env.API_URL}/user/login`, { 34 | username, 35 | password 36 | }) 37 | .then((response) => response.data) 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend-nuxt/services/todoService.js: -------------------------------------------------------------------------------- 1 | export default { 2 | list($axios, { state = undefined } = {}) { 3 | let url = `${process.env.API_URL}/todo` 4 | if (state) { 5 | url += `/${state}` 6 | } 7 | 8 | return $axios.get(url, {}).then((response) => response.data) 9 | }, 10 | 11 | postOne($axios, { todo }) { 12 | return $axios 13 | .post(`${process.env.API_URL}/todo`, todo) 14 | .then((response) => response.data) 15 | }, 16 | 17 | postBulk($axios, { state, todoList }) { 18 | return $axios 19 | .post(`${process.env.API_URL}/todo/${state}`, { 20 | todo: todoList 21 | }) 22 | .then((response) => response.data) 23 | }, 24 | 25 | deleteOne($axios, { todoId }) { 26 | return $axios 27 | .delete(`${process.env.API_URL}/todo/${todoId}`) 28 | .then((response) => response.data) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /frontend-nuxt/services/userService.js: -------------------------------------------------------------------------------- 1 | export default { 2 | me($axios) { 3 | return $axios 4 | .get(`${process.env.API_URL}/me`, {}) 5 | .then((response) => response.data) 6 | }, 7 | updateMe($axios, me) { 8 | return $axios 9 | .post(`${process.env.API_URL}/me`, me) 10 | .then((response) => response.data) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /frontend-nuxt/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /frontend-nuxt/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-nuxt/static/favicon.ico -------------------------------------------------------------------------------- /frontend-nuxt/static/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-nuxt/static/icon.png -------------------------------------------------------------------------------- /frontend-nuxt/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /frontend-nuxt/store/common.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | export const state = () => ({}) 4 | 5 | export const actions = { 6 | handleServiceException({ dispatch, commit }, { e, router = null }) { 7 | if (e.response) { 8 | const { data, status } = e.response.data 9 | 10 | let errorMessages = [] 11 | if (status === 422) { 12 | errorMessages = _.reduce( 13 | data, 14 | (errorMessages, tmpData) => { 15 | errorMessages.push(tmpData.msg) 16 | return errorMessages 17 | }, 18 | [] 19 | ) 20 | 21 | commit( 22 | 'alert/setMessage', 23 | { type: 'error', message: _.join(errorMessages, '\r\n') }, 24 | { root: true } 25 | ) 26 | } else if (status === 403) { 27 | dispatch('auth/sessionExpired', { router }, { root: true }) 28 | } 29 | } else { 30 | dispatch( 31 | 'alert/error', 32 | { showType: 'toast', title: 'Error', text: e.message }, 33 | { root: true } 34 | ) 35 | throw new Error(e) 36 | } 37 | } 38 | } 39 | 40 | export const getters = {} 41 | 42 | export const mutations = {} 43 | -------------------------------------------------------------------------------- /frontend-nuxt/store/index.js: -------------------------------------------------------------------------------- 1 | export const state = () => ({}) 2 | 3 | export const actions = {} 4 | 5 | export const getters = {} 6 | 7 | export const mutations = {} 8 | -------------------------------------------------------------------------------- /frontend-nuxt/store/user.js: -------------------------------------------------------------------------------- 1 | import moment from 'moment' 2 | import userService from '@/services/userService' 3 | 4 | export const state = () => ({ 5 | user: null, 6 | loading: false 7 | }) 8 | 9 | export const actions = { 10 | me({ dispatch, commit }, { router }) { 11 | dispatch('alert/clear', {}, { root: true }) 12 | commit('startRequest') 13 | 14 | userService 15 | .me(this.$axios) 16 | .then((response) => { 17 | commit('setUser', { user: response.data }) 18 | }) 19 | .catch((e) => { 20 | commit('requestFailed') 21 | dispatch('common/handleServiceException', { e, router }, { root: true }) 22 | }) 23 | }, 24 | updateMe({ dispatch, commit }, { user, router }) { 25 | dispatch('alert/clear', {}, { root: true }) 26 | commit('startRequest') 27 | 28 | userService 29 | .updateMe(this.$axios, { 30 | first_name: user.firstName, 31 | last_name: user.lastName, 32 | email: user.email, 33 | password: user.password 34 | }) 35 | .then((response) => { 36 | commit('setUser', { user: response.data }) 37 | commit( 38 | 'alert/setMessage', 39 | { 40 | type: 'success', 41 | message: 'Your information has been successfully updated.' 42 | }, 43 | { root: true } 44 | ) 45 | }) 46 | .catch((e) => { 47 | commit('requestFailed') 48 | dispatch('common/handleServiceException', { e, router }, { root: true }) 49 | }) 50 | } 51 | } 52 | 53 | export const getters = {} 54 | 55 | export const mutations = { 56 | startRequest(state) { 57 | state.loading = true 58 | }, 59 | requestFailed(state) { 60 | state.loading = false 61 | }, 62 | setUser(state, { user }) { 63 | state.loading = false 64 | state.user = user 65 | 66 | if (moment(state.user.last_login_at).isValid()) { 67 | state.user.last_login_at_formatted = moment( 68 | state.user.last_login_at 69 | ).format(process.env.FORMAT_DATETIME) 70 | } else { 71 | state.user.last_login_at_formatted = 'Never logged in' 72 | } 73 | 74 | if (state.user.first_name && state.user.last_name) { 75 | state.user.full_name = `${state.user.first_name}, ${state.user.last_name}` 76 | } 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /frontend-nuxt/stylelint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // add your custom config here 3 | // https://stylelint.io/user-guide/configuration 4 | 5 | extends: [ 6 | 'stylelint-config-recommended-scss', 7 | 'stylelint-config-prettier', 8 | 'stylelint-config-recommended-vue' 9 | ], 10 | plugins: ['stylelint-scss', 'stylelint-prettier'], 11 | rules: { 12 | // Prettier rules 13 | 'prettier/prettier': true, 14 | 15 | // SCSS rules 16 | 'scss/at-mixin-argumentless-call-parentheses': 'always', 17 | 'scss/at-mixin-parentheses-space-before': 'never', 18 | 'scss/double-slash-comment-whitespace-inside': 'always', 19 | 20 | // Base Stylelint rules 21 | 'selector-combinator-space-after': 'always', 22 | 'selector-descendant-combinator-no-non-space': true, 23 | 'number-leading-zero': 'always', 24 | 'declaration-no-important': true, 25 | 'number-no-trailing-zeros': true, 26 | 'length-zero-no-unit': true, 27 | 'function-name-case': 'lower', 28 | 'color-hex-case': 'lower', 29 | 'unit-case': 'lower', 30 | 'value-keyword-case': 'lower', 31 | 'property-case': 'lower', 32 | 'selector-pseudo-class-case': 'lower', 33 | 'selector-pseudo-element-case': 'lower', 34 | 'selector-type-case': 'lower', 35 | 'media-feature-name-case': 'lower', 36 | 'at-rule-name-case': 'lower', 37 | 'rule-empty-line-before': [ 38 | 'always', 39 | { 40 | except: ['after-single-line-comment', 'first-nested'] 41 | } 42 | ], 43 | 'selector-pseudo-element-no-unknown': [ 44 | true, 45 | { 46 | ignorePseudoElements: ['v-deep'] 47 | } 48 | ] 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /frontend-nuxt/test/pages/index.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils' 2 | 3 | import { BootstrapVue } from 'bootstrap-vue' 4 | 5 | import index from '@/pages/index.vue' 6 | 7 | // create an extended `Vue` constructor 8 | const localVue = createLocalVue() 9 | 10 | localVue.use(BootstrapVue) 11 | 12 | describe('index.vue', () => { 13 | let wrapper 14 | describe('renders', () => { 15 | beforeEach(() => { 16 | wrapper = shallowMount(index, { 17 | localVue 18 | }) 19 | }) 20 | 21 | it('renders', () => { 22 | expect(wrapper.find('.page-home').exists()).toBeTruthy() 23 | }) 24 | }) 25 | 26 | describe('head', () => { 27 | let result 28 | beforeEach(() => { 29 | wrapper = shallowMount(index, { 30 | localVue 31 | }) 32 | 33 | result = wrapper.vm.$options.head() 34 | }) 35 | 36 | it('returns expected head', () => { 37 | expect(result).toStrictEqual({ 38 | title: 'Homepage', 39 | meta: [ 40 | { 41 | hid: 'description', 42 | name: 'description', 43 | content: 'Home page description' 44 | } 45 | ] 46 | }) 47 | }) 48 | }) 49 | }) 50 | -------------------------------------------------------------------------------- /frontend-vue/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /frontend-vue/.dockerignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | .vscode 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | 26 | Dockerfile 27 | /public/static/config.json 28 | coverage -------------------------------------------------------------------------------- /frontend-vue/.editorconfig: -------------------------------------------------------------------------------- 1 | [*.{js,jsx,ts,tsx,vue}] 2 | indent_style = space 3 | indent_size = 2 4 | end_of_line = lf 5 | trim_trailing_whitespace = true 6 | insert_final_newline = true 7 | max_line_length = 100 8 | -------------------------------------------------------------------------------- /frontend-vue/.eslintignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /config/ 3 | /dist/ 4 | /*.js 5 | /*.config.js 6 | /test/unit/coverage/ 7 | -------------------------------------------------------------------------------- /frontend-vue/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ['plugin:vue/essential', '@vue/airbnb', 'prettier'], 7 | rules: { 8 | 'no-console': process.env.NODE_ENV === 'production' ? 'error' : 'off', 9 | 'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off', 10 | 'comma-dangle': ['error', 'never'], 11 | 'no-unused-vars': ['error', { argsIgnorePattern: '^_' }], 12 | 'no-shadow': 'off' 13 | }, 14 | parserOptions: { 15 | parser: 'babel-eslint' 16 | }, 17 | overrides: [ 18 | { 19 | files: ['**/__tests__/*.{j,t}s?(x)', '**/tests/unit/**/*.spec.{j,t}s?(x)'], 20 | env: { 21 | jest: true 22 | } 23 | } 24 | ] 25 | }; 26 | -------------------------------------------------------------------------------- /frontend-vue/.gitattributes: -------------------------------------------------------------------------------- 1 | # Fix end-of-lines in Git versions older than 2.10 2 | # https://github.com/git/git/blob/master/Documentation/RelNotes/2.10.0.txt#L248 3 | * text=auto eol=lf 4 | 5 | # === 6 | # Binary Files (don't diff, don't fix line endings) 7 | # === 8 | 9 | # Images 10 | *.png binary 11 | *.jpg binary 12 | *.jpeg binary 13 | *.gif binary 14 | *.ico binary 15 | *.tiff binary 16 | 17 | # Fonts 18 | *.oft binary 19 | *.ttf binary 20 | *.eot binary 21 | *.woff binary 22 | *.woff2 binary 23 | 24 | # Videos 25 | *.mov binary 26 | *.mp4 binary 27 | *.webm binary 28 | *.ogg binary 29 | *.mpg binary 30 | *.3gp binary 31 | *.avi binary 32 | *.wmv binary 33 | *.flv binary 34 | *.asf binary 35 | 36 | # Audio 37 | *.mp3 binary 38 | *.wav binary 39 | *.flac binary 40 | 41 | # Compressed 42 | *.gz binary 43 | *.zip binary 44 | *.7z binary 45 | -------------------------------------------------------------------------------- /frontend-vue/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | /tests/e2e/videos/ 6 | /tests/e2e/screenshots/ 7 | 8 | # local env files 9 | .env.local 10 | .env.*.local 11 | 12 | # Log files 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | 17 | # Editor directories and files 18 | .idea 19 | *.suo 20 | *.ntvs* 21 | *.njsproj 22 | *.sln 23 | *.sw? 24 | 25 | coverage 26 | -------------------------------------------------------------------------------- /frontend-vue/.markdownlint.yml: -------------------------------------------------------------------------------- 1 | default: true 2 | 3 | # === 4 | # Rule customizations for markdownlint go here 5 | # https://github.com/DavidAnson/markdownlint/blob/master/doc/Rules.md 6 | # === 7 | 8 | # Disable line length restrictions, because editor soft-wrapping is being 9 | # used instead. 10 | line-length: false 11 | 12 | # === 13 | # Prettier overrides 14 | # === 15 | 16 | no-multiple-blanks: false 17 | list-marker-space: false 18 | -------------------------------------------------------------------------------- /frontend-vue/.prettierignore: -------------------------------------------------------------------------------- 1 | /node_modules/** 2 | /dist/** 3 | /tests/unit/coverage/** 4 | -------------------------------------------------------------------------------- /frontend-vue/.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | printWidth: 120, 3 | tabWidth: 2, 4 | useTabs: false, 5 | semi: true, 6 | singleQuote: true, 7 | trailingComma: 'none', 8 | bracketSpacing: true, 9 | bracketSameLine: false, 10 | arrowParens: 'avoid', 11 | proseWrap: 'never', 12 | htmlWhitespaceSensitivity: 'strict', 13 | endOfLine: 'lf' 14 | }; 15 | -------------------------------------------------------------------------------- /frontend-vue/.stylelintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "stylelint-config-standard", 4 | "stylelint-config-css-modules", 5 | "stylelint-config-prettier", 6 | "stylelint-config-recess-order", 7 | "stylelint-scss", 8 | "stylelint-config-recommended-vue" 9 | ], 10 | "rules": { 11 | "alpha-value-notation": ["percentage", { "exceptProperties": ["opacity"] }] 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend-vue/.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.insertSpaces": true, 3 | "editor.formatOnSave": true, 4 | "editor.tabSize": 2, 5 | "eslint.enable": true, 6 | "eslint.alwaysShowStatus": true, 7 | "files.trimTrailingWhitespace": true, 8 | "files.exclude": {}, 9 | "files.insertFinalNewline": true, 10 | "search.exclude": { 11 | ".git": true, 12 | ".build": true, 13 | "**/.DS_Store": true, 14 | "**/.vscode": true, 15 | "**/coverage": true, 16 | "**/node_modules": true 17 | }, 18 | "editor.codeActionsOnSave": { 19 | "source.fixAll.eslint": true 20 | }, 21 | "cSpell.words": [] 22 | } 23 | -------------------------------------------------------------------------------- /frontend-vue/Dockerfile: -------------------------------------------------------------------------------- 1 | # initial stage 2 | FROM node:16-buster AS initial-stage 3 | 4 | RUN apt-get update -y && \ 5 | apt-get install -yq --no-install-recommends \ 6 | cmake=3.13.4-1 \ 7 | build-essential=12.6 \ 8 | libpng-dev=1.6.36-6 \ 9 | libjpeg-dev=1:1.5.2-2+deb10u1 \ 10 | gifsicle=1.91-5 \ 11 | xvfb=2:1.20.4-1+deb10u4 \ 12 | libgtk-3-dev=3.24.5-1 \ 13 | libnotify-dev=0.7.7-4 \ 14 | libgconf-2-4=3.2.6-5 \ 15 | libnss3=2:3.42.1-1+deb10u5 \ 16 | libxss1=1:1.2.3-1 \ 17 | libasound2=1.1.8-1 && \ 18 | rm -rf /var/lib/apt/lists/* 19 | 20 | WORKDIR /srv 21 | 22 | COPY package*.json ./ 23 | 24 | RUN npm install 25 | 26 | # build stage 27 | FROM initial-stage AS build-stage 28 | 29 | ARG NODE_ENV 30 | ARG BASE_URL 31 | ENV NODE_ENV=${NODE_ENV} 32 | ENV BASE_URL=${BASE_URL} 33 | 34 | # Add configuration files 35 | COPY image-files/ / 36 | 37 | WORKDIR /srv 38 | 39 | COPY . . 40 | 41 | RUN npm run build --mode=production 42 | 43 | ENTRYPOINT [ "docker-entrypoint.dev.sh" ] 44 | 45 | # production stage 46 | FROM nginx:stable-alpine AS production-stage 47 | 48 | RUN apk --no-cache add \ 49 | xz-libs=5.2.5-r1 \ 50 | freetype=2.10.4-r2 51 | 52 | # Add configuration files 53 | COPY image-files/ / 54 | 55 | COPY --from=build-stage /srv/dist /srv 56 | 57 | EXPOSE 80 58 | 59 | ENTRYPOINT [ "docker-entrypoint.sh" ] 60 | 61 | CMD ["nginx", "-g", "daemon off;"] 62 | -------------------------------------------------------------------------------- /frontend-vue/README.md: -------------------------------------------------------------------------------- 1 | # Frontend for Node.js + Vue.js boilerplate 2 | 3 | Vue.js, Vuex, Vue Router, Vuelidate, BootstrapVue, Jest, Cypress 4 | 5 | ## How to start development 6 | 7 | Simply install npm packages and run serve 8 | 9 | ```bash 10 | npm install 11 | ``` 12 | 13 | ```bash 14 | npm run serve 15 | ``` 16 | 17 | Open the browser and go to [http://localhost:8080](http://localhost:8080) 18 | 19 | ## Pages 20 | 21 | - [x] Main page 22 | - [x] Login 23 | - [x] Register 24 | - [x] Register confirm 25 | - [x] Forget my password 26 | - [x] My account 27 | - [x] Update my account 28 | - [x] Todo 29 | 30 | ## Todo 31 | 32 | - [x] Unit tests 33 | - [x] E2E tests 34 | - [ ] File upload 35 | -------------------------------------------------------------------------------- /frontend-vue/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: ['@vue/cli-plugin-babel/preset'] 3 | }; 4 | -------------------------------------------------------------------------------- /frontend-vue/cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js" 3 | } 4 | -------------------------------------------------------------------------------- /frontend-vue/image-files/etc/nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | user nginx; 2 | worker_processes auto; 3 | 4 | error_log /var/log/nginx/error.log warn; 5 | pid /var/run/nginx.pid; 6 | 7 | events { 8 | worker_connections 1024; 9 | } 10 | 11 | http { 12 | include /etc/nginx/mime.types; 13 | default_type application/octet-stream; 14 | 15 | sendfile on; 16 | keepalive_timeout 65; 17 | 18 | server { 19 | listen 80; 20 | sendfile on; 21 | default_type application/octet-stream; 22 | 23 | gzip on; 24 | gzip_http_version 1.1; 25 | gzip_disable "MSIE [1-6]\."; 26 | gzip_min_length 256; 27 | gzip_vary on; 28 | gzip_proxied expired no-cache no-store private auth; 29 | gzip_types text/plain text/css application/json application/javascript application/x-javascript text/xml application/xml application/xml+rss text/javascript; 30 | gzip_comp_level 9; 31 | 32 | root /srv/; 33 | 34 | location ^~ / { 35 | expires -1; 36 | add_header Pragma "no-cache"; 37 | add_header Cache-Control "no-store, no-cache, must-revalidate, post-check=0, pre-check=0"; 38 | 39 | try_files $uri $uri/ /index.html = 404; 40 | } 41 | 42 | #error_page 404 /404.html; 43 | 44 | # redirect server error pages to the static page /50x.html 45 | # 46 | 47 | error_page 500 502 503 504 /50x.html; 48 | location = /50x.html { 49 | root /srv; 50 | } 51 | } 52 | 53 | } 54 | -------------------------------------------------------------------------------- /frontend-vue/image-files/usr/local/bin/docker-entrypoint.dev.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | mkdir -p /srv/public/static 6 | 7 | cat < /srv/public/static/config.json 8 | { 9 | "apiUrl": "$API_URL", 10 | "format": { 11 | "timeZone": "Australia/Melbourne", 12 | "dateTime": "YYYY-MM-DD HH:mm:ss", 13 | "pickerDateTime": "yyyy-MM-dd HH:mm" 14 | } 15 | } 16 | EOT 17 | 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /frontend-vue/image-files/usr/local/bin/docker-entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | mkdir -p /srv/static 6 | 7 | cat < /srv/static/config.json 8 | { 9 | "apiUrl": "$API_URL", 10 | "format": { 11 | "timeZone": "Australia/Melbourne", 12 | "dateTime": "YYYY-MM-DD HH:mm:ss", 13 | "pickerDateTime": "yyyy-MM-dd HH:mm" 14 | } 15 | } 16 | EOT 17 | 18 | exec "$@" 19 | -------------------------------------------------------------------------------- /frontend-vue/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: '@vue/cli-plugin-unit-jest', 3 | collectCoverage: true, 4 | collectCoverageFrom: [ 5 | 'src/**/*.{js,vue}', 6 | '!**/node_modules/**', 7 | '!**/tests/**', 8 | '!**/coverage/**', 9 | '!src/main.js', 10 | '!src/registerServiceWorker.js' 11 | ] 12 | }; 13 | -------------------------------------------------------------------------------- /frontend-vue/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "target": "es2017" 5 | }, 6 | "exclude": ["node_modules"] 7 | } 8 | -------------------------------------------------------------------------------- /frontend-vue/lint-staged.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | '*.js': ['npm run lint:eslint', 'npm run lint:prettier', 'git add', 'npm run test:unit'], 3 | '{!(package)*.json,*.code-snippets,.!(browserslist)*rc}': ['npm run lint:prettier --parser json', 'git add'], 4 | 'package.json': ['npm run lint:prettier', 'git add'], 5 | '*.vue': ['npm run lint:eslint', 'npm run lint:stylelint', 'npm run lint:prettier', 'git add', 'npm run test:unit'], 6 | '*.scss': ['npm run lint:stylelint', 'npm run lint:prettier', 'git add'], 7 | '*.md': ['npm run lint:markdownlint', 'npm run lint:prettier', 'git add'], 8 | '*.{png,jpeg,jpg,gif,svg}': ['imagemin-lint-staged', 'git add'] 9 | }; 10 | -------------------------------------------------------------------------------- /frontend-vue/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | }; 6 | -------------------------------------------------------------------------------- /frontend-vue/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/favicon.ico -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon-120x120.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon-120x120.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon-152x152.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon-152x152.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon-180x180.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon-180x180.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon-60x60.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon-60x60.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon-76x76.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon-76x76.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/favicon-32x32.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/msapplication-icon-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/msapplication-icon-144x144.png -------------------------------------------------------------------------------- /frontend-vue/public/img/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/public/img/icons/mstile-150x150.png -------------------------------------------------------------------------------- /frontend-vue/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Frontend 9 | 10 | 11 | 17 |
18 | 19 | 20 | 21 | -------------------------------------------------------------------------------- /frontend-vue/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "short_name": "frontend", 4 | "icons": [ 5 | { 6 | "src": "./img/icons/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "./img/icons/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "start_url": "./index.html", 17 | "display": "standalone", 18 | "background_color": "#000000", 19 | "theme_color": "#4DBA87" 20 | } 21 | -------------------------------------------------------------------------------- /frontend-vue/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Disallow: 3 | -------------------------------------------------------------------------------- /frontend-vue/public/static/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "apiUrl": "http://localhost/api", 3 | "format": { 4 | "dateTime": "YYYY-MM-DD HH:MM:SS" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend-vue/src/App.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 31 | 32 | 43 | -------------------------------------------------------------------------------- /frontend-vue/src/assets/css/custom.css: -------------------------------------------------------------------------------- 1 | body { 2 | overflow-y: scroll; 3 | } 4 | 5 | .spinner { 6 | /* Spinner size and color */ 7 | width: 1rem; 8 | height: 1rem; 9 | border-top-color: #444; 10 | border-left-color: #444; 11 | 12 | /* Additional spinner styles */ 13 | animation: spinner 400ms linear infinite; 14 | border-bottom-color: transparent; 15 | border-right-color: transparent; 16 | border-style: solid; 17 | border-width: 2px; 18 | border-radius: 50%; 19 | box-sizing: border-box; 20 | display: inline-block; 21 | vertical-align: middle; 22 | } 23 | 24 | /* Animation styles */ 25 | @keyframes spinner { 26 | 0% { 27 | transform: rotate(0deg); 28 | } 29 | 100% { 30 | transform: rotate(360deg); 31 | } 32 | } 33 | 34 | /* Optional — create your own variations! */ 35 | .spinner-large { 36 | width: 5rem; 37 | height: 5rem; 38 | border-width: 6px; 39 | } 40 | 41 | .spinner-slow { 42 | animation: spinner 1s linear infinite; 43 | } 44 | 45 | .spinner-white { 46 | border-top-color: #fff; 47 | border-left-color: #fff; 48 | } 49 | 50 | .spinner-blue { 51 | border-top-color: #09d; 52 | border-left-color: #09d; 53 | } 54 | 55 | .page-title { 56 | font-size: 1.5rem; 57 | font-weight: bold; 58 | } 59 | 60 | .message-col { 61 | white-space: pre; 62 | } 63 | 64 | .todo-pending-list { 65 | color: #404040; 66 | } 67 | 68 | .todo-ongoing-list { 69 | color: #000000; 70 | font-weight: bold; 71 | } 72 | 73 | .todo-completed-list { 74 | color: #7b7b7b; 75 | text-decoration: line-through; 76 | } 77 | 78 | .todo-completed-span { 79 | text-decoration: line-through; 80 | } 81 | 82 | .todo-completed-list { 83 | color: #777; 84 | } 85 | 86 | .no-list-item { 87 | font-style: italic; 88 | font-size: 0.9rem; 89 | color: #777; 90 | } 91 | -------------------------------------------------------------------------------- /frontend-vue/src/assets/images/bootstrap-vue.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/src/assets/images/bootstrap-vue.png -------------------------------------------------------------------------------- /frontend-vue/src/assets/images/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/frontend-vue/src/assets/images/logo.png -------------------------------------------------------------------------------- /frontend-vue/src/components/FooterBar.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 46 | -------------------------------------------------------------------------------- /frontend-vue/src/components/TodoAddBox.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 75 | -------------------------------------------------------------------------------- /frontend-vue/src/main.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import VueMeta from 'vue-meta'; 3 | import Vuelidate from 'vuelidate'; 4 | import BootstrapVue from 'bootstrap-vue'; 5 | import 'bootstrap/dist/css/bootstrap.css'; 6 | import 'bootstrap-vue/dist/bootstrap-vue.css'; 7 | import VueSweetalert2 from 'vue-sweetalert2'; 8 | import '@sweetalert2/theme-dark/dark.css'; 9 | import { library } from '@fortawesome/fontawesome-svg-core'; 10 | import { faTrash, faPlus, faCheckSquare, faSquare } from '@fortawesome/free-solid-svg-icons'; 11 | import { FontAwesomeIcon } from '@fortawesome/vue-fontawesome'; 12 | 13 | import App from './App.vue'; 14 | import router from './router'; 15 | import store from './store'; 16 | import setupInterceptors from './services/setupInterceptors'; 17 | import './registerServiceWorker'; 18 | 19 | Vue.config.productionTip = false; 20 | 21 | Vue.use(VueMeta, { 22 | // optional pluginOptions 23 | refreshOnceOnNavigation: true 24 | }); 25 | Vue.use(Vuelidate); 26 | Vue.use(BootstrapVue); 27 | Vue.use(VueSweetalert2, {}); 28 | 29 | library.add(faTrash, faPlus, faCheckSquare, faSquare); 30 | Vue.component('font-awesome-icon', FontAwesomeIcon); 31 | 32 | setupInterceptors(store); 33 | 34 | new Vue({ 35 | router, 36 | store, 37 | render: h => h(App) 38 | }).$mount('#app'); 39 | -------------------------------------------------------------------------------- /frontend-vue/src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-console */ 2 | 3 | import { register } from 'register-service-worker'; 4 | 5 | if (process.env.NODE_ENV === 'production') { 6 | register(`${process.env.BASE_URL}service-worker.js`, { 7 | ready() { 8 | console.log('App is being served from cache by a service worker.\nFor more details, visit https://goo.gl/AFskqB'); 9 | }, 10 | registered() { 11 | console.log('Service worker has been registered.'); 12 | }, 13 | cached() { 14 | console.log('Content has been cached for offline use.'); 15 | }, 16 | updatefound() { 17 | console.log('New content is downloading.'); 18 | }, 19 | updated() { 20 | console.log('New content is available; please refresh.'); 21 | }, 22 | offline() { 23 | console.log('No internet connection found. App is running in offline mode.'); 24 | }, 25 | error(error) { 26 | console.error('Error during service worker registration:', error); 27 | } 28 | }); 29 | } 30 | -------------------------------------------------------------------------------- /frontend-vue/src/services/api.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const instance = axios.create({ 4 | headers: { 5 | 'Content-Type': 'application/json' 6 | } 7 | }); 8 | export default instance; 9 | -------------------------------------------------------------------------------- /frontend-vue/src/services/authService.js: -------------------------------------------------------------------------------- 1 | import configService from '@/services/configService'; 2 | import api from './api'; 3 | 4 | export default { 5 | async passwordReset({ key, password }) { 6 | return api 7 | .post(`${configService.get('apiUrl')}/user/password-reset`, { 8 | key, 9 | password 10 | }) 11 | .then(response => response.data); 12 | }, 13 | 14 | async passwordResetRequest({ email }) { 15 | return api 16 | .post(`${configService.get('apiUrl')}/user/password-reset-request`, { 17 | email 18 | }) 19 | .then(response => response.data); 20 | }, 21 | 22 | async register({ username, email, password, firstName, lastName }) { 23 | return api 24 | .post(`${configService.get('apiUrl')}/user/register`, { 25 | username, 26 | email, 27 | password, 28 | first_name: firstName, 29 | last_name: lastName 30 | }) 31 | .then(response => response.data); 32 | }, 33 | 34 | async login(username, password) { 35 | return api 36 | .post(`${configService.get('apiUrl')}/user/login`, { 37 | username, 38 | password 39 | }) 40 | .then(response => response.data); 41 | } 42 | }; 43 | -------------------------------------------------------------------------------- /frontend-vue/src/services/configService.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import * as vueConfig from '../../vue.config'; 3 | 4 | class ConfigService { 5 | constructor() { 6 | this.config = {}; 7 | } 8 | 9 | async loadConfig() { 10 | const response = await axios.get(`${vueConfig.publicPath}static/config.json`); 11 | this.config = response.data; 12 | } 13 | 14 | set(key, value) { 15 | this.config[key] = value; 16 | } 17 | 18 | get(key) { 19 | return this.config[key]; 20 | } 21 | } 22 | 23 | export default new ConfigService(); 24 | -------------------------------------------------------------------------------- /frontend-vue/src/services/setupInterceptors.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-underscore-dangle */ 2 | import axiosInstance from './api'; 3 | import configService from './configService'; 4 | 5 | const setupInterceptors = store => { 6 | axiosInstance.interceptors.request.use( 7 | config => { 8 | const token = localStorage.getItem('frontend-vue-auth-key') || ''; 9 | if (token) { 10 | // eslint-disable-next-line no-param-reassign 11 | config.headers.Authorization = token; 12 | } 13 | return config; 14 | }, 15 | error => Promise.reject(error) 16 | ); 17 | axiosInstance.interceptors.response.use( 18 | res => res, 19 | async err => { 20 | const originalConfig = err.config; 21 | if (originalConfig.url !== `${configService.get('apiUrl')}/user/login` && err.response) { 22 | // Access Token was expired 23 | if (err.response.status === 401 && !originalConfig._retry) { 24 | originalConfig._retry = true; 25 | try { 26 | const response = await axiosInstance.post(`${configService.get('apiUrl')}/refresh-token`, { 27 | refreshToken: localStorage.getItem('frontend-vue-refresh-auth-key') || '' 28 | }); 29 | const { data } = response.data; 30 | 31 | store.commit('auth/loginSuccess', { authKey: data.auth_key, refreshAuthKey: data.refresh_auth_key }); 32 | 33 | return axiosInstance(originalConfig); 34 | } catch (error) { 35 | return Promise.reject(error); 36 | } 37 | } 38 | } 39 | return Promise.reject(err); 40 | } 41 | ); 42 | }; 43 | export default setupInterceptors; 44 | -------------------------------------------------------------------------------- /frontend-vue/src/services/todoService.js: -------------------------------------------------------------------------------- 1 | import configService from '@/services/configService'; 2 | import api from './api'; 3 | 4 | export default { 5 | async list({ state = undefined } = {}) { 6 | let url = `${configService.get('apiUrl')}/todo`; 7 | if (state) { 8 | url += `/${state}`; 9 | } 10 | 11 | return api.get(url, {}).then(response => response.data); 12 | }, 13 | 14 | async postOne({ todo }) { 15 | return api.post(`${configService.get('apiUrl')}/todo`, todo).then(response => response.data); 16 | }, 17 | 18 | async postBulk({ state, todoList }) { 19 | return api 20 | .post(`${configService.get('apiUrl')}/todo/${state}`, { 21 | todo: todoList 22 | }) 23 | .then(response => response.data); 24 | }, 25 | 26 | async deleteOne({ todoId }) { 27 | return api.delete(`${configService.get('apiUrl')}/todo/${todoId}`).then(response => response.data); 28 | } 29 | }; 30 | -------------------------------------------------------------------------------- /frontend-vue/src/services/userService.js: -------------------------------------------------------------------------------- 1 | import configService from '@/services/configService'; 2 | import api from './api'; 3 | 4 | export default { 5 | async me() { 6 | return api 7 | .get(`${configService.get('apiUrl')}/me`, {}) 8 | .then(response => response.data) 9 | .catch(e => { 10 | throw e; 11 | }); 12 | }, 13 | async updateMe(me) { 14 | return api 15 | .post(`${configService.get('apiUrl')}/me`, me) 16 | .then(response => response.data) 17 | .catch(e => { 18 | throw e; 19 | }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /frontend-vue/src/store/index.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue'; 2 | import Vuex from 'vuex'; 3 | 4 | import common from './modules/common'; 5 | import auth from './modules/auth'; 6 | import alert from './modules/alert'; 7 | import user from './modules/user'; 8 | import todo from './modules/todo'; 9 | 10 | Vue.use(Vuex); 11 | 12 | export default new Vuex.Store({ 13 | modules: { 14 | common, 15 | auth, 16 | alert, 17 | user, 18 | todo 19 | }, 20 | strict: true 21 | }); 22 | -------------------------------------------------------------------------------- /frontend-vue/src/store/modules/common.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash'; 2 | 3 | const state = {}; 4 | 5 | const actions = { 6 | handleServiceException({ dispatch, commit }, { e, router = null }) { 7 | if (e.response) { 8 | const { data, status } = e.response.data; 9 | 10 | let errorMessages = []; 11 | if (status === 422) { 12 | errorMessages = _.reduce( 13 | data, 14 | (errorMessages, tmpData) => { 15 | errorMessages.push(tmpData.msg); 16 | return errorMessages; 17 | }, 18 | [] 19 | ); 20 | 21 | commit('alert/setMessage', { type: 'error', message: _.join(errorMessages, '\r\n') }, { root: true }); 22 | } else if (status === 403) { 23 | dispatch('auth/sessionExpired', { router }, { root: true }); 24 | } 25 | } else { 26 | dispatch('alert/error', { showType: 'toast', title: 'Error', text: e.message }, { root: true }); 27 | throw new Error(e); 28 | } 29 | } 30 | }; 31 | 32 | const getters = {}; 33 | 34 | const mutations = {}; 35 | 36 | export default { 37 | namespaced: true, 38 | state, 39 | getters, 40 | actions, 41 | mutations 42 | }; 43 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Account/Account.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 54 | -------------------------------------------------------------------------------- /frontend-vue/src/views/FindPassword.vue: -------------------------------------------------------------------------------- 1 | 27 | 28 | 53 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Home.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 27 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Login.vue: -------------------------------------------------------------------------------- 1 | 30 | 31 | 57 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Logout.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 20 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Page/Page.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 41 | -------------------------------------------------------------------------------- /frontend-vue/src/views/PasswordReset.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 79 | -------------------------------------------------------------------------------- /frontend-vue/src/views/Register.vue: -------------------------------------------------------------------------------- 1 | 28 | 29 | 50 | -------------------------------------------------------------------------------- /frontend-vue/tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: ['cypress'], 3 | env: { 4 | mocha: true, 5 | 'cypress/globals': true 6 | }, 7 | rules: { 8 | strict: 'off' 9 | } 10 | }; 11 | -------------------------------------------------------------------------------- /frontend-vue/tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | 3 | // if you need a custom webpack configuration you can uncomment the following import 4 | // and then use the `file:preprocessor` event 5 | // as explained in the cypress docs 6 | // https://docs.cypress.io/api/plugins/preprocessors-api.html#Examples 7 | 8 | /* eslint-disable import/no-extraneous-dependencies, global-require, arrow-body-style */ 9 | // const webpack = require('@cypress/webpack-preprocessor') 10 | 11 | module.exports = (on, config) => { 12 | // on('file:preprocessor', webpack({ 13 | // webpackOptions: require('@vue/cli-service/webpack.config'), 14 | // watchOptions: {} 15 | // })) 16 | 17 | return Object.assign({}, config, { 18 | fixturesFolder: 'tests/e2e/fixtures', 19 | integrationFolder: 'tests/e2e/specs', 20 | screenshotsFolder: 'tests/e2e/screenshots', 21 | videosFolder: 'tests/e2e/videos', 22 | supportFile: 'tests/e2e/support/index.js' 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /frontend-vue/tests/e2e/specs/login.js: -------------------------------------------------------------------------------- 1 | describe('Login', () => { 2 | beforeEach(() => { 3 | cy.visit('/login'); 4 | cy.get('[data-cy="nav-bar-login"] a').click(); 5 | }); 6 | 7 | afterEach(() => { 8 | // Emulate logout 9 | cy.clearCookies(); 10 | }); 11 | 12 | it('displays form validation', () => { 13 | cy.get('[data-cy="login-username"]').clear(); 14 | cy.get('[data-cy="login-password"]').clear(); 15 | 16 | cy.get('[data-cy="login-form"]').submit(); 17 | 18 | cy.get('[data-cy="login-username-invalid"]').should('be.visible'); 19 | 20 | cy.get('[data-cy="login-password-invalid"]').should('be.visible'); 21 | }); 22 | 23 | it('displays error with invalid credentials', () => { 24 | cy.get('[data-cy="login-username"]').type('invalid-user'); 25 | cy.get('[data-cy="login-password"]').type('invalid-password'); 26 | 27 | cy.get('[data-cy="login-form"]').submit(); 28 | 29 | cy.get('[data-cy="login-username-invalid"]').should('not.be.visible'); 30 | 31 | cy.get('[data-cy="login-password-invalid"]').should('not.be.visible'); 32 | 33 | cy.get('[data-cy="login-error-message"]').should('contain', 'Your username or password is incorrect.'); 34 | }); 35 | 36 | it('logins successfully with valid credentials', () => { 37 | cy.get('[data-cy="login-username"]').type('user'); 38 | cy.get('[data-cy="login-password"]').type('123456'); 39 | 40 | cy.get('[data-cy="login-form"]').submit(); 41 | 42 | cy.get('[data-cy="nav-bar-welcome-text"] a').should('contain', 'Welcome, User'); 43 | }); 44 | }); 45 | -------------------------------------------------------------------------------- /frontend-vue/tests/e2e/support/commands.js: -------------------------------------------------------------------------------- 1 | // *********************************************** 2 | // This example commands.js shows you how to 3 | // create various custom commands and overwrite 4 | // existing commands. 5 | // 6 | // For more comprehensive examples of custom 7 | // commands please read more here: 8 | // https://on.cypress.io/custom-commands 9 | // *********************************************** 10 | // 11 | // 12 | // -- This is a parent command -- 13 | // Cypress.Commands.add("login", (email, password) => { ... }) 14 | // 15 | // 16 | // -- This is a child command -- 17 | // Cypress.Commands.add("drag", { prevSubject: 'element'}, (subject, options) => { ... }) 18 | // 19 | // 20 | // -- This is a dual command -- 21 | // Cypress.Commands.add("dismiss", { prevSubject: 'optional'}, (subject, options) => { ... }) 22 | // 23 | // 24 | // -- This is will overwrite an existing command -- 25 | // Cypress.Commands.overwrite("visit", (originalFn, url, options) => { ... }) 26 | 27 | // Cypress.Commands.add('logout', () => {}); 28 | -------------------------------------------------------------------------------- /frontend-vue/tests/e2e/support/index.js: -------------------------------------------------------------------------------- 1 | // *********************************************************** 2 | // This example support/index.js is processed and 3 | // loaded automatically before your test files. 4 | // 5 | // This is a great place to put global configuration and 6 | // behavior that modifies Cypress. 7 | // 8 | // You can change the location of this file or turn off 9 | // automatically serving support files with the 10 | // 'supportFile' configuration option. 11 | // 12 | // You can read more here: 13 | // https://on.cypress.io/configuration 14 | // *********************************************************** 15 | 16 | // Import commands.js using ES2015 syntax: 17 | import './commands'; 18 | 19 | // Alternatively you can use CommonJS syntax: 20 | // require('./commands') 21 | -------------------------------------------------------------------------------- /frontend-vue/tests/unit/App.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 2 | import BootstrapVue from 'bootstrap-vue'; 3 | import App from '@/App.vue'; 4 | import ConfigService from '@/services/configService'; 5 | 6 | // create an extended `Vue` constructor 7 | const localVue = createLocalVue(); 8 | 9 | // install plugins as normal 10 | localVue.use(BootstrapVue); 11 | 12 | const NavBarStub = { 13 | name: 'nav-bar', 14 | template: '', 15 | props: [] 16 | }; 17 | 18 | const FooterBarStub = { 19 | name: 'footer-bar', 20 | template: '', 21 | props: [] 22 | }; 23 | 24 | const RouterViewStub = { 25 | name: 'router-view', 26 | template: '
', 27 | props: [] 28 | }; 29 | 30 | describe('App.vue', () => { 31 | let wrapper; 32 | beforeAll(() => { 33 | ConfigService.loadConfig = jest.fn(); 34 | 35 | wrapper = shallowMount(App, { 36 | localVue, 37 | stubs: { 38 | NavBar: NavBarStub, 39 | FooterBar: FooterBarStub, 40 | RouterView: RouterViewStub 41 | } 42 | }); 43 | }); 44 | 45 | it('triggers ConfigService.loadConfig', () => { 46 | expect(ConfigService.loadConfig).toHaveBeenCalled(); 47 | }); 48 | 49 | it('renders nav bar component', () => { 50 | expect(wrapper.getComponent(NavBarStub).exists()).toBeTruthy(); 51 | }); 52 | 53 | it('renders footer bar component', () => { 54 | expect(wrapper.getComponent(FooterBarStub).exists()).toBeTruthy(); 55 | }); 56 | 57 | it('renders router-view component', () => { 58 | expect(wrapper.getComponent(RouterViewStub).exists()).toBeTruthy(); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /frontend-vue/tests/unit/views/Home.spec.js: -------------------------------------------------------------------------------- 1 | import { createLocalVue, shallowMount } from '@vue/test-utils'; 2 | 3 | import BootstrapVue from 'bootstrap-vue'; 4 | 5 | import Home from '@/views/Home.vue'; 6 | 7 | // create an extended `Vue` constructor 8 | const localVue = createLocalVue(); 9 | 10 | localVue.use(BootstrapVue); 11 | 12 | describe('Home.vue', () => { 13 | let wrapper; 14 | beforeEach(() => { 15 | wrapper = shallowMount(Home, { 16 | localVue 17 | }); 18 | }); 19 | 20 | it('renders', () => { 21 | expect(wrapper.find('.page-home').exists()).toBeTruthy(); 22 | }); 23 | }); 24 | -------------------------------------------------------------------------------- /frontend-vue/vue.config.js: -------------------------------------------------------------------------------- 1 | // vue.config.js 2 | 3 | module.exports = { 4 | publicPath: process.env.BASE_URL || '/', 5 | configureWebpack: { 6 | performance: { 7 | hints: 'warning', 8 | maxAssetSize: 2048576, 9 | maxEntrypointSize: 2048576 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /mysql/.gitignore: -------------------------------------------------------------------------------- 1 | boilerplate.mwb.bak -------------------------------------------------------------------------------- /mysql/backup/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/mysql/backup/.gitkeep -------------------------------------------------------------------------------- /mysql/boilerplate.mwb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/chrisleekr/nodejs-vuejs-mysql-boilerplate/010aa24b0526751dfcecf2447ae2b6c634676576/mysql/boilerplate.mwb -------------------------------------------------------------------------------- /mysql/conf.d/my.cnf: -------------------------------------------------------------------------------- 1 | [mysqld] 2 | character-set-server=utf8mb4 3 | collation-server=utf8mb4_unicode_ci 4 | skip-character-set-client-handshake 5 | -------------------------------------------------------------------------------- /mysql/create-backup-sql.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | DATETIME=$(date '+%Y%m%d%H%M%S') 6 | 7 | docker exec mysql mysqldump -h127.0.0.1 -uroot -proot --databases boilerplate > "backup/backup-${DATETIME}.sql" 8 | -------------------------------------------------------------------------------- /nginx/index.html: -------------------------------------------------------------------------------- 1 | Node.js + Vue.js + MySQL Boilerplate 2 | -------------------------------------------------------------------------------- /nginx/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | --------------------------------------------------------------------------------