├── .browserslistrc ├── .dockerignore ├── .editorconfig ├── .env.template ├── .eslintrc.js ├── .github └── workflows │ └── ci.yml ├── .gitignore ├── .vscode ├── launch.json └── settings.json ├── Dockerfile ├── LICENSE ├── README.md ├── TODO.md ├── backend ├── constants.js ├── libs │ ├── graphql-generator.js │ ├── openapi-generator.js │ ├── swagger-ui.js │ └── utils.js ├── middlewares │ ├── async-context.middleware.js │ ├── check-permissions.middleware.js │ ├── docker-compose-generator.middleware.js │ ├── find-entity.middleware.js │ ├── graphql-generator.middleware.js │ ├── openapi-generator.middleware.js │ └── prometheus-file-generator.middleware.js ├── mixins │ ├── apollo.mixin.js │ ├── board-validators.mixin.js │ ├── cache-cleaner.mixin.js │ ├── config.mixin.js │ ├── cron.mixin.js │ ├── db.mixin.js │ ├── i18next.mixin.js │ ├── member-check.mixin.js │ ├── memoize.mixin.js │ ├── next-position.mixin.js │ ├── openapi.mixin.js │ ├── passport.mixin.js │ ├── strategies │ │ ├── facebook.strategy.mixin.js │ │ ├── github.strategy.mixin.js │ │ ├── google.strategy.mixin.js │ │ └── twitter.strategy.mixin.js │ └── token-generator.mixin.js ├── services │ ├── accounts.service.js │ ├── activities.service.js │ ├── api.service.js │ ├── boards.service.js │ ├── card.attachments.service.js │ ├── card.checklists.service.js │ ├── cards.service.js │ ├── config.service.js │ ├── laboratory.service.js │ ├── lists.service.js │ ├── mail.service.js │ └── tokens.service.js ├── templates │ └── mail │ │ ├── activate │ │ ├── html.pug │ │ └── subject.hbs │ │ ├── magic-link │ │ ├── html.pug │ │ └── subject.hbs │ │ ├── password-changed │ │ ├── html.pug │ │ └── subject.hbs │ │ ├── reset-password │ │ ├── html.pug │ │ └── subject.hbs │ │ └── welcome │ │ ├── html.pug │ │ └── subject.hbs └── tests │ ├── integration │ ├── actions.spec.js │ ├── checks.js │ ├── env.js │ └── helper-actions.js │ └── unit │ ├── accounts.service.spec.js │ └── config.service.spec.js ├── cypress.json ├── docker-compose.dev.yml ├── docker-compose.env ├── docker-compose.yml ├── frontend ├── .eslintrc.js ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── postcss.config.js ├── public │ ├── favicon.ico │ └── icons │ │ ├── android-chrome-192x192.png │ │ ├── android-chrome-512x512.png │ │ ├── apple-touch-icon.png │ │ ├── browserconfig.xml │ │ ├── favicon-16x16.png │ │ ├── favicon-32x32.png │ │ ├── favicon.ico │ │ ├── mstile-144x144.png │ │ ├── mstile-150x150.png │ │ ├── mstile-310x150.png │ │ ├── mstile-310x310.png │ │ ├── mstile-70x70.png │ │ └── site.webmanifest ├── src │ ├── App.vue │ ├── apollo.js │ ├── assets │ │ └── board-icon.svg │ ├── bus.js │ ├── components │ │ ├── Card.vue │ │ ├── Dialog.vue │ │ ├── E2FADialog.vue │ │ ├── EditBoardDialog.vue │ │ ├── EditCardDialog.vue │ │ ├── EditListDialog.vue │ │ ├── GuideSection.vue │ │ ├── KanbanItem.vue │ │ ├── Logo.vue │ │ ├── Panel.vue │ │ ├── SocialAuth.vue │ │ ├── SocialLinks.vue │ │ └── board │ │ │ ├── Board.vue │ │ │ ├── Card.vue │ │ │ ├── List.vue │ │ │ └── NewCard.vue │ ├── graphqlClient.js │ ├── i18next.js │ ├── jsconfig.json │ ├── layouts │ │ ├── AppLayout.vue │ │ └── AuthLayout.vue │ ├── main.js │ ├── mixins │ │ ├── auth.mixin.js │ │ └── dateFormatter.js │ ├── pages │ │ ├── About.vue │ │ ├── Board.vue │ │ ├── Home.vue │ │ ├── NotFound.vue │ │ ├── StyleGuide.vue │ │ └── auth │ │ │ ├── ForgotPassword.vue │ │ │ ├── Login.vue │ │ │ ├── Passwordless.vue │ │ │ ├── ResetPassword.vue │ │ │ ├── SignUp.vue │ │ │ └── VerifyAccount.vue │ ├── registerServiceWorker.js │ ├── router │ │ ├── index.js │ │ └── routes.js │ ├── store │ │ ├── authStore.js │ │ └── store.js │ ├── styles │ │ ├── buttons.css │ │ ├── common.css │ │ ├── extras.css │ │ ├── forms.css │ │ ├── index.css │ │ └── tables.css │ ├── toast.js │ └── utils.js ├── tailwind.config.js └── vite.config.js ├── graphql.config.js ├── graphql.md ├── jsconfig.json ├── kubernetes ├── .env.example ├── .gitignore ├── .secret.example ├── README.md ├── accounts-deployment.yaml ├── activities-deployment.yaml ├── api-deployment.yaml ├── boards-deployment.yaml ├── cards-attachments-deployment.yaml ├── cards-checklists-deployment.yaml ├── cards-deployment.yaml ├── config-deployment.yaml ├── lab-deployment.yaml ├── lists-deployment.yaml ├── mail-deployment.yaml ├── mongodb.yaml ├── nats.yaml ├── redis.yaml └── tokens-deployment.yaml ├── locales ├── en │ ├── common.json │ ├── common.missing.json │ └── errors.json └── hu │ ├── common.json │ ├── common.missing.json │ └── errors.json ├── moleculer.config.js ├── monitoring ├── README.md ├── alertmanager │ └── config.yml ├── grafana │ ├── plugins │ │ └── grafana-piechart-panel │ │ │ ├── LICENSE │ │ │ ├── MANIFEST.txt │ │ │ ├── README.md │ │ │ ├── dark.js │ │ │ ├── dark.js.map │ │ │ ├── editor.html │ │ │ ├── img │ │ │ ├── piechart-donut.png │ │ │ ├── piechart-legend-on-graph.png │ │ │ ├── piechart-legend-rhs.png │ │ │ ├── piechart-legend-under.png │ │ │ ├── piechart-options.png │ │ │ ├── piechart_logo_large.png │ │ │ ├── piechart_logo_large.svg │ │ │ ├── piechart_logo_small.png │ │ │ └── piechart_logo_small.svg │ │ │ ├── light.js │ │ │ ├── light.js.map │ │ │ ├── module.html │ │ │ ├── module.js │ │ │ ├── module.js.LICENSE.txt │ │ │ ├── module.js.map │ │ │ ├── plugin.json │ │ │ └── styles │ │ │ ├── dark.css │ │ │ └── light.css │ └── provisioning │ │ ├── dashboards │ │ ├── Moleculer_Dashboard.json │ │ ├── MongoDB.json │ │ ├── NATS.json │ │ ├── Prometheus.json │ │ ├── Redis.json │ │ ├── Traefik.json │ │ ├── dashboard.yml │ │ └── moleculer.json │ │ └── datasources │ │ └── datasource.yml └── prometheus │ ├── .gitignore │ ├── alert.rules │ └── prometheus.yml ├── package-lock.json ├── package.json ├── prettier.config.js ├── repl-commands ├── accounts.js ├── boards.js ├── create-board.js ├── index.js ├── lists.js └── rest.js └── tests └── e2e ├── .eslintrc.js ├── .gitignore ├── bootstrap.js ├── maildev.service.js ├── plugins └── index.js ├── specs └── accounts │ ├── login.js │ ├── reset-password.js │ └── signup.js ├── support ├── commands.js └── index.js └── util └── mailtrap.js /.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | not ie <= 8 4 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | .github 3 | coverage/ 4 | frontend/ 5 | kubernetes/ 6 | logs/ 7 | monitoring/ 8 | node_modules/ 9 | tests/ 10 | tools/ 11 | .env 12 | docker-compose*.env 13 | docker-compose*.yml 14 | Dockerfile 15 | 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig helps developers define and maintain consistent 2 | # coding styles between different editors and IDEs 3 | # editorconfig.org 4 | 5 | root = true 6 | 7 | [*] 8 | 9 | # Change these settings to your own preference 10 | indent_style = tab 11 | indent_size = 4 12 | space_after_anon_function = true 13 | 14 | # We recommend you to keep these unchanged 15 | end_of_line = lf 16 | charset = utf-8 17 | trim_trailing_whitespace = true 18 | insert_final_newline = true 19 | 20 | [*.md] 21 | trim_trailing_whitespace = false 22 | indent_style = space 23 | indent_size = 4 24 | 25 | [{package,bower}.json] 26 | indent_style = space 27 | indent_size = 2 28 | 29 | [*.yml] 30 | indent_style = space 31 | indent_size = 2 32 | 33 | [*.js] 34 | quote_type = "double" 35 | 36 | [*.hbs] 37 | trim_trailing_whitespace = true 38 | insert_final_newline = false 39 | -------------------------------------------------------------------------------- /.env.template: -------------------------------------------------------------------------------- 1 | JWT_SECRET= 2 | HASHID_SALT= 3 | TOKEN_SALT= 4 | 5 | GOOGLE_CLIENT_ID= 6 | GOOGLE_CLIENT_SECRET= 7 | 8 | FACEBOOK_CLIENT_ID= 9 | FACEBOOK_CLIENT_SECRET= 10 | 11 | GITHUB_CLIENT_ID= 12 | GITHUB_CLIENT_SECRET= 13 | 14 | MAILTRAP_USER= 15 | MAILTRAP_PASS= 16 | 17 | APOLLO_KEY= 18 | APOLLO_GRAPH_VARIANT= 19 | 20 | LABORATORY_APIKEY= 21 | LABORATORY_TOKEN= 22 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true, 5 | commonjs: true, 6 | es6: true, 7 | jquery: false, 8 | jest: true, 9 | jasmine: true 10 | }, 11 | extends: ["eslint:recommended", "plugin:prettier/recommended"], 12 | //plugins: ["prettier"], 13 | parserOptions: { 14 | sourceType: "module", 15 | ecmaVersion: 9 16 | }, 17 | rules: { 18 | semi: ["error", "always"], 19 | "no-var": ["error"], 20 | "no-console": ["off"], 21 | "no-unused-vars": ["warn"] 22 | } 23 | }; 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI test 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | test: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | 12 | - name: Use Node.js 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: 16.x 16 | 17 | - name: Install dependencies 18 | run: npm i --legacy-peer-deps 19 | 20 | - name: Install dependencies in frontend 21 | run: npm i --legacy-peer-deps 22 | working-directory: frontend 23 | 24 | - name: Build frontend 25 | run: npm run build 26 | working-directory: frontend 27 | 28 | - name: Run unit tests 29 | run: npm run test:unit 30 | 31 | - name: Start MongoDB 32 | uses: supercharge/mongodb-github-action@1.3.0 33 | with: 34 | mongodb-version: 5.0 35 | 36 | - name: Run integration tests 37 | run: npm run test:integration 38 | 39 | - name: Run E2E tests 40 | run: npm run test:e2e 41 | 42 | - name: Upload E2E videos 43 | uses: actions/upload-artifact@v2 44 | with: 45 | name: videos 46 | path: | 47 | tests/e2e/videos 48 | if: failure() 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # Typescript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | tools/ 61 | data/ 62 | public/ 63 | schema.gql 64 | openapi.json 65 | service-schema.json 66 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible Node.js debug attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "type": "node", 9 | "request": "launch", 10 | "name": "Debug", 11 | "program": "${workspaceRoot}/node_modules/moleculer/bin/moleculer-runner.js", 12 | "cwd": "${workspaceRoot}", 13 | "args": [ 14 | "--hot", 15 | "--env", 16 | "backend/services/**/*.js" 17 | ], 18 | "env":{"PORT": "4000"} 19 | }, 20 | { 21 | "name": "Attach", 22 | "port": 9229, 23 | "request": "attach", 24 | "skipFiles": [ 25 | "/**" 26 | ], 27 | "type": "pwa-node", 28 | "restart": true 29 | }, 30 | { 31 | "type": "node", 32 | "request": "launch", 33 | "name": "E2E test", 34 | "program": "${workspaceRoot}/node_modules/moleculer/bin/moleculer-runner.js", 35 | "cwd": "${workspaceRoot}", 36 | "env": { 37 | "TEST_E2E": "true", 38 | "MONGO_URI": "mongodb://localhost/kantab-e2e" 39 | }, 40 | "args": [ 41 | "--env", 42 | "backend/services" 43 | ] 44 | }, 45 | { 46 | "type": "node", 47 | "request": "launch", 48 | "name": "Jest unit", 49 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 50 | "args": [ 51 | "--testMatch", 52 | "**/unit/**/*.spec.js", 53 | "--runInBand" 54 | ], 55 | "cwd": "${workspaceRoot}", 56 | "runtimeArgs": [ 57 | "--nolazy" 58 | ] 59 | }, 60 | { 61 | "type": "node", 62 | "request": "launch", 63 | "name": "Jest integration", 64 | "program": "${workspaceRoot}/node_modules/jest-cli/bin/jest.js", 65 | "args": [ 66 | "--testMatch", 67 | "**/integration/**/*.spec.js", 68 | "--runInBand", 69 | "--no-cache" 70 | ], 71 | "cwd": "${workspaceRoot}", 72 | "runtimeArgs": [ 73 | "--nolazy" 74 | ], 75 | "env": { 76 | "TEST_INT": "run", 77 | "MONGO_URI": "mongodb://localhost/kantab-int" 78 | } 79 | } 80 | ] 81 | } 82 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "jest.autoEnable": false 3 | } 4 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:16-alpine 2 | 3 | ENV NODE_ENV=production 4 | 5 | RUN mkdir /app 6 | WORKDIR /app 7 | 8 | COPY package.json package-lock.json ./ 9 | 10 | RUN npm install --production --legacy-peer-deps 11 | 12 | COPY . . 13 | 14 | CMD ["npm", "start"] 15 | -------------------------------------------------------------------------------- /backend/constants.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const C = { 4 | STATUS_ACTIVE: 1, 5 | STATUS_INACTIVE: 0, 6 | STATUS_DELETED: -1, 7 | 8 | ROLE_SYSTEM: "$system", 9 | ROLE_EVERYONE: "$everyone", 10 | ROLE_AUTHENTICATED: "$authenticated", 11 | ROLE_BOARD_MEMBER: "$board-member", 12 | ROLE_BOARD_OWNER: "$board-owner", 13 | ROLE_ADMINISTRATOR: "administrator", 14 | ROLE_USER: "user", 15 | 16 | VISIBILITY_PRIVATE: "private", 17 | VISIBILITY_PROTECTED: "protected", 18 | VISIBILITY_PUBLIC: "public", 19 | VISIBILITY_PUBLISHED: "published", 20 | 21 | TOKEN_TYPE_VERIFICATION: "verification", 22 | TOKEN_TYPE_PASSWORDLESS: "passwordless", 23 | TOKEN_TYPE_PASSWORD_RESET: "password-reset", 24 | TOKEN_TYPE_API_KEY: "api-key" 25 | }; 26 | 27 | module.exports = { 28 | ...C, 29 | 30 | TOKEN_TYPES: [ 31 | C.TOKEN_TYPE_VERIFICATION, 32 | C.TOKEN_TYPE_PASSWORDLESS, 33 | C.TOKEN_TYPE_PASSWORD_RESET, 34 | C.TOKEN_TYPE_API_KEY 35 | ], 36 | 37 | DEFAULT_LABELS: [ 38 | { id: 1, name: "Low priority", color: "#fad900" }, 39 | { id: 2, name: "Medium priority", color: "#ff9f19" }, 40 | { id: 3, name: "High priority", color: "#eb4646" } 41 | ], 42 | 43 | TIMESTAMP_FIELDS: { 44 | createdAt: { 45 | type: "number", 46 | readonly: true, 47 | onCreate: () => Date.now(), 48 | graphql: { type: "Long" } 49 | }, 50 | updatedAt: { 51 | type: "number", 52 | readonly: true, 53 | onUpdate: () => Date.now(), 54 | graphql: { type: "Long" } 55 | }, 56 | deletedAt: { 57 | type: "number", 58 | readonly: true, 59 | hidden: "byDefault", 60 | onRemove: () => Date.now(), 61 | graphql: { type: "Long" } 62 | } 63 | }, 64 | 65 | ARCHIVED_FIELDS: { 66 | archived: { type: "boolean", readonly: true, default: false }, 67 | archivedAt: { type: "number", readonly: true, graphql: { type: "Long" } } 68 | } 69 | }; 70 | -------------------------------------------------------------------------------- /backend/libs/utils.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | capitalize(str) { 3 | return str[0].toUpperCase() + str.slice(1); 4 | }, 5 | 6 | uncapitalize(str) { 7 | return str[0].toLowerCase() + str.slice(1); 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /backend/middlewares/async-context.middleware.js: -------------------------------------------------------------------------------- 1 | const { AsyncLocalStorage } = require("async_hooks"); 2 | 3 | const asyncLocalStorage = new AsyncLocalStorage(); 4 | 5 | module.exports = { 6 | name: "AsyncContext", 7 | 8 | localAction(handler) { 9 | return ctx => asyncLocalStorage.run(ctx, () => handler(ctx)); 10 | }, 11 | 12 | serviceCreated(svc) { 13 | svc.getContext = () => asyncLocalStorage.getStore(); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /backend/middlewares/check-permissions.middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const { MoleculerClientError } = require("moleculer").Errors; 5 | const C = require("../constants"); 6 | 7 | module.exports = { 8 | name: "CheckPermissions", 9 | 10 | // Wrap local action handlers 11 | localAction(handler, action) { 12 | // If this feature enabled 13 | if (action.permissions) { 14 | const permissions = Array.isArray(action.permissions) 15 | ? action.permissions 16 | : [action.permissions]; 17 | 18 | const permNames = []; 19 | const permFuncs = []; 20 | permissions.forEach(p => { 21 | if (_.isFunction(p)) { 22 | // Add custom permission function 23 | return permFuncs.push(p); 24 | } 25 | 26 | if (_.isString(p)) { 27 | if (p == C.ROLE_AUTHENTICATED) { 28 | // Check if user is logged in 29 | return permFuncs.push(async ctx => { 30 | return !!ctx.meta.userID; 31 | }); 32 | } 33 | 34 | if (p == C.ROLE_BOARD_OWNER) { 35 | // Check if user is owner of the board 36 | return permFuncs.push(async ctx => { 37 | if (_.isFunction(ctx.service.isBoardOwner)) 38 | return ctx.service.isBoardOwner.call(this, ctx); 39 | return false; 40 | }); 41 | } 42 | 43 | if (p == C.ROLE_BOARD_MEMBER) { 44 | // Check if user is member of the entity 45 | return permFuncs.push(async ctx => { 46 | if (_.isFunction(ctx.service.isBoardMember)) 47 | return ctx.service.isBoardMember.call(this, ctx); 48 | return false; 49 | }); 50 | } 51 | 52 | // Add role or permission name 53 | permNames.push(p); 54 | } 55 | }); 56 | 57 | return async function CheckPermissionsMiddleware(ctx) { 58 | let res = false; 59 | 60 | if (ctx.meta.$repl == true) res = true; 61 | if (ctx.meta.roles && ctx.meta.roles.includes(C.ROLE_ADMINISTRATOR)) res = true; 62 | if (permFuncs.length == 0) res = true; 63 | 64 | if (res !== true) { 65 | if (permFuncs.length > 0) { 66 | const results = await ctx.broker.Promise.all( 67 | permFuncs.map(async fn => fn.call(this, ctx)) 68 | ); 69 | res = results.some(r => !!r); 70 | } 71 | 72 | if (res !== true) 73 | throw new MoleculerClientError( 74 | "You have no right for this operation!", 75 | 401, 76 | "ERR_HAS_NO_ACCESS", 77 | { action: action.name } 78 | ); 79 | } 80 | 81 | // Call the handler 82 | return handler(ctx); 83 | }.bind(this); 84 | } 85 | 86 | // Return original handler, because feature is disabled 87 | return handler; 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /backend/middlewares/docker-compose-generator.middleware.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | const yaml = require("js-yaml"); 5 | 6 | module.exports = { 7 | name: "DockerComposeGenerator", 8 | 9 | started(broker) { 10 | const common = broker.metadata.dockerCompose; 11 | if (!common || !common.root) return; 12 | 13 | const res = _.cloneDeep(common.root); 14 | 15 | if (res.services == null) res.services = {}; 16 | 17 | const svcBaseDir = common.serviceBaseDir 18 | ? path.resolve(common.serviceBaseDir) 19 | : path.resolve(".", "services"); 20 | 21 | broker.services.forEach(svc => { 22 | if (svc.metadata.dockerCompose === false) return; 23 | if (svc.metadata.dockerCompose == null && !common.serviceTemplate) return; 24 | if (svc.name.startsWith("$")) return; 25 | 26 | const schema = _.defaultsDeep( 27 | {}, 28 | svc.metadata.dockerCompose ? svc.metadata.dockerCompose.template : null, 29 | common.serviceTemplate 30 | ); 31 | if (schema.environment == null) schema.environment = {}; 32 | 33 | if (schema.environment.SERVICES == null) { 34 | const relPath = path.relative(svcBaseDir, svc.__filename); 35 | schema.environment.SERVICES = relPath.replace(/\\/g, "/"); 36 | } 37 | 38 | const serviceName = 39 | svc.metadata.dockerCompose && svc.metadata.dockerCompose.name 40 | ? svc.metadata.dockerCompose.name 41 | : svc.fullName.replace(/[^\w\d]/g, "_"); 42 | res.services[serviceName] = schema; 43 | }); 44 | 45 | const content = yaml.dump(res); 46 | 47 | fs.writeFileSync(common.filename, content, "utf8"); 48 | 49 | broker.logger.info(`Docker Compose file generated. Filename: ${common.filename}`); 50 | 51 | if (process.env.ONLY_GENERATE) { 52 | broker.logger.info(`Shutting down...`); 53 | broker.stop(); 54 | } 55 | } 56 | }; 57 | -------------------------------------------------------------------------------- /backend/middlewares/find-entity.middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | module.exports = { 6 | name: "FindEntity", 7 | 8 | // Wrap local action handlers 9 | localAction(handler, action) { 10 | // If this feature enabled 11 | if (action.needEntity) { 12 | return async function FindEntityMiddleware(ctx) { 13 | const svc = ctx.service; 14 | const params = { id: ctx.params.id }; 15 | if (action.scopes) { 16 | params.scope = action.scopes; 17 | } else { 18 | params.scope = ctx.params.scope; 19 | } 20 | if (action.defaultPopulate) { 21 | params.populate = action.defaultPopulate; 22 | } 23 | ctx.locals.entity = await svc.resolveEntities(ctx, params, { 24 | throwIfNotExist: true 25 | }); 26 | 27 | // Call the handler 28 | return handler(ctx); 29 | }.bind(this); 30 | } 31 | 32 | // Return original handler, because feature is disabled 33 | return handler; 34 | } 35 | }; 36 | -------------------------------------------------------------------------------- /backend/middlewares/graphql-generator.middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const pluralize = require("pluralize"); 4 | const { generateCRUDGraphQL } = require("../libs/graphql-generator"); 5 | 6 | module.exports = { 7 | name: "GraphQL-Generator", 8 | 9 | serviceCreating(svc, schema) { 10 | if ( 11 | !schema.settings || 12 | !schema.settings.graphql || 13 | !["accounts", "boards", "lists", "cards"].includes(schema.name) 14 | ) 15 | return; 16 | 17 | let name = schema.settings.graphql.entityName || schema.name; 18 | const entityName = pluralize(name, 1); 19 | generateCRUDGraphQL(entityName, schema); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /backend/middlewares/openapi-generator.middleware.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const pluralize = require("pluralize"); 4 | const { generateOpenAPISchema } = require("../libs/openapi-generator"); 5 | 6 | module.exports = { 7 | name: "OpenAPI-Generator", 8 | 9 | serviceCreating(svc, schema) { 10 | const name = schema.name; 11 | if (!["boards", "lists", "accounts", "cards"].includes(name)) return; 12 | const entityName = pluralize(name, 1); 13 | generateOpenAPISchema(entityName, schema); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /backend/middlewares/prometheus-file-generator.middleware.js: -------------------------------------------------------------------------------- 1 | const _ = require("lodash"); 2 | const fs = require("fs"); 3 | const path = require("path"); 4 | 5 | module.exports = function (opts) { 6 | opts = _.defaultsDeep(opts, { 7 | filename: "./monitoring/prometheus/targets.json", 8 | jobName: "kantab", 9 | port: 3030 10 | }); 11 | 12 | /** 13 | * Generates an updated target list. 14 | * More info: https://prometheus.io/docs/prometheus/latest/configuration/configuration/#file_sd_config 15 | */ 16 | async function regenerateTargets(broker) { 17 | let nodeList = await broker.registry.getNodeList({ onlyAvailable: true }); 18 | 19 | // Skip CLI clients 20 | nodeList = nodeList.filter(node => !node.id.startsWith("cli-")); 21 | 22 | const targets = nodeList.map(node => ({ 23 | labels: { job: opts.jobName || node.hostname, nodeID: node.id }, 24 | targets: [`${node.hostname}:${opts.port}`] 25 | })); 26 | 27 | try { 28 | fs.writeFileSync(path.resolve(opts.filename), JSON.stringify(targets, null, 2), "utf8"); 29 | broker.logger.debug("Successfully updated Prometheus target file"); 30 | } catch (error) { 31 | broker.logger.warn("Broker couldn't write to Prometheus' target file"); 32 | } 33 | } 34 | 35 | return { 36 | name: "PrometheusFileGenerator", 37 | 38 | async started(broker) { 39 | broker.localBus.on("$node.connected", () => regenerateTargets(broker)); 40 | broker.localBus.on("$node.disconnected", () => regenerateTargets(broker)); 41 | 42 | regenerateTargets(broker); 43 | } 44 | }; 45 | }; 46 | -------------------------------------------------------------------------------- /backend/mixins/apollo.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const fs = require("fs"); 4 | 5 | const { ApolloService } = require("moleculer-apollo-server"); 6 | 7 | const { GraphQLError } = require("graphql"); 8 | const Kind = require("graphql/language").Kind; 9 | const { GraphQLJSONObject } = require("graphql-type-json"); 10 | const GraphQLLong = require("graphql-type-long"); 11 | 12 | const depthLimit = require("graphql-depth-limit"); 13 | const { createComplexityLimitRule } = require("graphql-validation-complexity"); 14 | 15 | module.exports = { 16 | mixins: [ 17 | // GraphQL 18 | ApolloService({ 19 | typeDefs: ` 20 | scalar Date 21 | scalar JSON 22 | scalar Long 23 | `, 24 | 25 | resolvers: { 26 | Date: { 27 | __parseValue(value) { 28 | return new Date(value); // value from the client 29 | }, 30 | __serialize(value) { 31 | return value.getTime(); // value sent to the client 32 | }, 33 | __parseLiteral(ast) { 34 | if (ast.kind === Kind.INT) return parseInt(ast.value, 10); // ast value is always in string format 35 | 36 | return null; 37 | } 38 | }, 39 | JSON: GraphQLJSONObject, 40 | Long: GraphQLLong 41 | }, 42 | 43 | routeOptions: { 44 | authentication: true, 45 | cors: { 46 | origin: "*" 47 | } 48 | }, 49 | 50 | // https://www.apollographql.com/docs/apollo-server/v2/api/apollo-server.html 51 | serverOptions: { 52 | tracing: false, 53 | introspection: true, 54 | 55 | validationRules: [ 56 | depthLimit(10), 57 | createComplexityLimitRule(1000, { 58 | createError(cost, documentNode) { 59 | const error = new GraphQLError("custom error", [documentNode]); 60 | error.meta = { cost }; 61 | return error; 62 | } 63 | }) 64 | ] 65 | } 66 | }) 67 | ], 68 | 69 | methods: { 70 | /** 71 | * Prepare context params for GraphQL requests. 72 | * 73 | * @param {Object} params 74 | * @param {String} actionName 75 | * @returns {Boolean} 76 | */ 77 | prepareContextParams(params, actionName) { 78 | if (params.input) { 79 | if ([".create", ".update", ".replace"].some(method => actionName.endsWith(method))) 80 | return params.input; 81 | } 82 | return params; 83 | } 84 | }, 85 | 86 | events: { 87 | "graphql.schema.updated"({ schema }) { 88 | this.logger.info("Generated GraphQL schema:\n\n" + schema); 89 | fs.writeFileSync("./schema.gql", schema, "utf8"); 90 | } 91 | } 92 | }; 93 | -------------------------------------------------------------------------------- /backend/mixins/board-validators.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | methods: { 5 | /** 6 | * Validate the `board` property of list. 7 | */ 8 | validateBoard({ ctx, value }) { 9 | return ctx 10 | .call("v1.boards.resolve", { id: value, throwIfNotExist: true, fields: ["id"] }) 11 | .then(() => true) 12 | .catch(err => err.message); 13 | }, 14 | 15 | /** 16 | * Validate the `board` property of list. 17 | */ 18 | validateList({ ctx, value }) { 19 | return ctx 20 | .call("v1.lists.resolve", { id: value, throwIfNotExist: true, fields: ["id"] }) 21 | .then(() => true) 22 | .catch(err => err.message); 23 | } 24 | } 25 | }; 26 | -------------------------------------------------------------------------------- /backend/mixins/cache-cleaner.mixin.js: -------------------------------------------------------------------------------- 1 | module.exports = function (eventNames) { 2 | const events = {}; 3 | 4 | eventNames.forEach(name => { 5 | events[name] = function () { 6 | if (this.broker.cacher) { 7 | this.logger.debug(`Clear local '${this.fullName}' cache`); 8 | this.broker.cacher.clean(`${this.fullName}.**`); 9 | } 10 | }; 11 | }); 12 | 13 | return { 14 | events 15 | }; 16 | }; 17 | -------------------------------------------------------------------------------- /backend/mixins/config.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const { match } = require("moleculer").Utils; 5 | 6 | module.exports = function (keys, opts) { 7 | opts = _.defaultsDeep(opts, { 8 | propName: "config", 9 | objPropName: "configObj", 10 | configChanged: "configChanged", 11 | serviceName: "config", 12 | serviceVersion: 1 13 | }); 14 | 15 | return { 16 | dependencies: [{ name: opts.serviceName, version: opts.serviceVersion }], 17 | 18 | events: { 19 | async "config.changed"(ctx) { 20 | this.logger.info("Configuration changed. Updating..."); 21 | const changes = Array.isArray(ctx.params) ? ctx.params : [ctx.params]; 22 | changes.forEach(item => { 23 | if (keys.some(key => match(item.key, key))) { 24 | this[opts.propName][item.key] = item.value; 25 | _.set(this[opts.objPropName], item.key, item.value); 26 | this.logger.debug("Configuration updated:", this[opts.propName]); 27 | 28 | if (_.isFunction(this[opts.configChanged])) { 29 | this[opts.configChanged].call(this, item.key, item.value, item); 30 | } 31 | } 32 | }); 33 | this.logger.info("Configuration changed.", this[opts.propName]); 34 | } 35 | }, 36 | 37 | async started() { 38 | if (!_.isObject(this[opts.propName])) this[opts.propName] = {}; 39 | if (!_.isObject(this[opts.objPropName])) this[opts.objPropName] = {}; 40 | 41 | if (keys.length > 0) { 42 | const items = await this.broker.call("v1.config.get", { key: keys }); 43 | if (items) { 44 | items.forEach(item => { 45 | this[opts.propName][item.key] = item.value; 46 | _.set(this[opts.objPropName], item.key, item.value); 47 | }); 48 | } 49 | } 50 | 51 | this.logger.debug("Configuration loaded:", this[opts.propName]); 52 | } 53 | }; 54 | }; 55 | -------------------------------------------------------------------------------- /backend/mixins/cron.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const cron = require("cron"); 4 | 5 | /** 6 | * Mixin service for Cron 7 | * Credits: https://github.com/davidroman0O/moleculer-cron 8 | * 9 | * @name moleculer-cron 10 | * @module Service 11 | */ 12 | module.exports = { 13 | name: "cron", 14 | 15 | /** 16 | * Methods 17 | */ 18 | methods: { 19 | /** 20 | * Find a job by name 21 | * 22 | * @param {String} name 23 | * @returns {CronJob} 24 | */ 25 | getJob(name) { 26 | return this.$crons.find(job => job.name == name); 27 | }, 28 | 29 | // stolen on StackOverflow 30 | makeid(size) { 31 | let text = ""; 32 | let possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; 33 | 34 | for (let i = 0; i < size; i++) 35 | text += possible.charAt(Math.floor(Math.random() * possible.length)); 36 | 37 | return text; 38 | }, 39 | 40 | /** 41 | * Get a Cron time 42 | * @param {String} time 43 | */ 44 | getCronTime(time) { 45 | return new cron.CronTime(time); 46 | } 47 | }, 48 | 49 | /** 50 | * Service created lifecycle event handler 51 | */ 52 | created() { 53 | this.$crons = []; 54 | 55 | if (this.schema.crons) { 56 | this.$crons = this.schema.crons.map(_job => { 57 | const job = Object.assign({}, _job); 58 | if (!job.name) job.name = this.makeid(20); 59 | 60 | // Prevent error on runOnInit that handle onTick at the end of the constructor 61 | const runOnInit = job.runOnInit; 62 | job.runOnInit = undefined; 63 | 64 | if (typeof job.onTick == "object") { 65 | const def = job.onTick; 66 | job.onTick = async () => { 67 | const startTime = Date.now(); 68 | this.logger.info(`Job '${job.name}' has been started.`); 69 | try { 70 | if (def.action) { 71 | await this.broker.call(def.action, def.params, def.opts); 72 | } 73 | if (def.event) { 74 | if (def.broadcast == true) 75 | await this.broker.broadcast(def.event, def.payload, def.opts); 76 | else await this.broker.emit(def.event, def.payload, def.opts); 77 | } 78 | this.logger.info( 79 | `Job '${job.name}' has been stopped. Time: ${ 80 | Date.now() - startTime 81 | } ms` 82 | ); 83 | } catch (err) { 84 | this.logger.error( 85 | `Job '${job.name}' execution failed. Time: ${ 86 | Date.now() - startTime 87 | } ms` 88 | ); 89 | this.logger.error(err); 90 | } 91 | }; 92 | } 93 | 94 | const instance_job = new cron.CronJob(job); 95 | 96 | instance_job.runOnStarted = runOnInit; 97 | instance_job.manualStart = job.manualStart || false; 98 | instance_job.name = job.name; 99 | 100 | return instance_job; 101 | }); 102 | } 103 | }, 104 | 105 | /** 106 | * Service started lifecycle event handler 107 | */ 108 | started() { 109 | this.$crons.map(job => { 110 | this.logger.debug(`Start '${job.name}' cron job.`); 111 | if (!job.manualStart) { 112 | job.start(); 113 | } 114 | if (job.runOnStarted) { 115 | job.runOnStarted(); 116 | } 117 | }); 118 | }, 119 | 120 | /** 121 | * Service stopped lifecycle event handler 122 | */ 123 | stopped() { 124 | this.$crons.map(job => { 125 | this.logger.debug(`Start '${job.name}' cron job.`); 126 | job.stop(); 127 | }); 128 | } 129 | }; 130 | -------------------------------------------------------------------------------- /backend/mixins/db.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const crypto = require("crypto"); 5 | const path = require("path"); 6 | const mkdir = require("mkdirp").sync; 7 | const DbService = require("@moleculer/database").Service; 8 | const HashIds = require("hashids/cjs"); 9 | const ObjectID = require("mongodb").ObjectID; 10 | 11 | const TESTING = process.env.NODE_ENV === "test"; 12 | 13 | module.exports = function (opts = {}) { 14 | if (!process.env.TOKEN_SALT && (TESTING || process.env.TEST_E2E)) { 15 | process.env.HASHID_SALT = crypto.randomBytes(32).toString("hex"); 16 | } 17 | 18 | const hashids = new HashIds(process.env.HASHID_SALT); 19 | 20 | if ((TESTING && !process.env.TEST_INT) || process.env.ONLY_GENERATE) { 21 | opts = _.defaultsDeep(opts, { 22 | adapter: "NeDB" 23 | }); 24 | } else { 25 | if (process.env.NEDB_FOLDER) { 26 | const dir = path.resolve(process.env.NEDB_FOLDER); 27 | mkdir(dir); 28 | opts = _.defaultsDeep(opts, { 29 | adapter: { 30 | type: "NeDB", 31 | options: { filename: path.join(dir, `${opts.collection}.db`) } 32 | } 33 | }); 34 | } else { 35 | opts = _.defaultsDeep(opts, { 36 | adapter: { 37 | type: "MongoDB", 38 | options: { 39 | uri: process.env.MONGO_URI || "mongodb://localhost/kantab", 40 | collection: opts.collection 41 | } 42 | } 43 | }); 44 | } 45 | } 46 | 47 | const schema = { 48 | mixins: [DbService(opts)], 49 | 50 | // No need hashids encoding for NeDB at unit testing 51 | methods: !TESTING 52 | ? { 53 | encodeID(id) { 54 | if (ObjectID.isValid(id)) id = id.toString(); 55 | return hashids.encodeHex(id); 56 | }, 57 | 58 | decodeID(id) { 59 | return hashids.decodeHex(id); 60 | } 61 | } 62 | : undefined, 63 | 64 | created() { 65 | if (!process.env.HASHID_SALT) { 66 | this.broker.fatal("Environment variable 'HASHID_SALT' must be configured!"); 67 | } 68 | }, 69 | 70 | async started() { 71 | /* istanbul ignore next */ 72 | if (!TESTING) { 73 | try { 74 | // Create indexes 75 | await this.createIndexes(); 76 | } catch (err) { 77 | this.logger.error("Unable to create indexes.", err); 78 | } 79 | } 80 | 81 | if (process.env.TEST_E2E || process.env.TEST_INT) { 82 | // Clean collection 83 | this.logger.info(`Clear '${opts.collection}' collection before tests...`); 84 | await this.clearEntities(); 85 | } 86 | 87 | // Seeding if the DB is empty 88 | const count = await this.countEntities(null, {}); 89 | if (count == 0 && _.isFunction(this.seedDB)) { 90 | this.logger.info(`Seed '${opts.collection}' collection...`); 91 | await this.seedDB(); 92 | } 93 | } 94 | }; 95 | 96 | return schema; 97 | }; 98 | -------------------------------------------------------------------------------- /backend/mixins/i18next.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const path = require("path"); 5 | 6 | const i18next = require("i18next"); 7 | const i18nextFs = require("i18next-node-fs-backend"); 8 | 9 | // Credits: Copied from https://github.com/i18next/i18next-express-middleware/blob/master/src/utils.js 10 | function setPath(object, path, newValue) { 11 | let stack; 12 | if (typeof path !== "string") stack = [].concat(path); 13 | if (typeof path === "string") stack = path.split("."); 14 | 15 | while (stack.length > 1) { 16 | let key = stack.shift(); 17 | if (key.indexOf("###") > -1) key = key.replace(/###/g, "."); 18 | if (!object[key]) object[key] = {}; 19 | object = object[key]; 20 | } 21 | 22 | let key = stack.shift(); 23 | if (key.indexOf("###") > -1) key = key.replace(/###/g, "."); 24 | object[key] = newValue; 25 | } 26 | 27 | module.exports = function (mixinOptions) { 28 | mixinOptions = _.defaultsDeep(mixinOptions, { 29 | folder: "./locales", 30 | routePath: "/locales" 31 | }); 32 | 33 | i18next 34 | .use(i18nextFs) 35 | .init({ 36 | //debug: true, 37 | fallbackLng: "en", 38 | whitelist: ["en", "hu"], 39 | ns: ["common", "errors"], 40 | defaultNS: "common", 41 | load: "all", 42 | saveMissing: true, //config.isDevMode(), 43 | saveMissingTo: "all", // "fallback", "current", "all" 44 | 45 | backend: { 46 | // path where resources get loaded from 47 | loadPath: path.join(mixinOptions.folder, "{{lng}}", "{{ns}}.json"), 48 | 49 | // path to post missing resources 50 | addPath: path.join(mixinOptions.folder, "{{lng}}", "{{ns}}.missing.json"), 51 | 52 | // jsonIndent to use when storing json files 53 | jsonIndent: 4 54 | } 55 | }) 56 | .catch(err => console.warn(err)); 57 | 58 | return { 59 | created() { 60 | const route = { 61 | path: mixinOptions.routePath, 62 | 63 | aliases: { 64 | // multiload backend route 65 | "GET /": (req, res) => { 66 | let resources = {}; 67 | 68 | let languages = req.query["lng"] ? req.query["lng"].split(" ") : []; 69 | let namespaces = req.query["ns"] ? req.query["ns"].split(" ") : []; 70 | 71 | // extend ns 72 | namespaces.forEach(ns => { 73 | if (i18next.options.ns && i18next.options.ns.indexOf(ns) < 0) 74 | i18next.options.ns.push(ns); 75 | }); 76 | 77 | i18next.services.backendConnector.load(languages, namespaces, function () { 78 | languages.forEach(lng => 79 | namespaces.forEach(ns => 80 | setPath( 81 | resources, 82 | [lng, ns], 83 | i18next.getResourceBundle(lng, ns) 84 | ) 85 | ) 86 | ); 87 | 88 | res.setHeader("Content-Type", "application/json; charset=utf-8"); 89 | res.end(JSON.stringify(resources)); 90 | }); 91 | }, 92 | 93 | // missing keys 94 | "POST /": (req, res) => { 95 | let lng = req.query["lng"]; 96 | let ns = req.query["ns"]; 97 | 98 | for (let m in req.body) { 99 | if (m != "_t") 100 | i18next.services.backendConnector.saveMissing( 101 | [lng], 102 | ns, 103 | m, 104 | req.body[m] 105 | ); 106 | } 107 | res.end("ok"); 108 | } 109 | }, 110 | 111 | mappingPolicy: "restrict", 112 | 113 | bodyParsers: { 114 | urlencoded: { extended: true } 115 | } 116 | }; 117 | 118 | // Add route. 119 | this.settings.routes.unshift(route); 120 | } 121 | }; 122 | }; 123 | -------------------------------------------------------------------------------- /backend/mixins/member-check.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | module.exports = { 6 | /** 7 | * Methods 8 | */ 9 | methods: { 10 | /** 11 | * Try to resolve the board entity in order to check the permissions. 12 | * 13 | * @param {Context} ctx 14 | * @returns 15 | */ 16 | async _getBoardEntity(ctx) { 17 | if (this.name == "boards") { 18 | if (ctx.locals.entity && ctx.locals.entity.owner && ctx.locals.entity.members) { 19 | return ctx.locals.entity; 20 | } else { 21 | return ctx.call("v1.boards.resolve", { id: ctx.locals.entity.id }); 22 | } 23 | } else { 24 | if (ctx.locals.entity) { 25 | if (_.isObject(ctx.locals.entity.board)) { 26 | return ctx.call("v1.boards.resolve", { id: ctx.locals.entity.board.id }); 27 | } else if (_.isString(ctx.locals.entity.board)) { 28 | return ctx.call("v1.boards.resolve", { id: ctx.locals.entity.board }); 29 | } else if (_.isObject(ctx.locals.entity.list)) { 30 | const list = await ctx.call("v1.lists.resolve", { 31 | id: ctx.locals.entity.list.id 32 | }); 33 | return ctx.call("v1.boards.resolve", { id: list.board }); 34 | } else if (_.isString(ctx.locals.entity.list)) { 35 | const list = await ctx.call("v1.lists.resolve", { 36 | id: ctx.locals.entity.list 37 | }); 38 | return ctx.call("v1.boards.resolve", { id: list.board }); 39 | } 40 | } else if (_.isString(ctx.params.board)) { 41 | return ctx.call("v1.boards.resolve", { id: ctx.params.board }); 42 | } else if (_.isString(ctx.params.list)) { 43 | const list = await ctx.call("v1.lists.resolve", { id: ctx.params.list }); 44 | return ctx.call("v1.boards.resolve", { id: list.board }); 45 | } 46 | } 47 | }, 48 | 49 | /** 50 | * Internal method to check the owner of entity. (called from CheckPermission middleware) 51 | * 52 | * @param {Context} ctx 53 | * @returns {Promise} 54 | */ 55 | async isBoardOwner(ctx) { 56 | if (ctx.meta.$repl) return true; 57 | if (!ctx.meta.userID) return false; 58 | 59 | const board = await this._getBoardEntity(ctx); 60 | return board != null && board.owner == ctx.meta.userID; 61 | }, 62 | 63 | /** 64 | * Internal method to check the membership of board. 65 | * 66 | * @param {Context} ctx 67 | * @returns {Promise} 68 | */ 69 | async isBoardMember(ctx) { 70 | if (ctx.meta.$repl) return true; 71 | if (!ctx.meta.userID) return false; 72 | 73 | const board = await this._getBoardEntity(ctx); 74 | return board != null && board.members.includes(ctx.meta.userID); 75 | } 76 | } 77 | }; 78 | -------------------------------------------------------------------------------- /backend/mixins/memoize.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = function (opts = {}) { 4 | return { 5 | methods: { 6 | async memoize(name, params, fn) { 7 | if (!this.broker.cacher) return fn(); 8 | 9 | const key = this.broker.cacher.defaultKeygen( 10 | `${this.name}:memoize-${name}`, 11 | params, 12 | {} 13 | ); 14 | 15 | let res = await this.broker.cacher.get(key); 16 | if (res) return res; 17 | 18 | res = await fn(); 19 | this.broker.cacher.set(key, res, opts.ttl); 20 | 21 | return res; 22 | } 23 | } 24 | }; 25 | }; 26 | -------------------------------------------------------------------------------- /backend/mixins/next-position.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | module.exports = { 4 | methods: { 5 | getNextPosition(ctx) { 6 | return this.findEntities(ctx).then( 7 | rows => rows.reduce((a, row) => Math.max(a, row.position), 0) + 1 8 | ); 9 | } 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /backend/mixins/passport.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const fs = require("fs"); 5 | const path = require("path"); 6 | const passport = require("passport"); 7 | const cookie = require("cookie"); 8 | 9 | module.exports = function (mixinOptions) { 10 | if (!mixinOptions || !mixinOptions.providers) 11 | throw new Error("Missing 'providers' property in service mixin options"); 12 | 13 | const strategyMixins = []; 14 | 15 | const Providers = []; 16 | 17 | _.forIn(mixinOptions.providers, (setting, name) => { 18 | if (!setting) return; 19 | 20 | const filename = path.resolve(__dirname, "strategies", `${name}.strategy.mixin.js`); 21 | if (fs.existsSync(filename)) { 22 | const strategy = require(filename); 23 | strategyMixins.push(strategy); 24 | Providers.push({ name, setting: _.isObject(setting) ? setting : {} }); 25 | } 26 | }); 27 | 28 | return { 29 | mixins: strategyMixins, 30 | 31 | actions: { 32 | /** 33 | * Return the supported Social Auth providers 34 | */ 35 | supportedSocialAuthProviders: { 36 | graphql: { 37 | query: "supportedSocialAuthProviders: [String]" 38 | }, 39 | handler() { 40 | return Providers.map(o => o.name); 41 | } 42 | } 43 | }, 44 | 45 | methods: { 46 | async signInSocialUser(params, cb) { 47 | const msg = `Missing 'signInSocialUser' method implementation in the '${this.name}' service.`; 48 | this.logger.warn(msg); 49 | cb(new Error(msg)); 50 | }, 51 | 52 | socialAuthCallback(setting, providerName) { 53 | return (req, res) => err => { 54 | if (err) { 55 | this.logger.warn("Authentication error.", err); 56 | this.sendError(req, res, err); 57 | return; 58 | } 59 | 60 | if (mixinOptions.cookieName !== false) { 61 | res.setHeader( 62 | "Set-Cookie", 63 | cookie.serialize( 64 | mixinOptions.cookieName || "jwt-token", 65 | req.user.token, 66 | Object.assign( 67 | { 68 | //httpOnly: true, 69 | path: "/", 70 | maxAge: 60 * 60 * 24 * 90 // 90 days 71 | }, 72 | mixinOptions.cookieOptions || {} 73 | ) 74 | ) 75 | ); 76 | } 77 | 78 | this.logger.info(`Successful authentication with '${providerName}'.`); 79 | this.logger.info("User", req.user); 80 | this.sendRedirect(res, mixinOptions.successRedirect || "/", 302); 81 | }; 82 | } 83 | }, 84 | 85 | created() { 86 | const route = { 87 | path: mixinOptions.routePath || "/auth", 88 | 89 | use: [passport.initialize()], 90 | 91 | aliases: {}, 92 | 93 | mappingPolicy: "restrict", 94 | 95 | bodyParsers: { 96 | json: true, 97 | urlencoded: { extended: true } 98 | } 99 | }; 100 | 101 | if (mixinOptions.localAuthAlias) 102 | route.aliases["POST /local"] = mixinOptions.localAuthAlias; 103 | 104 | Providers.forEach(provider => { 105 | const fnName = `register${_.capitalize(provider.name)}Strategy`; 106 | 107 | if (_.isFunction(this[fnName])) { 108 | this[fnName](provider.setting, route); 109 | } else { 110 | throw new Error(`Missing Passport strategy mixin for '${provider.name}'`); 111 | } 112 | }); 113 | 114 | route.aliases[ 115 | "GET /supported-social-auth-providers" 116 | ] = `${this.fullName}.supportedSocialAuthProviders`; 117 | 118 | // Add `/auth` route. 119 | this.settings.routes.unshift(route); 120 | } 121 | }; 122 | }; 123 | -------------------------------------------------------------------------------- /backend/mixins/strategies/facebook.strategy.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const passport = require("passport"); 4 | 5 | const providerName = "facebook"; 6 | 7 | /** 8 | * Handle keys: https://developers.facebook.com/apps/ 9 | */ 10 | module.exports = { 11 | methods: { 12 | registerFacebookStrategy(setting, route) { 13 | let Strategy; 14 | try { 15 | Strategy = require("passport-facebook").Strategy; 16 | } catch (error) { 17 | this.logger.error( 18 | "The 'passport-facebook' package is missing. Please install it with 'npm i passport-facebook' command." 19 | ); 20 | return; 21 | } 22 | 23 | setting = Object.assign( 24 | {}, 25 | { 26 | scope: ["email", "user_location"] 27 | }, 28 | setting 29 | ); 30 | 31 | passport.use( 32 | providerName, 33 | new Strategy( 34 | Object.assign( 35 | { 36 | clientID: process.env.FACEBOOK_CLIENT_ID, 37 | clientSecret: process.env.FACEBOOK_CLIENT_SECRET, 38 | callbackURL: `/auth/${providerName}/callback`, 39 | profileFields: [ 40 | "first_name", 41 | "last_name", 42 | "email", 43 | "link", 44 | "locale", 45 | "timezone" 46 | ] 47 | }, 48 | setting 49 | ), 50 | (accessToken, refreshToken, profile, done) => { 51 | this.logger.info(`Received '${providerName}' social profile: `, profile); 52 | 53 | this.signInSocialUser( 54 | { 55 | provider: providerName, 56 | accessToken, 57 | refreshToken, 58 | profile: this.processFacebookProfile(profile) 59 | }, 60 | done 61 | ); 62 | } 63 | ) 64 | ); 65 | 66 | // Create route aliases 67 | const callback = this.socialAuthCallback(setting, providerName); 68 | 69 | route.aliases[`GET /${providerName}`] = (req, res) => 70 | passport.authenticate(providerName, { scope: setting.scope })( 71 | req, 72 | res, 73 | callback(req, res) 74 | ); 75 | route.aliases[`GET /${providerName}/callback`] = (req, res) => 76 | passport.authenticate(providerName, { session: false })( 77 | req, 78 | res, 79 | callback(req, res) 80 | ); 81 | }, 82 | 83 | processFacebookProfile(profile) { 84 | const res = { 85 | provider: profile.provider, 86 | socialID: profile.id, 87 | fullName: profile.name.givenName + " " + profile.name.familyName, 88 | email: profile._json.email, 89 | avatar: `https://graph.facebook.com/${profile.id}/picture?type=large` 90 | }; 91 | 92 | return res; 93 | } 94 | } 95 | }; 96 | -------------------------------------------------------------------------------- /backend/mixins/strategies/github.strategy.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const passport = require("passport"); 4 | 5 | const providerName = "github"; 6 | 7 | /** 8 | * Handle keys: https://github.com/settings/applications/new 9 | */ 10 | module.exports = { 11 | methods: { 12 | registerGithubStrategy(setting, route) { 13 | let Strategy; 14 | try { 15 | Strategy = require("passport-github2").Strategy; 16 | } catch (error) { 17 | this.logger.error( 18 | "The 'passport-github2' package is missing. Please install it with 'npm i passport-github2' command." 19 | ); 20 | return; 21 | } 22 | 23 | setting = Object.assign( 24 | {}, 25 | { 26 | scope: "user:email" 27 | }, 28 | setting 29 | ); 30 | 31 | passport.use( 32 | providerName, 33 | new Strategy( 34 | Object.assign( 35 | { 36 | clientID: process.env.GITHUB_CLIENT_ID, 37 | clientSecret: process.env.GITHUB_CLIENT_SECRET, 38 | callbackURL: `/auth/${providerName}/callback` 39 | }, 40 | setting 41 | ), 42 | (accessToken, refreshToken, profile, done) => { 43 | this.logger.info(`Received '${providerName}' social profile: `, profile); 44 | 45 | this.signInSocialUser( 46 | { 47 | provider: providerName, 48 | accessToken, 49 | refreshToken, 50 | profile: this.processGithubProfile(profile) 51 | }, 52 | done 53 | ); 54 | } 55 | ) 56 | ); 57 | 58 | // Create route aliases 59 | const callback = this.socialAuthCallback(setting, providerName); 60 | 61 | route.aliases[`GET /${providerName}`] = (req, res) => 62 | passport.authenticate(providerName, { scope: setting.scope })( 63 | req, 64 | res, 65 | callback(req, res) 66 | ); 67 | route.aliases[`GET /${providerName}/callback`] = (req, res) => 68 | passport.authenticate(providerName, { session: false })( 69 | req, 70 | res, 71 | callback(req, res) 72 | ); 73 | }, 74 | 75 | processGithubProfile(profile) { 76 | const res = { 77 | provider: profile.provider, 78 | socialID: profile.id, 79 | username: profile.username, 80 | fullName: profile.displayName || profile.username, 81 | avatar: profile._json.avatar_url 82 | }; 83 | 84 | if (profile.emails && profile.emails.length > 0) { 85 | let email = profile.emails.find(email => email.primary); 86 | if (!email) email = profile.emails[0]; 87 | 88 | res.email = email.value; 89 | } 90 | 91 | return res; 92 | } 93 | } 94 | }; 95 | -------------------------------------------------------------------------------- /backend/mixins/strategies/google.strategy.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const passport = require("passport"); 4 | 5 | const providerName = "google"; 6 | 7 | /** 8 | * Handle keys: https://console.developers.google.com/project/express-mongo-boilerplate/apiui/consent 9 | */ 10 | module.exports = { 11 | methods: { 12 | registerGoogleStrategy(setting, route) { 13 | let Strategy; 14 | try { 15 | Strategy = require("passport-google-oauth20").Strategy; 16 | } catch (error) { 17 | this.logger.error( 18 | "The 'passport-google-oauth20' package is missing. Please install it with 'npm i passport-google-oauth20' command." 19 | ); 20 | return; 21 | } 22 | 23 | setting = Object.assign( 24 | {}, 25 | { 26 | scope: "profile email" 27 | }, 28 | setting 29 | ); 30 | 31 | passport.use( 32 | providerName, 33 | new Strategy( 34 | Object.assign( 35 | { 36 | clientID: process.env.GOOGLE_CLIENT_ID, 37 | clientSecret: process.env.GOOGLE_CLIENT_SECRET, 38 | callbackURL: `/auth/${providerName}/callback` 39 | }, 40 | setting 41 | ), 42 | (accessToken, refreshToken, profile, done) => { 43 | this.logger.info(`Received '${providerName}' social profile: `, profile); 44 | 45 | this.signInSocialUser( 46 | { 47 | provider: providerName, 48 | accessToken, 49 | refreshToken, 50 | profile: this.processGoogleProfile(profile) 51 | }, 52 | done 53 | ); 54 | } 55 | ) 56 | ); 57 | 58 | // Create route aliases 59 | const callback = this.socialAuthCallback(setting, providerName); 60 | 61 | route.aliases[`GET /${providerName}`] = (req, res) => 62 | passport.authenticate(providerName, { scope: setting.scope })( 63 | req, 64 | res, 65 | callback(req, res) 66 | ); 67 | route.aliases[`GET /${providerName}/callback`] = (req, res) => 68 | passport.authenticate(providerName, { session: false })( 69 | req, 70 | res, 71 | callback(req, res) 72 | ); 73 | }, 74 | 75 | processGoogleProfile(profile) { 76 | const res = { 77 | provider: profile.provider, 78 | socialID: profile.id, 79 | fullName: profile.name.givenName + " " + profile.name.familyName 80 | }; 81 | if (profile.emails && profile.emails.length > 0) res.email = profile.emails[0].value; 82 | 83 | if (profile.photos && profile.photos.length > 0) 84 | res.avatar = profile.photos[0].value.replace("sz=50", "sz=200"); 85 | 86 | return res; 87 | } 88 | } 89 | }; 90 | -------------------------------------------------------------------------------- /backend/mixins/strategies/twitter.strategy.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const passport = require("passport"); 4 | 5 | const providerName = "twitter"; 6 | 7 | /** 8 | * Old OAuth v1 authentication doesn't support sessionless authentication. 9 | * 10 | * Handle keys: https://apps.twitter.com/app/new 11 | */ 12 | module.exports = { 13 | methods: { 14 | registerTwitterStrategy(setting, route) { 15 | let Strategy; 16 | try { 17 | Strategy = require("passport-twitter").Strategy; 18 | } catch (error) { 19 | this.logger.error( 20 | "The 'passport-twitter' package is missing. Please install it with 'npm i passport-twitter' command." 21 | ); 22 | return; 23 | } 24 | 25 | passport.use( 26 | providerName, 27 | new Strategy( 28 | Object.assign( 29 | { 30 | consumerKey: process.env.TWITTER_CLIENT_ID, 31 | consumerSecret: process.env.TWITTER_CLIENT_SECRET, 32 | callbackURL: `/auth/${providerName}/callback`, 33 | includeEmail: true 34 | }, 35 | setting 36 | ), 37 | (accessToken, refreshToken, profile, done) => { 38 | this.logger.info(`Received '${providerName}' social profile: `, profile); 39 | 40 | this.signInSocialUser( 41 | { 42 | provider: providerName, 43 | accessToken, 44 | refreshToken, 45 | profile: this.processTwitterProfile(profile) 46 | }, 47 | done 48 | ); 49 | } 50 | ) 51 | ); 52 | 53 | // Create route aliases 54 | const callback = this.socialAuthCallback(setting, providerName); 55 | 56 | route.aliases[`GET /${providerName}`] = (req, res) => 57 | passport.authenticate(providerName, {})(req, res, callback(req, res)); 58 | route.aliases[`GET /${providerName}/callback`] = (req, res) => 59 | passport.authenticate(providerName)(req, res, callback(req, res)); 60 | }, 61 | 62 | processTwitterProfile(profile) { 63 | const res = { 64 | provider: profile.provider, 65 | socialID: profile.id, 66 | username: profile.username, 67 | fullName: profile.displayName, 68 | email: `${profile.username}@twitter.com`, 69 | avatar: profile._json.profile_image_url_https 70 | }; 71 | 72 | return res; 73 | } 74 | } 75 | }; 76 | -------------------------------------------------------------------------------- /backend/mixins/token-generator.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const UIDGenerator = require("uid-generator"); 4 | const uidgen = new UIDGenerator(256); 5 | 6 | module.exports = { 7 | methods: { 8 | generateToken() { 9 | return uidgen.generateSync(); 10 | } 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /backend/services/activities.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | const DbService = require("../mixins/db.mixin"); 6 | //const ConfigLoader = require("../mixins/config.mixin"); 7 | //const { MoleculerRetryableError, MoleculerClientError } = require("moleculer").Errors; 8 | 9 | /** 10 | * Activities service (board, card, ...etc) 11 | */ 12 | module.exports = { 13 | name: "activities", 14 | version: 1, 15 | 16 | mixins: [ 17 | DbService({}) 18 | //CacheCleaner(["cache.clean.cards", "cache.clean.activities", "cache.clean.accounts"]), 19 | //ConfigLoader([]) 20 | ], 21 | 22 | /** 23 | * Service dependencies 24 | */ 25 | dependencies: [], 26 | 27 | /** 28 | * Service settings 29 | */ 30 | settings: { 31 | /*fields: [ 32 | "_id", 33 | "board", 34 | "list", 35 | "card", 36 | 37 | "type", // Similar to https://developers.trello.com/reference#action-types 38 | "params", 39 | "text", 40 | 41 | "isSystem", 42 | "createdAt", 43 | "createdBy", 44 | ]*/ 45 | }, 46 | 47 | /** 48 | * Actions 49 | */ 50 | actions: {}, 51 | 52 | /** 53 | * Events 54 | */ 55 | events: {}, 56 | 57 | /** 58 | * Methods 59 | */ 60 | methods: {}, 61 | 62 | /** 63 | * Service created lifecycle event handler 64 | */ 65 | created() {}, 66 | 67 | /** 68 | * Service started lifecycle event handler 69 | */ 70 | started() {}, 71 | 72 | /** 73 | * Service stopped lifecycle event handler 74 | */ 75 | stopped() {} 76 | }; 77 | -------------------------------------------------------------------------------- /backend/services/card.attachments.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | const DbService = require("../mixins/db.mixin"); 6 | //const ConfigLoader = require("../mixins/config.mixin"); 7 | //const { MoleculerRetryableError, MoleculerClientError } = require("moleculer").Errors; 8 | 9 | /** 10 | * Card attachment service 11 | */ 12 | module.exports = { 13 | name: "card.attachments", 14 | version: 1, 15 | 16 | mixins: [ 17 | DbService({ 18 | cache: { 19 | additionalKeys: ["#userID"] 20 | } 21 | }) 22 | //CacheCleaner(["cache.clean.cards", "cache.clean.card.attachments", "cache.clean.accounts"]), 23 | //ConfigLoader([]) 24 | ], 25 | 26 | /** 27 | * Service dependencies 28 | */ 29 | dependencies: [], 30 | 31 | /** 32 | * Service settings 33 | */ 34 | settings: { 35 | /*fields: [ 36 | "_id", 37 | "board", 38 | "card", 39 | "createdBy", 40 | 41 | "type", 42 | "title", 43 | "description", 44 | "url", 45 | "size", 46 | 47 | "options", 48 | "createdAt", 49 | "updatedAt" 50 | ]*/ 51 | }, 52 | 53 | /** 54 | * Actions 55 | */ 56 | actions: {}, 57 | 58 | /** 59 | * Events 60 | */ 61 | events: {}, 62 | 63 | /** 64 | * Methods 65 | */ 66 | methods: {}, 67 | 68 | /** 69 | * Service created lifecycle event handler 70 | */ 71 | created() {}, 72 | 73 | /** 74 | * Service started lifecycle event handler 75 | */ 76 | started() {}, 77 | 78 | /** 79 | * Service stopped lifecycle event handler 80 | */ 81 | stopped() {} 82 | }; 83 | -------------------------------------------------------------------------------- /backend/services/card.checklists.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | const DbService = require("../mixins/db.mixin"); 6 | //const ConfigLoader = require("../mixins/config.mixin"); 7 | //const { MoleculerRetryableError, MoleculerClientError } = require("moleculer").Errors; 8 | 9 | /** 10 | * Card checklist service 11 | */ 12 | module.exports = { 13 | name: "card.checklists", 14 | version: 1, 15 | 16 | mixins: [ 17 | DbService({ 18 | cache: { 19 | additionalKeys: ["#userID"] 20 | } 21 | }) 22 | //CacheCleaner(["cache.clean.cards", "cache.clean.card.checklists", "cache.clean.accounts"]), 23 | //ConfigLoader([]) 24 | ], 25 | 26 | /** 27 | * Service dependencies 28 | */ 29 | dependencies: [], 30 | 31 | /** 32 | * Service settings 33 | */ 34 | settings: { 35 | /*fields: [ 36 | "_id", 37 | "board", 38 | "card", 39 | "createdBy", 40 | 41 | "title", 42 | "description", 43 | "position", 44 | 45 | "options", 46 | "createdAt", 47 | "updatedAt" 48 | ]*/ 49 | }, 50 | 51 | /** 52 | * Actions 53 | */ 54 | actions: {}, 55 | 56 | /** 57 | * Events 58 | */ 59 | events: {}, 60 | 61 | /** 62 | * Methods 63 | */ 64 | methods: {}, 65 | 66 | /** 67 | * Service created lifecycle event handler 68 | */ 69 | created() {}, 70 | 71 | /** 72 | * Service started lifecycle event handler 73 | */ 74 | started() {}, 75 | 76 | /** 77 | * Service stopped lifecycle event handler 78 | */ 79 | stopped() {} 80 | }; 81 | -------------------------------------------------------------------------------- /backend/services/laboratory.service.js: -------------------------------------------------------------------------------- 1 | const Laboratory = require("@moleculer/lab"); 2 | 3 | const port = Number(process.env.LABORATORY_PORT || 3212); 4 | 5 | module.exports = { 6 | name: "laboratory", 7 | mixins: 8 | process.env.TEST_E2E || process.env.TEST_INT || process.env.NODE_ENV == "test" 9 | ? undefined 10 | : [Laboratory.AgentService], 11 | 12 | metadata: { 13 | dockerCompose: { 14 | template: { 15 | expose: [port], 16 | ports: [`${port}:${port}`] 17 | } 18 | } 19 | }, 20 | 21 | settings: { 22 | name: "KanTab", 23 | port: port, 24 | token: process.env.LABORATORY_TOKEN, 25 | apiKey: process.env.LABORATORY_APIKEY 26 | } 27 | }; 28 | -------------------------------------------------------------------------------- /backend/services/mail.service.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const MailService = require("moleculer-mail"); 4 | const ConfigLoader = require("../mixins/config.mixin"); 5 | 6 | const MAILDEV_TRANSPORT = { 7 | host: "localhost", 8 | port: 1025, 9 | ignoreTLS: true 10 | }; 11 | 12 | const MAILTRAP_TRANSPORT = { 13 | host: "smtp.mailtrap.io", 14 | port: 2525, 15 | auth: { 16 | user: process.env.MAILTRAP_USER, 17 | pass: process.env.MAILTRAP_PASS 18 | } 19 | }; 20 | 21 | module.exports = { 22 | name: "mail", 23 | version: 1, 24 | 25 | mixins: [MailService, ConfigLoader(["site.**", "mail.**"])], 26 | 27 | /** 28 | * Service dependencies 29 | */ 30 | dependencies: [{ name: "config", version: 1 }], 31 | 32 | /** 33 | * Service settings 34 | */ 35 | settings: { 36 | from: "no-reply@kantab.moleculer.services", 37 | transport: 38 | process.env.TEST_E2E || process.env.TEST_INT ? MAILDEV_TRANSPORT : MAILTRAP_TRANSPORT, 39 | templateFolder: "./backend/templates/mail" 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /backend/templates/mail/activate/html.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | body 4 | h1 Hi #{user.fullName}! 5 | a(href=site.url + "/verify-account?token=" + token) Click here to verify your registration. 6 | -------------------------------------------------------------------------------- /backend/templates/mail/activate/subject.hbs: -------------------------------------------------------------------------------- 1 | ✔ Activate your new {{siteName}} account! -------------------------------------------------------------------------------- /backend/templates/mail/magic-link/html.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | body 4 | h1 Hi #{user.fullName}! 5 | a(href=site.url + "/passwordless?token=" + token) Click here to login to your account. 6 | -------------------------------------------------------------------------------- /backend/templates/mail/magic-link/subject.hbs: -------------------------------------------------------------------------------- 1 | ✔ Login to your account on {{site.name}} -------------------------------------------------------------------------------- /backend/templates/mail/password-changed/html.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | body 4 | p(style='font-weight: bold;') Hi #{user.fullName}, 5 | p This is a quick note to let you know that your password has been changed. You may now login to 6 | a(href=site.url) #{site.url} 7 | | with your new credentials. 8 | br 9 | p If you have any questions or encounter any problems logging in, please contact us. 10 | p If you did not change your password, please reply to this email immediately and let us know. 11 | p Thanks, 12 | p The #{site.name} Team 13 | -------------------------------------------------------------------------------- /backend/templates/mail/password-changed/subject.hbs: -------------------------------------------------------------------------------- 1 | ✔ Your password has been changed on {{siteName}} -------------------------------------------------------------------------------- /backend/templates/mail/reset-password/html.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | body 4 | p(style='font-weight: bold;') Hi #{user.fullName}, 5 | p You recently requested to reset your password for your account. Click the button below to reset it. 6 | p(style='text-align: center;') 7 | a(style='background: #8CAD26; border-radius: 4px; color: #fff; display: inline-block; font-size: 16px; padding: 10px 15px; text-align: center; text-decoration: none;' href=site.url + "/reset-password?token=" + token) Reset your password 8 | p If you did not request a password reset, please ignore this email or reply to let us know. This password reset is only valid for the next 30 minutes. 9 | p Thanks, 10 | p The #{site.name} Team 11 | hr 12 | 13 | p If you're having trouble clicking the password reset button, copy and paste the URL below into your web browser. 14 | p 15 | a(href=site.url + "/reset-password?token=" + token) #{site.url + "/reset-password?token=" + token} 16 | -------------------------------------------------------------------------------- /backend/templates/mail/reset-password/subject.hbs: -------------------------------------------------------------------------------- 1 | ✔ Reset your password on {{siteName}} -------------------------------------------------------------------------------- /backend/templates/mail/welcome/html.pug: -------------------------------------------------------------------------------- 1 | doctype html 2 | html(lang="en") 3 | body 4 | h1 Welcome, #{user.fullName}! 5 | -------------------------------------------------------------------------------- /backend/templates/mail/welcome/subject.hbs: -------------------------------------------------------------------------------- 1 | ✔ Welcome to {{siteName}}! -------------------------------------------------------------------------------- /backend/tests/integration/checks.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | async function checkResponse(promise, expected) { 6 | const res = await promise; 7 | expect(res).toEqual(expected); 8 | 9 | return res; 10 | } 11 | 12 | async function checkError(promise, errorName) { 13 | try { 14 | await promise; 15 | } catch (err) { 16 | if (typeof errorName === "string") { 17 | //if (err.name != errorName) console.log(err); 18 | expect(err.name).toBe(errorName); 19 | } else if (_.isObject(errorName)) { 20 | Object.keys(errorName).forEach(key => { 21 | expect(err[key]).toBe(errorName[key]); 22 | }); 23 | } 24 | } 25 | } 26 | 27 | async function checkBoardVisibility(fn, params, responses) { 28 | for (const user of Object.keys(responses)) { 29 | const res = responses[user]; 30 | if (res.data) { 31 | await checkResponse(fn(user, params), res.data); 32 | } else if (res.error) { 33 | await checkError(fn(user, params), res.error); 34 | } 35 | } 36 | } 37 | 38 | function listResponse(rows) { 39 | return { 40 | page: 1, 41 | pageSize: 10, 42 | rows, 43 | total: rows.length, 44 | totalPages: 1 45 | }; 46 | } 47 | 48 | module.exports = { 49 | checkResponse, 50 | checkError, 51 | checkBoardVisibility, 52 | listResponse 53 | }; 54 | -------------------------------------------------------------------------------- /backend/tests/integration/env.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const Runner = require("moleculer").Runner; 3 | const kleur = require("kleur"); 4 | 5 | module.exports = function init() { 6 | const state = { 7 | broker: null, 8 | users: {}, 9 | contexts: {}, 10 | boards: {}, 11 | lists: {}, 12 | cards: {} 13 | }; 14 | 15 | return { 16 | async setupEnv() { 17 | console.log(kleur.magenta().bold("Booting Moleculer project for integration tests...")); 18 | try { 19 | const runner = new Runner(); 20 | const broker = await runner.start([ 21 | process.argv[0], 22 | __filename, 23 | path.join(__dirname, "..", "..", "services", "**", "*.service.js") 24 | ]); 25 | 26 | console.log(kleur.magenta().bold("Broker started. NodeID:"), broker.nodeID); 27 | 28 | // Disable verification 29 | await broker.call("v1.config.set", { 30 | key: "accounts.verification.enabled", 31 | value: false 32 | }); 33 | // Disable mail sending 34 | await broker.call("v1.config.set", { 35 | key: "mail.enabled", 36 | value: false 37 | }); 38 | 39 | state.broker = broker; 40 | 41 | return state; 42 | } catch (err) { 43 | console.error(err); 44 | process.exit(1); 45 | } 46 | }, 47 | 48 | async tearDownEnv() { 49 | if (state.broker) await state.broker.stop(); 50 | } 51 | }; 52 | }; 53 | -------------------------------------------------------------------------------- /backend/tests/integration/helper-actions.js: -------------------------------------------------------------------------------- 1 | module.exports = function ({ broker, contexts }) { 2 | return { 3 | registerAccount(data) { 4 | return broker.call("v1.accounts.register", data); 5 | }, 6 | 7 | login(email, password) { 8 | return broker.call("v1.accounts.login", { email, password }); 9 | }, 10 | 11 | boardCreate(user, data) { 12 | return broker.call("v1.boards.create", data, contexts[user]); 13 | }, 14 | 15 | boards(user, params = {}) { 16 | return broker.call("v1.boards.list", params, contexts[user]); 17 | }, 18 | 19 | boardsAll(user, params = {}) { 20 | return broker.call("v1.boards.find", params, contexts[user]); 21 | }, 22 | 23 | boardsCount(user, params = {}) { 24 | return broker.call("v1.boards.count", params, contexts[user]); 25 | }, 26 | 27 | boardByID(user, params = {}) { 28 | return broker.call("v1.boards.get", params, contexts[user]); 29 | }, 30 | 31 | boardResolve(user, params = {}) { 32 | return broker.call("v1.boards.resolve", params, contexts[user]); 33 | }, 34 | 35 | boardUpdate(user, params = {}) { 36 | return broker.call("v1.boards.update", params, contexts[user]); 37 | }, 38 | 39 | boardAddMembers(user, params) { 40 | return broker.call("v1.boards.addMembers", params, contexts[user]); 41 | }, 42 | 43 | boardRemoveMembers(user, params) { 44 | return broker.call("v1.boards.removeMembers", params, contexts[user]); 45 | }, 46 | 47 | boardTransferOwnership(user, params) { 48 | return broker.call("v1.boards.transferOwnership", params, contexts[user]); 49 | }, 50 | 51 | boardArchive(user, id) { 52 | return broker.call("v1.boards.archive", { id }, contexts[user]); 53 | }, 54 | 55 | boardUnarchive(user, id) { 56 | return broker.call("v1.boards.unarchive", { id }, contexts[user]); 57 | }, 58 | 59 | boardRemove(user, params = {}) { 60 | return broker.call("v1.boards.remove", params, contexts[user]); 61 | } 62 | }; 63 | }; 64 | -------------------------------------------------------------------------------- /cypress.json: -------------------------------------------------------------------------------- 1 | { 2 | "pluginsFile": "tests/e2e/plugins/index.js", 3 | "baseUrl": "http://localhost:4000", 4 | 5 | "defaultCommandTimeout": 10000 6 | } 7 | -------------------------------------------------------------------------------- /docker-compose.dev.yml: -------------------------------------------------------------------------------- 1 | version: '3.0' 2 | services: 3 | nats: 4 | image: nats:2 5 | ports: 6 | - '4222:4222' 7 | restart: unless-stopped 8 | command: 9 | - '-m' 10 | - '8222' 11 | redis: 12 | image: redis:6-alpine 13 | ports: 14 | - '6379:6379' 15 | restart: unless-stopped 16 | mongo: 17 | image: mongo:4 18 | ports: 19 | - "27017:27017" 20 | volumes: 21 | - mongo_data:/data/db 22 | restart: unless-stopped 23 | volumes: 24 | mongo_data: 25 | driver: local 26 | -------------------------------------------------------------------------------- /docker-compose.env: -------------------------------------------------------------------------------- 1 | MOL_NAMESPACE=kantab-prod 2 | MOL_TRANSPORTER=nats://nats:4222 3 | MOL_LOGLEVEL=info 4 | 5 | SERVICEDIR=backend/services 6 | MONGO_URI=mongodb://mongo:27017/kantab 7 | -------------------------------------------------------------------------------- /frontend/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | env: { 4 | node: true 5 | }, 6 | extends: ["eslint:recommended", "plugin:vue/vue3-recommended", "plugin:prettier/recommended"], 7 | /*parserOptions: { 8 | parser: "babel-eslint" 9 | },*/ 10 | rules: { 11 | "no-console": process.env.NODE_ENV === "production" ? "warn" : "off", 12 | "no-debugger": process.env.NODE_ENV === "production" ? "warn" : "off", 13 | "no-unused-vars": "warn", 14 | "vue/no-unused-components": "warn", 15 | "no-undef": "warn" 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/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | dist-ssr 5 | *.local 6 | 7 | !public/ 8 | -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | KanTab 14 | 15 | 16 |
17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend-vite", 3 | "version": "0.0.0", 4 | "scripts": { 5 | "dev": "vite --host", 6 | "build": "vite build", 7 | "deps": "npm-check -u" 8 | }, 9 | "dependencies": { 10 | "dayjs": "^1.10.7", 11 | "graphql-request": "^3.7.0", 12 | "pinia": "^2.0.11", 13 | "vue": "^3.2.29" 14 | }, 15 | "devDependencies": { 16 | "@apollo/client": "^3.5.8", 17 | "@vitejs/plugin-vue": "^2.1.0", 18 | "@vue/apollo-option": "^4.0.0-alpha.16", 19 | "@vue/compat": "^3.2.29", 20 | "apollo-cache-inmemory": "^1.6.6", 21 | "apollo-link-context": "^1.0.20", 22 | "autoprefixer": "^10.4.2", 23 | "axios": "^0.25.0", 24 | "core-js": "^3.21.0", 25 | "eslint": "^8.8.0", 26 | "eslint-plugin-prettier": "^4.0.0", 27 | "eslint-plugin-vue": "^8.4.0", 28 | "graphql": "^16.3.0", 29 | "graphql-tag": "^2.12.6", 30 | "i18next-browser-languagedetector": "^6.1.3", 31 | "izitoast": "^1.4.0", 32 | "js-cookie": "^3.0.1", 33 | "mitt": "^3.0.0", 34 | "npm-check": "^5.9.2", 35 | "postcss": "^8.4.6", 36 | "postcss-import": "^14.0.2", 37 | "prettier": "^2.5.0", 38 | "pug": "^3.0.2", 39 | "register-service-worker": "^1.7.1", 40 | "sass": "^1.49.7", 41 | "sweetalert": "^2.1.2", 42 | "tailwindcss": "^3.0.18", 43 | "vite": "^2.7.13", 44 | "vue-color-kit": "^1.0.5", 45 | "vue-router": "^4.0.12", 46 | "vue-websocket": "^0.2.3", 47 | "vue3-smooth-dnd": "0.0.2", 48 | "yaqrcode": "^0.2.1" 49 | }, 50 | "engines": { 51 | "node": ">= 16.x.x" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /frontend/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | "postcss-import": {}, 4 | "tailwindcss/nesting": {}, 5 | tailwindcss: {}, 6 | autoprefixer: {} 7 | } 8 | }; 9 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/icons/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/android-chrome-192x192.png -------------------------------------------------------------------------------- /frontend/public/icons/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/android-chrome-512x512.png -------------------------------------------------------------------------------- /frontend/public/icons/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/apple-touch-icon.png -------------------------------------------------------------------------------- /frontend/public/icons/browserconfig.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | #da532c 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /frontend/public/icons/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/favicon-16x16.png -------------------------------------------------------------------------------- /frontend/public/icons/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/favicon-32x32.png -------------------------------------------------------------------------------- /frontend/public/icons/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/favicon.ico -------------------------------------------------------------------------------- /frontend/public/icons/mstile-144x144.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/mstile-144x144.png -------------------------------------------------------------------------------- /frontend/public/icons/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/mstile-150x150.png -------------------------------------------------------------------------------- /frontend/public/icons/mstile-310x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/mstile-310x150.png -------------------------------------------------------------------------------- /frontend/public/icons/mstile-310x310.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/mstile-310x310.png -------------------------------------------------------------------------------- /frontend/public/icons/mstile-70x70.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/frontend/public/icons/mstile-70x70.png -------------------------------------------------------------------------------- /frontend/public/icons/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /frontend/src/App.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 25 | -------------------------------------------------------------------------------- /frontend/src/apollo.js: -------------------------------------------------------------------------------- 1 | import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client/core"; 2 | import { setContext } from "apollo-link-context"; 3 | import { createApolloProvider } from "@vue/apollo-option"; 4 | 5 | import Cookie from "js-cookie"; 6 | 7 | const COOKIE_TOKEN_NAME = "jwt-token"; 8 | // HTTP connection to the API 9 | const httpLink = createHttpLink({ 10 | uri: "/graphql" 11 | }); 12 | 13 | const authLink = setContext((_, { headers }) => { 14 | const token = Cookie.get(COOKIE_TOKEN_NAME); 15 | 16 | return { 17 | headers: { 18 | ...headers, 19 | authorization: token ? `Bearer ${token}` : null 20 | } 21 | }; 22 | }); 23 | 24 | export async function apolloAuth() { 25 | try { 26 | await apolloClient.resetStore(); 27 | } catch (e) { 28 | console.error("Error on cache reset (login)", e.message); 29 | } 30 | } 31 | 32 | export const apolloClient = new ApolloClient({ 33 | link: authLink.concat(httpLink), 34 | cache: new InMemoryCache({ 35 | addTypename: false, 36 | freezeResults: false, 37 | resultCaching: false 38 | }), 39 | assumeImmutableResults: false 40 | }); 41 | 42 | export const apolloProvider = createApolloProvider({ 43 | defaultClient: apolloClient 44 | }); 45 | -------------------------------------------------------------------------------- /frontend/src/assets/board-icon.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /frontend/src/bus.js: -------------------------------------------------------------------------------- 1 | import mitt from "mitt"; 2 | 3 | const emitter = mitt(); 4 | 5 | const bus = { 6 | on: (...args) => emitter.on(...args), 7 | once: (...args) => emitter.once(...args), 8 | off: (...args) => emitter.off(...args), 9 | emit: (...args) => emitter.emit(...args), 10 | 11 | install: app => { 12 | app.config.globalProperties.$bus = bus; 13 | 14 | app.mixin({ 15 | beforeMount() { 16 | const events = this.$options.events; 17 | if (events) { 18 | for (const event in events) { 19 | const fn = events[event].bind(this); 20 | events[event]._binded = fn; 21 | bus.on(event, fn); 22 | } 23 | // console.log("On all", emitter.all); 24 | } 25 | }, 26 | 27 | beforeUnmount() { 28 | const events = this.$options.events; 29 | if (events) { 30 | for (const event in events) { 31 | if (events[event]._binded) { 32 | bus.off(event, events[event]._binded); 33 | } 34 | } 35 | // console.log("Off all", emitter.all); 36 | } 37 | } 38 | }); 39 | } 40 | }; 41 | 42 | export default bus; 43 | -------------------------------------------------------------------------------- /frontend/src/components/Card.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 46 | 47 | 144 | -------------------------------------------------------------------------------- /frontend/src/components/Dialog.vue: -------------------------------------------------------------------------------- 1 | 34 | 50 | -------------------------------------------------------------------------------- /frontend/src/components/EditBoardDialog.vue: -------------------------------------------------------------------------------- 1 | 54 | 125 | -------------------------------------------------------------------------------- /frontend/src/components/GuideSection.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 27 | -------------------------------------------------------------------------------- /frontend/src/components/KanbanItem.vue: -------------------------------------------------------------------------------- 1 | 18 | 19 | 29 | -------------------------------------------------------------------------------- /frontend/src/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 8 | -------------------------------------------------------------------------------- /frontend/src/components/Panel.vue: -------------------------------------------------------------------------------- 1 | 23 | 24 | 46 | 47 | 144 | -------------------------------------------------------------------------------- /frontend/src/components/SocialLinks.vue: -------------------------------------------------------------------------------- 1 | 34 | 35 | 57 | 58 | 69 | -------------------------------------------------------------------------------- /frontend/src/components/board/Board.vue: -------------------------------------------------------------------------------- 1 | 26 | 27 | 80 | -------------------------------------------------------------------------------- /frontend/src/graphqlClient.js: -------------------------------------------------------------------------------- 1 | import { GraphQLClient } from "graphql-request"; 2 | import Cookie from "js-cookie"; 3 | 4 | const COOKIE_TOKEN_NAME = "jwt-token"; 5 | const endpoint = "/graphql"; 6 | //const client = new GraphQLClient(endpoint); 7 | 8 | // Set a single header 9 | const token = Cookie.get(COOKIE_TOKEN_NAME); 10 | 11 | //const client = new GraphQLClient(endpoint); 12 | 13 | export const graphqlClient = new GraphQLClient(endpoint); 14 | graphqlClient.setHeader("authorization", token ? `Bearer ${token}` : null); 15 | 16 | // Override all existing headers 17 | graphqlClient.setHeaders({ 18 | authorization: token ? `Bearer ${token}` : null, 19 | anotherheader: "header_value" 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/src/i18next.js: -------------------------------------------------------------------------------- 1 | import i18next from "i18next"; 2 | import i18NextLanguageDetector from "i18next-browser-languagedetector"; 3 | import i18NextXHR from "i18next-xhr-backend"; 4 | import i18NBackendAdapter from "i18next-multiload-backend-adapter"; 5 | 6 | import dayjs from "dayjs"; 7 | import "dayjs/locale/hu.js"; 8 | import localizedFormat from "dayjs/plugin/localizedFormat"; 9 | dayjs.extend(localizedFormat); 10 | 11 | export default function () { 12 | return init() 13 | .then(t => { 14 | return app => { 15 | app.config.globalProperties.$i18n = i18next; 16 | app.config.globalProperties.$lang = i18next.language; 17 | app.config.globalProperties.$t = t; 18 | 19 | // Register as a directive 20 | app.directive("i18n", { 21 | bind: function (el, binding) { 22 | el.innerHTML = i18next.t(binding.expression); 23 | } 24 | }); 25 | 26 | dayjs.locale(i18next.language); 27 | 28 | console.log( 29 | `I18Next initialized! Language: ${ 30 | i18next.language 31 | }, Date format: ${dayjs().format("LLLL")}` 32 | ); 33 | }; 34 | }) 35 | .catch(err => { 36 | console.error("Unable to initialize I18Next", err); 37 | return err; 38 | }); 39 | } 40 | 41 | async function init() { 42 | return i18next 43 | .use(i18NBackendAdapter) 44 | .use(i18NextLanguageDetector) 45 | .init({ 46 | //lng: "en", 47 | fallbackLng: "en", 48 | whitelist: ["en", "hu"], 49 | ns: ["common", "errors"], 50 | defaultNS: "common", 51 | load: "languageOnly", 52 | saveMissing: true, 53 | saveMissingTo: "all", // "fallback", "current", "all" 54 | 55 | backend: { 56 | backend: i18NextXHR, 57 | backendOption: { 58 | // path where resources get loaded from 59 | loadPath: "/locales?lng={{lng}}&ns={{ns}}", 60 | 61 | // path to post missing resources 62 | addPath: "/locales?lng={{lng}}&ns={{ns}}" 63 | } 64 | }, 65 | 66 | detection: { 67 | order: ["cookie", "navigator"] 68 | /* 69 | // keys or params to lookup language from 70 | lookupCookie: 'i18next', 71 | lookupLocalStorage: 'i18nextLng', 72 | 73 | // cache user language on 74 | caches: ['localStorage', 'cookie'] 75 | 76 | // optional expire and domain for set cookie 77 | cookieMinutes: 10, 78 | cookieDomain: 'myDomain', 79 | */ 80 | } 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": ".", 4 | "paths": { 5 | "@/*": ["src/*"] 6 | } 7 | }, 8 | "exclude": ["node_modules", "dist"] 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/layouts/AppLayout.vue: -------------------------------------------------------------------------------- 1 | 36 | 37 | 56 | 57 | 66 | -------------------------------------------------------------------------------- /frontend/src/layouts/AuthLayout.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 29 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp, h } from "vue"; 2 | 3 | // --- I18NEXT --- 4 | import VueI18Next from "./i18next"; 5 | 6 | // --- EVENT BUS --- 7 | import eventBus from "./bus"; 8 | 9 | // --- VUE-ROUTER --- 10 | import router from "./router"; 11 | 12 | // --- PINIA STORE --- 13 | import { createPinia } from "pinia"; 14 | 15 | // --- VUE-ROUTER-SYNC --- 16 | //import { sync } from "vuex-router-sync"; 17 | //sync(store, router); 18 | 19 | // --- SOCKET.IO CLIENT --- 20 | //import VueWebsocket from "vue-websocket"; 21 | //Vue.use(VueWebsocket); 22 | 23 | // --- SERVICE WORKER --- 24 | import "./registerServiceWorker"; 25 | 26 | // --- SWEET ALERTS --- 27 | import swal from "sweetalert"; 28 | 29 | // --- NOTIFICATIONS (IZITOAST) --- 30 | import iziToast from "./toast"; 31 | 32 | // --- VUE GRAPHQL CLIENT --- 33 | import { graphqlClient } from "./graphqlClient"; 34 | // TailwindCSS 35 | import "./styles/index.css"; 36 | 37 | // --- APP --- 38 | import App from "./App.vue"; 39 | 40 | // --- BOOTSTRAP --- 41 | VueI18Next().then(i18n => { 42 | const app = createApp({ 43 | render: () => h(App) 44 | }); 45 | app.use(eventBus); 46 | app.use(router); 47 | app.use(i18n); 48 | app.use(graphqlClient); 49 | app.use(createPinia()); 50 | 51 | app.config.globalProperties.$swal = swal; 52 | app.config.globalProperties.$toast = iziToast; 53 | //app.config.globalProperties.$apollo = apolloClient; 54 | 55 | app.mount("#app"); 56 | 57 | window.app = app; 58 | }); 59 | -------------------------------------------------------------------------------- /frontend/src/mixins/auth.mixin.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import SocialAuth from "../components/SocialAuth.vue"; 4 | 5 | export default { 6 | components: { 7 | SocialAuth 8 | }, 9 | data() { 10 | return { 11 | processing: false, 12 | fullName: "", 13 | email: "", 14 | username: "", 15 | password: "", 16 | error: null, 17 | success: null 18 | }; 19 | }, 20 | 21 | methods: { 22 | async submit() { 23 | this.processing = true; 24 | this.error = null; 25 | this.success = null; 26 | try { 27 | await this.process(); 28 | } catch (err) { 29 | //console.log(JSON.stringify(err, null, 2)); 30 | this.error = err.message; 31 | this.success = null; 32 | } 33 | this.processing = false; 34 | } 35 | } 36 | }; 37 | -------------------------------------------------------------------------------- /frontend/src/mixins/dateFormatter.js: -------------------------------------------------------------------------------- 1 | import dayjs from "dayjs"; 2 | import duration from "dayjs/plugin/duration"; 3 | import relativeTime from "dayjs/plugin/relativeTime"; 4 | dayjs.extend(duration); 5 | dayjs.extend(relativeTime); 6 | 7 | export default { 8 | methods: { 9 | dateToAgo(timestamp) { 10 | return dayjs(timestamp).fromNow(); 11 | }, 12 | dateToLong(timestamp) { 13 | return dayjs(timestamp).format("LLL"); 14 | } 15 | } 16 | }; 17 | -------------------------------------------------------------------------------- /frontend/src/pages/Board.vue: -------------------------------------------------------------------------------- 1 | 40 | 41 | 106 | -------------------------------------------------------------------------------- /frontend/src/pages/Home.vue: -------------------------------------------------------------------------------- 1 | 42 | 43 | 74 | 75 | 83 | -------------------------------------------------------------------------------- /frontend/src/pages/NotFound.vue: -------------------------------------------------------------------------------- 1 | 20 | 21 | 30 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/ForgotPassword.vue: -------------------------------------------------------------------------------- 1 | 29 | 30 | 49 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/Login.vue: -------------------------------------------------------------------------------- 1 | 63 | 64 | 114 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/Passwordless.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 37 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/ResetPassword.vue: -------------------------------------------------------------------------------- 1 | 31 | 32 | 55 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/SignUp.vue: -------------------------------------------------------------------------------- 1 | 72 | 73 | 96 | -------------------------------------------------------------------------------- /frontend/src/pages/auth/VerifyAccount.vue: -------------------------------------------------------------------------------- 1 | 10 | 11 | 36 | -------------------------------------------------------------------------------- /frontend/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( 9 | "App is being served from cache by a service worker.\n" + 10 | "For more details, visit https://goo.gl/AFskqB" 11 | ); 12 | }, 13 | registered() { 14 | console.log("Service worker has been registered."); 15 | }, 16 | cached() { 17 | console.log("Content has been cached for offline use."); 18 | }, 19 | updatefound() { 20 | console.log("New content is downloading."); 21 | }, 22 | updated() { 23 | console.log("New content is available; please refresh."); 24 | }, 25 | offline() { 26 | console.log("No internet connection found. App is running in offline mode."); 27 | }, 28 | error(error) { 29 | console.error("Error during service worker registration:", error); 30 | } 31 | }); 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createWebHistory, createRouter } from "vue-router"; 2 | import routes from "./routes"; 3 | 4 | export default createRouter({ 5 | history: createWebHistory(), 6 | scrollBehavior: () => ({ top: 0 }), 7 | routes 8 | }); 9 | -------------------------------------------------------------------------------- /frontend/src/router/routes.js: -------------------------------------------------------------------------------- 1 | import Home from "../pages/Home.vue"; 2 | 3 | export default [ 4 | { 5 | path: "/", 6 | component: () => import("../layouts/AppLayout.vue"), 7 | children: [ 8 | { 9 | path: "/", 10 | name: "home", 11 | component: Home 12 | }, 13 | { 14 | path: "/style-guide", 15 | name: "style-guide", 16 | component: () => import("../pages/StyleGuide.vue") 17 | }, 18 | { 19 | path: "/about", 20 | name: "about", 21 | component: () => import("../pages/About.vue"), 22 | meta: { 23 | requiresAuth: true 24 | } 25 | }, 26 | { 27 | path: "/board/:id-:slug", 28 | name: "Board", 29 | component: () => import("../pages/Board.vue"), 30 | props: true 31 | } 32 | ] 33 | }, 34 | { 35 | path: "/auth", 36 | component: () => import("../layouts/AuthLayout.vue"), 37 | children: [ 38 | { 39 | path: "/login", 40 | name: "login", 41 | component: () => import("../pages/auth/Login.vue"), 42 | meta: { 43 | redirectAuth: "home" 44 | } 45 | }, 46 | { 47 | path: "/signup", 48 | name: "signup", 49 | component: () => import("../pages/auth/SignUp.vue"), 50 | meta: { 51 | redirectAuth: "home" 52 | } 53 | }, 54 | { 55 | path: "/forgot-password", 56 | name: "forgot-password", 57 | component: () => import("../pages/auth/ForgotPassword.vue"), 58 | meta: { 59 | redirectAuth: "home" 60 | } 61 | }, 62 | { 63 | path: "/reset-password", 64 | name: "reset-password", 65 | component: () => import("../pages/auth/ResetPassword.vue"), 66 | meta: { 67 | redirectAuth: "home" 68 | } 69 | }, 70 | { 71 | path: "/verify-account", 72 | name: "verify-account", 73 | component: () => import("../pages/auth/VerifyAccount.vue"), 74 | meta: { 75 | redirectAuth: "home" 76 | } 77 | }, 78 | { 79 | path: "/passwordless", 80 | name: "passwordless", 81 | component: () => import("../pages/auth/Passwordless.vue"), 82 | meta: { 83 | redirectAuth: "home" 84 | } 85 | } 86 | ] 87 | }, 88 | { 89 | path: "/:pathMatch(.*)*", 90 | name: "404", 91 | component: () => import("../pages/NotFound.vue") 92 | } 93 | ]; 94 | -------------------------------------------------------------------------------- /frontend/src/styles/common.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | @apply m-0 p-0 w-full h-full; 3 | @apply text-text font-sans; 4 | 5 | font-size: 16px; 6 | 7 | -webkit-font-smoothing: antialiased; 8 | -moz-osx-font-smoothing: grayscale; 9 | 10 | background-image: -webkit-radial-gradient(center center, #454F56, #1D2124); 11 | background-attachment: fixed; 12 | } 13 | 14 | @layer base { 15 | h1 { 16 | @apply text-5xl font-title font-light; 17 | } 18 | h2 { 19 | @apply text-4xl font-title font-light; 20 | } 21 | h3 { 22 | @apply text-3xl font-title font-light; 23 | } 24 | h4 { 25 | @apply text-2xl font-title font-light; 26 | } 27 | h5 { 28 | @apply text-xl font-title font-light; 29 | } 30 | 31 | a.link { 32 | @apply border-b border-b-primary text-white hover:text-primary 33 | } 34 | } 35 | 36 | @layer components { 37 | 38 | .text-shadow { 39 | text-shadow: 0 0 0.25rem rgb(0 0 0 / 60%); 40 | } 41 | 42 | .text-shadow-sm { 43 | text-shadow: 0 0 0.1rem rgb(0 0 0 / 40%); 44 | } 45 | 46 | .text-shadow-md { 47 | text-shadow: 0 0 0.5rem rgb(0 0 0 / 60%); 48 | } 49 | 50 | .text-shadow-lg { 51 | text-shadow: 0 0 1rem rgb(0 0 0 / 60%); 52 | } 53 | 54 | .text-shadow-none { 55 | text-shadow: none; 56 | } 57 | } 58 | 59 | 60 | 61 | /* ---------------------------------- */ 62 | @keyframes progress-bar-stripes { 63 | from { background-position: 40px 0; } 64 | to { background-position: 0 0; } 65 | } 66 | 67 | /* ---------------------------------- */ 68 | .fade-enter-active, 69 | .fade-leave-active { 70 | transition: opacity .2s linear; 71 | } 72 | 73 | .fade-enter-from, 74 | .fade-leave-to { 75 | opacity: 0; 76 | } 77 | 78 | .scale-enter-active, 79 | .scale-leave-active { 80 | transition: transform .2s linear; 81 | } 82 | 83 | .scale-enter-from, 84 | .scale-leave-to { 85 | transform: scale(0); 86 | } 87 | 88 | 89 | /* ---------------------------------- */ 90 | ::-webkit-scrollbar { 91 | @apply w-2 h-2; 92 | } 93 | 94 | ::-webkit-scrollbar-thumb { 95 | @apply bg-neutral-400 cursor-pointer border-0 rounded-full; 96 | } 97 | 98 | ::-webkit-scrollbar-track { 99 | @apply bg-transparent; 100 | } 101 | 102 | ::-webkit-scrollbar-corner { 103 | @apply bg-transparent; 104 | } 105 | -------------------------------------------------------------------------------- /frontend/src/styles/extras.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | 3 | .alert { 4 | @apply bg-input text-text 5 | px-4 py-1 rounded-lg 6 | shadow-md 7 | text-shadow 8 | text-base; 9 | 10 | .icon { 11 | @apply mr-2 leading-none; 12 | } 13 | 14 | .close { 15 | @apply cursor-pointer border-l border-l-neutral-600 ml-2 pl-3; 16 | } 17 | } 18 | 19 | .badge { 20 | @apply bg-input text-text rounded px-2 py-1 text-xs text-shadow; 21 | 22 | &.pill { 23 | @apply rounded-full grid place-items-center; 24 | min-width: 1.65rem; 25 | } 26 | 27 | &.outlined { 28 | @apply bg-transparent border border-text 29 | } 30 | } 31 | 32 | .progressbar { 33 | @apply bg-input text-text rounded-full h-5 text-sm; 34 | 35 | &.small { 36 | @apply h-4 text-xs; 37 | } 38 | 39 | &.extra-small { 40 | @apply h-2 text-xs; 41 | } 42 | 43 | &.large { 44 | @apply h-8 text-lg; 45 | } 46 | 47 | .progress { 48 | @apply bg-primary rounded-full text-shadow text-ellipsis h-full transition-all grid place-items-center; 49 | } 50 | 51 | &.stripped .progress { 52 | background-image: linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent); 53 | background-size: 40px 40px; 54 | animation: progress-bar-stripes 1s linear infinite; 55 | } 56 | } 57 | } 58 | 59 | -------------------------------------------------------------------------------- /frontend/src/styles/index.css: -------------------------------------------------------------------------------- 1 | @import "tailwindcss/base"; 2 | @import "tailwindcss/components"; 3 | @import "tailwindcss/utilities"; 4 | 5 | @import "./common.css"; 6 | @import "./buttons.css"; 7 | @import "./extras.css"; 8 | @import "./forms.css"; 9 | @import "./tables.css"; 10 | 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/styles/tables.css: -------------------------------------------------------------------------------- 1 | @layer components { 2 | 3 | table.table { 4 | @apply w-full border-collapse border border-input rounded-md text-shadow; 5 | 6 | th, td { 7 | @apply px-3 py-2 text-center cursor-default; 8 | } 9 | 10 | thead th, tfoot td { 11 | @apply bg-input font-bold uppercase; 12 | } 13 | 14 | thead th { 15 | i.fa { 16 | @apply mr-2; 17 | } 18 | 19 | &:first-child { 20 | @apply rounded-tl-md; 21 | } 22 | &:last-child { 23 | @apply rounded-tr-md; 24 | } 25 | 26 | &.sortable { 27 | @apply px-6; 28 | 29 | @apply relative; 30 | &::after { 31 | content: "\f0dc"; 32 | @apply absolute text-neutral-500 right-1.5 top-2 transition-all font-awesome; 33 | } 34 | 35 | &.sorted { 36 | &::after { 37 | content: "\f0dd"; 38 | @apply text-text; 39 | } 40 | 41 | &.desc::after { 42 | content: "\f0de"; 43 | } 44 | 45 | } 46 | } 47 | } 48 | 49 | tbody { 50 | tr { 51 | @apply transition-colors; 52 | 53 | td { 54 | &.selector { 55 | &::after { 56 | content: "\f096"; 57 | @apply font-awesome text-xl; 58 | } 59 | } 60 | } 61 | } 62 | 63 | /* If there is no tfoot add radius */ 64 | &:last-child { 65 | tr:last-child td { 66 | &:first-child { 67 | border-radius: 0 0 0 8px; 68 | } 69 | 70 | &:last-child { 71 | border-radius: 0 0 8px 0; 72 | } 73 | } 74 | } 75 | 76 | } 77 | 78 | tfoot tr td { 79 | &:first-child { 80 | @apply rounded-bl-md; 81 | } 82 | &:last-child { 83 | @apply rounded-br-md; 84 | } 85 | } 86 | 87 | &.bordered { 88 | tbody tr:not(:last-child) td { 89 | @apply border-b border-b-neutral-600; 90 | } 91 | } 92 | 93 | &.stripped { 94 | tbody tr:nth-child(even) { 95 | @apply bg-white bg-opacity-5; 96 | } 97 | } 98 | 99 | /* Hover & Selected styles */ 100 | tbody tr:nth-child(even), tbody tr:nth-child(odd) { 101 | @apply hover:bg-primary-600 hover:bg-opacity-50; 102 | 103 | &.inactive td:not(.selector) { 104 | @apply italic text-neutral-500; 105 | } 106 | 107 | &.selected { 108 | @apply bg-primary-600; 109 | 110 | td.selector::after { 111 | content: "\f046"; 112 | } 113 | 114 | &.inactive td { 115 | @apply text-text text-opacity-60; 116 | } 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /frontend/src/toast.js: -------------------------------------------------------------------------------- 1 | import iziToast from "izitoast"; 2 | import "izitoast/dist/css/iziToast.css"; 3 | 4 | iziToast.settings({ 5 | theme: "light", 6 | position: "bottomRight", 7 | animateInside: false, 8 | transitionIn: "fadeInUp" 9 | //timeout: 10000, 10 | }); 11 | 12 | export default iziToast; 13 | -------------------------------------------------------------------------------- /frontend/src/utils.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Get the contrasting color for any hex color 3 | * (c) 2019 Chris Ferdinandi, MIT License, https://gomakethings.com 4 | * Derived from work by Brian Suda, https://24ways.org/2010/calculating-color-contrast/ 5 | * @param {String} A hexcolor value 6 | * @return {String} The contrasting color (black or white) 7 | */ 8 | export function getTextColorByBackgroundColor(hexcolor) { 9 | // If a leading # is provided, remove it 10 | if (hexcolor.slice(0, 1) === "#") { 11 | hexcolor = hexcolor.slice(1); 12 | } 13 | 14 | // If a three-character hexcode, make six-character 15 | if (hexcolor.length === 3) { 16 | hexcolor = hexcolor 17 | .split("") 18 | .map(function (hex) { 19 | return hex + hex; 20 | }) 21 | .join(""); 22 | } 23 | 24 | // Convert to RGB value 25 | var r = parseInt(hexcolor.substr(0, 2), 16); 26 | var g = parseInt(hexcolor.substr(2, 2), 16); 27 | var b = parseInt(hexcolor.substr(4, 2), 16); 28 | 29 | // Get YIQ ratio 30 | var yiq = (r * 299 + g * 587 + b * 114) / 1000; 31 | 32 | // Check contrast 33 | return yiq >= 128 ? "black" : "white"; 34 | } 35 | -------------------------------------------------------------------------------- /frontend/tailwind.config.js: -------------------------------------------------------------------------------- 1 | const colors = require("tailwindcss/colors"); 2 | const defaultTheme = require("tailwindcss/defaultTheme"); 3 | 4 | // Default: https://github.com/tailwindlabs/tailwindcss/blob/master/stubs/defaultConfig.stub.js 5 | module.exports = { 6 | important: "#app", 7 | content: ["./index.html", "./src/**/*.{vue,js,ts,jsx,tsx}"], 8 | theme: { 9 | extend: { 10 | colors: { 11 | // https://www.tailwindshades.com/#color=74.70967741935483%2C63.78600823045269%2C47.647058823529406&step-up=8&step-down=11&hue-shift=0&name=atlantis&overrides=e30%3D 12 | primary: { 13 | DEFAULT: "#8CAD26", 14 | 50: "#D8EAA0", 15 | 100: "#D1E790", 16 | 200: "#C4DF6E", 17 | 300: "#B6D84D", 18 | 400: "#A7CE2D", 19 | 500: "#8CAD26", 20 | 600: "#677F1C", 21 | 700: "#425112", 22 | 800: "#1C2308", 23 | 900: "#000000" 24 | }, 25 | secondary: { 26 | DEFAULT: "#C96B2C", 27 | 50: "#F0D1BC", 28 | 100: "#EDC6AB", 29 | 200: "#E5AF8A", 30 | 300: "#DE9869", 31 | 400: "#D78147", 32 | 500: "#C96B2C", 33 | 600: "#9B5222", 34 | 700: "#6D3A18", 35 | 800: "#3F210E", 36 | 900: "#110904" 37 | }, 38 | panel: "#2e353a", 39 | card: "#22272b", 40 | muted: "#929292", 41 | text: "#dedede", 42 | input: "#1D2124", 43 | 44 | warning: colors.yellow[600], 45 | negative: colors.red[500], 46 | positive: colors.green[600] 47 | }, 48 | 49 | fontFamily: { 50 | title: ["Roboto Condensed", ...defaultTheme.fontFamily.sans], 51 | sans: [/*'"Open Sans"', */ ...defaultTheme.fontFamily.sans], 52 | awesome: ["FontAwesome"] 53 | }, 54 | 55 | fontSize: { 56 | xxs: ["0.6rem", { lineHeight: "0.8rem" }] 57 | }, 58 | 59 | boxShadow: { 60 | panel: "2px 5px 5px 0 rgb(0 0 0 / 25%)", 61 | primary: "0 0 10px rgba(#8CAD26 / 80%)" 62 | }, 63 | 64 | width: { 65 | list: "280px" 66 | }, 67 | 68 | minWidth: { 69 | list: "280px" 70 | } 71 | } 72 | }, 73 | plugins: [] 74 | }; 75 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "vite"; 2 | import vue from "@vitejs/plugin-vue"; 3 | import { resolve } from "path"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | plugins: [vue()], 8 | resolve: { 9 | alias: { 10 | "@": resolve(__dirname, "src") 11 | } 12 | }, 13 | build: { 14 | outDir: "../public", 15 | emptyOutDir: true, 16 | chunkSizeWarningLimit: 1024 17 | }, 18 | server: { 19 | port: 8080, 20 | proxy: { 21 | "/api": "http://localhost:4000", 22 | "/auth": "http://localhost:4000", 23 | "/locales": "http://localhost:4000", 24 | "/graphql": "http://localhost:4000" 25 | } 26 | } 27 | }); 28 | -------------------------------------------------------------------------------- /graphql.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | schema: "http://localhost:4000/graphql" 3 | }; 4 | -------------------------------------------------------------------------------- /jsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "checkJs": false, 4 | "target": "es6", 5 | }, 6 | "exclude": [ 7 | "node_modules" 8 | ] 9 | } 10 | -------------------------------------------------------------------------------- /kubernetes/.env.example: -------------------------------------------------------------------------------- 1 | # Node 2 | NODE_ENV=production 3 | PORT=3000 4 | 5 | # Moleculer 6 | NAMESPACE=kantab-prod 7 | LOGLEVEL=info 8 | TRANSPORTER=nats://nats:4222 9 | CACHER=redis://redis:6379 10 | SERVICEDIR=backend/services 11 | 12 | # APM 13 | APM_SERVICE_NAME= 14 | MOLECULER_APM_ENABLE= 15 | -------------------------------------------------------------------------------- /kubernetes/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .secret -------------------------------------------------------------------------------- /kubernetes/.secret.example: -------------------------------------------------------------------------------- 1 | # MongoDB 2 | MONGO_URI= 3 | 4 | JWT_SECRET= 5 | HASHID_SALT= 6 | TOKEN_SALT= 7 | 8 | GOOGLE_CLIENT_ID= 9 | GOOGLE_CLIENT_SECRET= 10 | 11 | FACEBOOK_CLIENT_ID= 12 | FACEBOOK_CLIENT_SECRET= 13 | 14 | GITHUB_CLIENT_ID= 15 | GITHUB_CLIENT_SECRET= 16 | 17 | MAILTRAP_USER= 18 | MAILTRAP_PASS= 19 | 20 | APOLLO_KEY= 21 | APOLLO_GRAPH_VARIANT= 22 | 23 | LABORATORY_APIKEY= 24 | LABORATORY_TOKEN= 25 | 26 | -------------------------------------------------------------------------------- /kubernetes/README.md: -------------------------------------------------------------------------------- 1 | # K8s Deployment 2 | 3 | 1. create the secret: 4 | Copy the `.secret.example` to `.secret` and set your values 5 | ``` 6 | kubectl create secret generic kantab-secrets --from-env-file=./.secret --namespace=default 7 | ``` 8 | 2. Create a configmap: 9 | Copy the `.env.example` to `.env` and set your values 10 | ``` 11 | kubectl create configmap kantab-configmap --from-env-file=./.env --namespace=default 12 | ``` 13 | 14 | 3. Apply yamls 15 | ``` 16 | kubectl apply -f . 17 | ``` 18 | 19 | This will create all services without ingress. 20 | -------------------------------------------------------------------------------- /kubernetes/accounts-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # accounts service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: accounts-deployment 8 | namespace: default 9 | labels: 10 | name: accounts-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: accounts 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: accounts 21 | spec: 22 | containers: 23 | - name: accounts 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: accounts 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for accounts service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-accounts 48 | namespace: default 49 | labels: 50 | name: hpa-accounts 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: accounts-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/activities-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # activities service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: activities-deployment 8 | namespace: default 9 | labels: 10 | name: activities-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: activities 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: activities 21 | spec: 22 | containers: 23 | - name: activities 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: activities 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for activities service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-activities 48 | namespace: default 49 | labels: 50 | name: hpa-activities 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: activities-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/api-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # Service for Moleculer API Gateway service 3 | ######################################################### 4 | apiVersion: v1 5 | kind: Service 6 | metadata: 7 | name: api 8 | namespace: default 9 | labels: 10 | name: api 11 | app: kantab 12 | spec: 13 | selector: 14 | app: api 15 | ports: 16 | - port: 3000 17 | targetPort: 3000 18 | type: LoadBalancer 19 | --- 20 | ######################################################### 21 | # API Gateway Deployment 22 | ######################################################### 23 | apiVersion: apps/v1 24 | kind: Deployment 25 | metadata: 26 | name: api 27 | namespace: default 28 | labels: 29 | name: api 30 | app: kantab 31 | spec: 32 | selector: 33 | matchLabels: 34 | app: api 35 | replicas: 3 36 | template: 37 | metadata: 38 | labels: 39 | app: api 40 | spec: 41 | containers: 42 | - name: api 43 | image: icebob/kantab:latest 44 | envFrom: 45 | - configMapRef: 46 | name: kantab-configmap 47 | - secretRef: 48 | name: kantab-secrets 49 | ports: 50 | - name: http 51 | protocol: TCP 52 | containerPort: 3000 53 | env: 54 | - name: SERVICES 55 | value: api 56 | resources: 57 | limits: 58 | cpu: 200m 59 | memory: 500Mi 60 | requests: 61 | cpu: 100m 62 | memory: 40Mi 63 | --- 64 | ######################################################### 65 | # Horizontal Pod AutoScaler for API service (K8s >= v1.17) 66 | ######################################################### 67 | apiVersion: autoscaling/v2 68 | kind: HorizontalPodAutoscaler 69 | metadata: 70 | name: hpa-api 71 | namespace: default 72 | labels: 73 | name: hpa-api 74 | app: kantab 75 | spec: 76 | scaleTargetRef: 77 | apiVersion: apps/v1 78 | kind: Deployment 79 | name: api 80 | minReplicas: 3 81 | maxReplicas: 6 82 | metrics: 83 | - type: Resource 84 | resource: 85 | name: cpu 86 | target: 87 | type: Utilization 88 | averageUtilization: 80 89 | --- 90 | 91 | -------------------------------------------------------------------------------- /kubernetes/boards-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # boards service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: boards-deployment 8 | namespace: default 9 | labels: 10 | name: boards-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: boards 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: boards 21 | spec: 22 | containers: 23 | - name: boards 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: boards 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for boards service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-boards 48 | namespace: default 49 | labels: 50 | name: hpa-boards 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: boards-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/cards-attachments-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # cards-attachments service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: cards-attachments-deployment 8 | namespace: default 9 | labels: 10 | name: cards-attachments-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: cards-attachments 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: cards-attachments 21 | spec: 22 | containers: 23 | - name: cards-attachments 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: cards.attachments 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for cards-attachments service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-cards-attachments 48 | namespace: default 49 | labels: 50 | name: hpa-cards-attachments 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: cards-attachments-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/cards-checklists-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # cards-checklists service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: cards-checklists-deployment 8 | namespace: default 9 | labels: 10 | name: cards-checklists-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: cards-checklists 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: cards-checklists 21 | spec: 22 | containers: 23 | - name: cards-checklists 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: cards.checklists 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for cards-checklists service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-cards-checklists 48 | namespace: default 49 | labels: 50 | name: hpa-cards-checklists 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: cards-checklists-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/cards-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # cards service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: cards-deployment 8 | namespace: default 9 | labels: 10 | name: cards-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: cards 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: cards 21 | spec: 22 | containers: 23 | - name: cards 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: cards 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for cards service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-cards 48 | namespace: default 49 | labels: 50 | name: hpa-cards 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: cards-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/config-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # config service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: config-deployment 8 | namespace: default 9 | labels: 10 | name: config-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: config 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: config 21 | spec: 22 | containers: 23 | - name: config 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: config 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for config service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-config 48 | namespace: default 49 | labels: 50 | name: hpa-config 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: config-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/lab-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: lab 5 | namespace: default 6 | labels: 7 | name: lab 8 | app: kantab 9 | spec: 10 | selector: 11 | app: lab 12 | ports: 13 | - port: 3212 14 | targetPort: 3212 15 | type: LoadBalancer 16 | --- 17 | apiVersion: apps/v1 18 | kind: Deployment 19 | metadata: 20 | name: lab 21 | namespace: default 22 | labels: 23 | name: lab 24 | app: kantab 25 | spec: 26 | selector: 27 | matchLabels: 28 | app: lab 29 | replicas: 1 30 | template: 31 | metadata: 32 | labels: 33 | app: lab 34 | spec: 35 | containers: 36 | - name: lab 37 | image: icebob/kantab:latest 38 | envFrom: 39 | - configMapRef: 40 | name: kantab-configmap 41 | - secretRef: 42 | name: kantab-secrets 43 | env: 44 | - name: SERVICES 45 | value: laboratory 46 | ports: 47 | - name: lab 48 | protocol: TCP 49 | containerPort: 3212 50 | resources: 51 | limits: 52 | cpu: 200m 53 | memory: 500Mi 54 | requests: 55 | cpu: 50m 56 | memory: 40Mi 57 | -------------------------------------------------------------------------------- /kubernetes/lists-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # lists service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: lists-deployment 8 | namespace: default 9 | labels: 10 | name: lists-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: lists 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: lists 21 | spec: 22 | containers: 23 | - name: lists 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: lists 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for lists service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-lists 48 | namespace: default 49 | labels: 50 | name: hpa-lists 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: lists-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/mail-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # mail service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: mail-deployment 8 | namespace: default 9 | labels: 10 | name: mail-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: mail 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: mail 21 | spec: 22 | containers: 23 | - name: mail 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: mail 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for mail service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-mail 48 | namespace: default 49 | labels: 50 | name: hpa-mail 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: mail-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /kubernetes/mongodb.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # API version 2 | kind: StatefulSet 3 | metadata: 4 | name: mongodb # Unique name for the deployment 5 | labels: 6 | app: mongodb # Labels to be applied to this deployment 7 | spec: 8 | serviceName: mongo 9 | replicas: 1 # Run a single pod in the deployment 10 | selector: 11 | matchLabels: # This deployment applies to the Pods matching these labels 12 | app: mongodb 13 | template: # Template for the pods that will be created by this deployment 14 | metadata: 15 | labels: # Labels to be applied to the Pods in this deployment 16 | app: mongodb 17 | spec: # Spec for the container which will be run inside the Pod. 18 | containers: 19 | - name: mongodb 20 | image: mongo 21 | ports: 22 | - containerPort: 27017 23 | resources: {} 24 | volumeMounts: 25 | - mountPath: /data/db 26 | name: mongo-data 27 | volumes: 28 | - name: mongo-data 29 | persistentVolumeClaim: 30 | claimName: mongo-data 31 | --- 32 | apiVersion: v1 33 | kind: Service # Type of Kubernetes resource 34 | metadata: 35 | name: mongodb # Name of the Kubernetes resource 36 | labels: # Labels that will be applied to this resource 37 | app: mongodb 38 | spec: 39 | ports: 40 | - port: 27017 # Map incoming connections on port 27017 to the target port 27017 of the Pod 41 | targetPort: 27017 42 | selector: # Map any Pod with the specified labels to this service 43 | app: mongodb 44 | --- 45 | apiVersion: v1 46 | kind: PersistentVolumeClaim 47 | metadata: 48 | name: mongo-data 49 | labels: 50 | name: mongo-data 51 | spec: 52 | accessModes: 53 | - ReadWriteOnce 54 | resources: 55 | requests: 56 | storage: 1Gi 57 | -------------------------------------------------------------------------------- /kubernetes/redis.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 # API version 2 | kind: Deployment 3 | metadata: 4 | name: redis # Unique name for the deployment 5 | labels: 6 | app: redis # Labels to be applied to this deployment 7 | spec: 8 | selector: 9 | matchLabels: # This deployment applies to the Pods matching these labels 10 | app: redis 11 | replicas: 1 # Run a single pod in the deployment 12 | template: # Template for the pods that will be created by this deployment 13 | metadata: 14 | labels: # Labels to be applied to the Pods in this deployment 15 | app: redis 16 | spec: # Spec for the container which will be run inside the Pod. 17 | containers: 18 | - name: redis 19 | image: redis 20 | ports: 21 | - containerPort: 6379 22 | --- 23 | apiVersion: v1 24 | kind: Service # Type of Kubernetes resource 25 | metadata: 26 | name: redis # Name of the Kubernetes resource 27 | labels: # Labels that will be applied to this resource 28 | app: redis 29 | spec: 30 | ports: 31 | - port: 6379 # Map incoming connections on port 6379 to the target port 6379 of the Pod 32 | targetPort: 6379 33 | selector: # Map any Pod with the specified labels to this service 34 | app: redis 35 | -------------------------------------------------------------------------------- /kubernetes/tokens-deployment.yaml: -------------------------------------------------------------------------------- 1 | ######################################################### 2 | # tokens service 3 | ######################################################### 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: tokens-deployment 8 | namespace: default 9 | labels: 10 | name: tokens-deployment 11 | app: kantab 12 | spec: 13 | selector: 14 | matchLabels: 15 | app: tokens 16 | replicas: 2 17 | template: 18 | metadata: 19 | labels: 20 | app: tokens 21 | spec: 22 | containers: 23 | - name: tokens 24 | image: icebob/kantab:latest 25 | envFrom: 26 | - configMapRef: 27 | name: kantab-configmap 28 | - secretRef: 29 | name: kantab-secrets 30 | env: 31 | - name: SERVICES 32 | value: tokens 33 | resources: 34 | limits: 35 | cpu: 200m 36 | memory: 500Mi 37 | requests: 38 | cpu: 50m 39 | memory: 40Mi 40 | --- 41 | ######################################################### 42 | # Horizontal Pod AutoScaler for tokens service (K8s >= v1.17) 43 | ######################################################### 44 | apiVersion: autoscaling/v2 45 | kind: HorizontalPodAutoscaler 46 | metadata: 47 | name: hpa-tokens 48 | namespace: default 49 | labels: 50 | name: hpa-tokens 51 | app: kantab 52 | spec: 53 | scaleTargetRef: 54 | apiVersion: apps/v1 55 | kind: Deployment 56 | name: tokens-deployment 57 | minReplicas: 2 58 | maxReplicas: 3 59 | metrics: 60 | - type: Resource 61 | resource: 62 | name: cpu 63 | target: 64 | type: Utilization 65 | averageUtilization: 80 66 | --- 67 | 68 | 69 | -------------------------------------------------------------------------------- /locales/en/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcomeTo": "Welcome to {{app.title}}", 3 | 4 | "SignIn": "Sign In", 5 | "SignUp": "Sign Up", 6 | 7 | "ModifiedAt": "Modified at {{ago}}", 8 | 9 | "About": "About", 10 | 11 | "Public": "Public", 12 | "MyBoards": "My boards", 13 | "noDescription": "no description", 14 | "Title": "Title", 15 | "EditBoard": "Edit board", 16 | "NewBoard": "New board", 17 | "Description": "Description", 18 | "Ok": "Ok", 19 | "Cancel": "Cancel", 20 | "UseCustomColor": "Use custom color", 21 | "Remove": "Remove", 22 | "EditList": "Edit list", 23 | "NewList": "New list", 24 | "NewCard": "Add card", 25 | "EditCard": "Edit card", 26 | "PublicBoards": "Nyilvános táblák" 27 | } 28 | -------------------------------------------------------------------------------- /locales/en/common.missing.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /locales/en/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /locales/hu/common.json: -------------------------------------------------------------------------------- 1 | { 2 | "welcomeTo": "Üdvözöljük a {{title}} oldalon!", 3 | 4 | "SignIn": "Bejelentkezés", 5 | "SignUp": "Regisztráció", 6 | 7 | "ModifiedAt": "Módosítva {{ago}}", 8 | 9 | "About": "Névjegy", 10 | 11 | "Public": "Nyilvános", 12 | "MyBoards": "Tábláim", 13 | "noDescription": "nincs megjegyzés", 14 | "Title": "Cím", 15 | "EditBoard": "Tábla szerkesztése", 16 | "NewBoard": "Új tábla", 17 | "Description": "Megjegyzés", 18 | "Ok": "Ok", 19 | "Cancel": "Mégsem", 20 | "UseCustomColor": "Egyedi szín használata", 21 | "Remove": "Eltávolítás", 22 | "EditList": "Lista szerkesztése", 23 | "NewList": "Új lista", 24 | "NewCard": "Új kártya", 25 | "EditCard": "Kártya szerkesztése", 26 | "PublicBoards": "Nyilvános táblák" 27 | } 28 | -------------------------------------------------------------------------------- /locales/hu/common.missing.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /locales/hu/errors.json: -------------------------------------------------------------------------------- 1 | { 2 | 3 | } 4 | -------------------------------------------------------------------------------- /monitoring/README.md: -------------------------------------------------------------------------------- 1 | # Kantab monitoring 2 | 3 | In production, this project contains some monitoring features. It uses [Prometheus](https://prometheus.io/) & [Grafana](https://grafana.com/). 4 | 5 | ## Start the project in production in Docker containers 6 | 7 | ```bash 8 | $ docker-compose up --build -d 9 | ``` 10 | 11 | ## UI 12 | 13 | ### Prometheus UI 14 | ``` 15 | http://:9090 16 | ``` 17 | ![](https://files.gitter.im/moleculerjs/moleculer/fi8l/image.png) 18 | 19 | The Docker Compose files contains the following Prometheus Exporters: 20 | - NATS Exporter 21 | - Redis Exporter 22 | - Mongo Exporter 23 | 24 | ### Grafana UI 25 | 26 | - **Username**: admin 27 | - **Password**: admin 28 | ``` 29 | http://:9000 30 | ``` 31 | 32 | ![](https://files.gitter.im/moleculerjs/moleculer/BpWN/image.png) 33 | 34 | The Grafana contains the following dashboards: 35 | - Moleculer 36 | - MongoDB 37 | - NATS Server Dashboard 38 | - Redis Dashboard 39 | - Prometheus 40 | 41 | ## Alert manager 42 | 43 | It starts a [prom/alertmanager](https://hub.docker.com/r/prom/alertmanager/) instance. To configure alert channels, edit the [alertmanager/config.yml](alertmanager/config.yml) file. To configure alerts, edit the [prometheus/alert.rules](prometheus/alert.rules) file. 44 | 45 | The [prometheus/prometheus.yml](prometheus/prometheus.yml) file contains the Prometheus server settings. 46 | -------------------------------------------------------------------------------- /monitoring/alertmanager/config.yml: -------------------------------------------------------------------------------- 1 | route: 2 | receiver: 'slack' 3 | 4 | receivers: 5 | - name: 'slack' 6 | slack_configs: 7 | - send_resolved: true 8 | username: '' 9 | channel: '#' 10 | api_url: '' -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Grafana 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 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/MANIFEST.txt: -------------------------------------------------------------------------------- 1 | 2 | -----BEGIN PGP SIGNED MESSAGE----- 3 | Hash: SHA512 4 | 5 | { 6 | "plugin": "grafana-piechart-panel", 7 | "version": "1.6.1", 8 | "files": { 9 | "LICENSE": "b46400c1ab5630edeb4e43b240a027d203375ebe5a9efb7de88c604c5c5cace0", 10 | "README.md": "549e456ba86b2677fbb55f21dac137452d1fa10b8f73534f2c9be06dfe0ed21c", 11 | "dark.js": "1e1732392f8c733433d5078f62c181732b2e9cf801915a7cac1db1b29777d22e", 12 | "dark.js.map": "9f9a08e0c7c69f92a7bab8690faadc8ec2f65379834362cdb45fb8d6679a7336", 13 | "editor.html": "aa4a490a2a93c1c6fdcbfc2cf7351afd1af213205ce74ab1f3f4f08bac9db24c", 14 | "light.js": "eb1808113eafd870881e57b3a28f4229a3710981670b0dc7409742a01b041259", 15 | "light.js.map": "80ee11ec21f071cf8988b3b049e8159cbcdb6153b5ca9a5523fe7dbf96083323", 16 | "module.html": "10d8f62fdc7359e904e04b34400d8770098d4973e7796b54f007e6a8fa284a3b", 17 | "module.js": "20da74222a8af656376bbd77558c936df722db5a295cea460f58e0a1dc0a5a96", 18 | "module.js.LICENSE.txt": "30f143a8e05630b1ab2ff90e572b97708701d7fc5f8e8fd2e44bbe9787723aea", 19 | "module.js.map": "932bbcc50abe443aca25323f05ce53baef20363ffb6fa803ff7434d86a44a01b", 20 | "plugin.json": "51692acde552a569ec2716465586ea64bddead48aa279a35cda43b6722e78d17", 21 | "styles/dark.css": "e0c2663c2c7dabf97ffaebc740a3b5f7fb69aefaaf89351d9fa8762f77ec5cf6", 22 | "styles/light.css": "6430d7d73d05e50adedf3d0ec9fa6f26970ca362f24be63b3b8f506405f8a921" 23 | }, 24 | "time": 1600286584192, 25 | "keyId": "7e4d0c6a708866e7" 26 | } 27 | -----BEGIN PGP SIGNATURE----- 28 | Version: OpenPGP.js v4.10.1 29 | Comment: https://openpgpjs.org 30 | 31 | wqIEARMKAAYFAl9ib3gACgkQfk0ManCIZucMEAIJAdAVb6pOOaYvNDBWDXmr 32 | u7fsPS4qr96g+WHpMfQf9TSuEqAWMbKBHJQIyYLR2Q5ZvL+h+aQpfb6gsCqZ 33 | SKFRobEeAgkB5wsDfPMof5lwJf8IP1t293xURrVKWadtpp0mV5N+Fxrf6YQ0 34 | GiTNk5Y+zmmf7lmhQ7efGf/vW04ePgsvJGjz6pE= 35 | =IlbY 36 | -----END PGP SIGNATURE----- 37 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/grafana/piechart-panel.svg?style=svg)](https://circleci.com/gh/grafana/piechart-panel) 2 | [![David Dependancy Status](https://david-dm.org/grafana/piechart-panel.svg)](https://david-dm.org/grafana/piechart-panel) 3 | [![David Dev Dependency Status](https://david-dm.org/grafana/piechart-panel/dev-status.svg)](https://david-dm.org/grafana/piechart-panel/?type=dev) 4 | 5 | Use the new grafana-cli tool to install piechart-panel from the commandline: 6 | 7 | ``` 8 | grafana-cli plugins install grafana-piechart-panel 9 | ``` 10 | 11 | The plugin will be installed into your grafana plugins directory; the default is /var/lib/grafana/plugins if you installed the grafana package. 12 | 13 | More instructions on the cli tool can be found [here](https://grafana.com/docs/grafana/latest/plugins/installation/). 14 | 15 | You need the lastest grafana build for Grafana 3.0 to enable plugin support. You can get it here : http://grafana.org/download/builds.html 16 | 17 | ## Alternative installation methods 18 | 19 | ### Download latest zip 20 | 21 | ```BASH 22 | wget -nv https://grafana.com/api/plugins/grafana-piechart-panel/versions/latest/download -O /tmp/grafana-piechart-panel.zip 23 | ``` 24 | 25 | Extract and move into place 26 | ```BASH 27 | unzip -q /tmp/grafana-piechart-panel.zip -d /tmp 28 | mv /tmp/grafana-piechart-panel-* /var/lib/grafana/plugins/grafana-piechart-panel 29 | sudo service grafana-server restart 30 | ``` 31 | 32 | ### Git Clone 33 | It is also possible to clone this repo directly into your plugins directory. 34 | 35 | Afterwards restart grafana-server and the plugin should be automatically detected and used. 36 | 37 | ``` 38 | git clone https://github.com/grafana/piechart-panel.git --branch release-1.3.8 39 | sudo service grafana-server restart 40 | ``` 41 | 42 | ### Clone into a directory of your choice 43 | 44 | If the plugin is cloned to a directory that is not the default plugins directory then you need to edit your grafana.ini config file (Default location is at /etc/grafana/grafana.ini) and add this: 45 | 46 | ```ini 47 | [plugin.piechart] 48 | path = /home/your/clone/dir/piechart-panel 49 | ``` 50 | 51 | Note that if you clone it into the grafana plugins directory you do not need to add the above config option. That is only 52 | if you want to place the plugin in a directory outside the standard plugins directory. Be aware that grafana-server 53 | needs read access to the directory. 54 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/dark.js: -------------------------------------------------------------------------------- 1 | define((function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=10)}({10:function(e,t,n){}})})); 2 | //# sourceMappingURL=dark.js.map -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-donut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-donut.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-on-graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-on-graph.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-rhs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-rhs.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-under.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-legend-under.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-options.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart-options.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_large.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_large.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_large.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_small.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/icebob/kantab/12e8ce2c614e697d078fb5ceb490e0030234ad4e/monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_small.png -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/img/piechart_logo_small.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/light.js: -------------------------------------------------------------------------------- 1 | define((function(){return function(e){var t={};function n(r){if(t[r])return t[r].exports;var o=t[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,n),o.l=!0,o.exports}return n.m=e,n.c=t,n.d=function(e,t,r){n.o(e,t)||Object.defineProperty(e,t,{enumerable:!0,get:r})},n.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},n.t=function(e,t){if(1&t&&(e=n(e)),8&t)return e;if(4&t&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(n.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&t&&"string"!=typeof e)for(var o in e)n.d(r,o,function(t){return e[t]}.bind(null,o));return r},n.n=function(e){var t=e&&e.__esModule?function(){return e.default}:function(){return e};return n.d(t,"a",t),t},n.o=function(e,t){return Object.prototype.hasOwnProperty.call(e,t)},n.p="/",n(n.s=9)}({9:function(e,t,n){}})})); 2 | //# sourceMappingURL=light.js.map -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/module.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |
5 |
6 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/module.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | * perfect-scrollbar v1.2.0 3 | * (c) 2017 Hyunje Jun 4 | * @license MIT 5 | */ 6 | 7 | /*! ***************************************************************************** 8 | Copyright (c) Microsoft Corporation. 9 | 10 | Permission to use, copy, modify, and/or distribute this software for any 11 | purpose with or without fee is hereby granted. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH 14 | REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY 15 | AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, 16 | INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM 17 | LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR 18 | OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR 19 | PERFORMANCE OF THIS SOFTWARE. 20 | ***************************************************************************** */ 21 | -------------------------------------------------------------------------------- /monitoring/grafana/plugins/grafana-piechart-panel/plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "panel", 3 | "name": "Pie Chart", 4 | "id": "grafana-piechart-panel", 5 | "info": { 6 | "description": "Pie chart panel for grafana", 7 | "author": { 8 | "name": "Grafana Labs", 9 | "url": "http://grafana.com" 10 | }, 11 | "keywords": [ 12 | "piechart", 13 | "panel" 14 | ], 15 | "logos": { 16 | "small": "img/piechart_logo_small.svg", 17 | "large": "img/piechart_logo_large.svg" 18 | }, 19 | "links": [ 20 | { 21 | "name": "Project site", 22 | "url": "https://github.com/grafana/piechart-panel" 23 | }, 24 | { 25 | "name": "Blog Post", 26 | "url": "https://blog.raintank.io/friends-dont-let-friends-abuse-pie-charts/" 27 | }, 28 | { 29 | "name": "MIT License", 30 | "url": "https://github.com/grafana/piechart-panel/blob/master/LICENSE" 31 | } 32 | ], 33 | "screenshots": [ 34 | { 35 | "name": "Donut!", 36 | "path": "img/piechart-donut.png" 37 | }, 38 | { 39 | "name": "Legend on the graph", 40 | "path": "img/piechart-legend-on-graph.png" 41 | }, 42 | { 43 | "name": "Legend to the right", 44 | "path": "img/piechart-legend-rhs.png" 45 | }, 46 | { 47 | "name": "Legend underneath", 48 | "path": "img/piechart-legend-under.png" 49 | }, 50 | { 51 | "name": "Piechart options", 52 | "path": "img/piechart-options.png" 53 | } 54 | ], 55 | "version": "1.6.1", 56 | "updated": "2020-09-16", 57 | "build": { 58 | "time": 1600286584067, 59 | "repo": "git@github.com:grafana/piechart-panel.git", 60 | "branch": "v1.6.x", 61 | "hash": "9cc257f74cbb133f6573805e372ed1d79f35896b", 62 | "number": 241 63 | } 64 | }, 65 | "dependencies": { 66 | "grafanaVersion": "4.6", 67 | "plugins": [] 68 | } 69 | } -------------------------------------------------------------------------------- /monitoring/grafana/provisioning/dashboards/dashboard.yml: -------------------------------------------------------------------------------- 1 | apiVersion: 1 2 | 3 | providers: 4 | - name: 'Prometheus' 5 | orgId: 1 6 | folder: '' 7 | type: file 8 | disableDeletion: false 9 | editable: true 10 | allowUiUpdates: true 11 | options: 12 | path: /etc/grafana/provisioning/dashboards -------------------------------------------------------------------------------- /monitoring/grafana/provisioning/datasources/datasource.yml: -------------------------------------------------------------------------------- 1 | # config file version 2 | apiVersion: 1 3 | 4 | # list of datasources that should be deleted from the database 5 | deleteDatasources: 6 | - name: Prometheus 7 | orgId: 1 8 | 9 | # list of datasources to insert/update depending 10 | # whats available in the database 11 | datasources: 12 | # name of the datasource. Required 13 | - name: Prometheus 14 | # datasource type. Required 15 | type: prometheus 16 | # access mode. direct or proxy. Required 17 | access: proxy 18 | # org id. will default to orgId 1 if not specified 19 | orgId: 1 20 | # url 21 | url: http://prometheus:9090 22 | # database password, if used 23 | password: 24 | # database user, if used 25 | user: 26 | # database name, if used 27 | database: 28 | # enable/disable basic auth 29 | basicAuth: false 30 | # basic auth username 31 | basicAuthUser: admin 32 | # basic auth password 33 | basicAuthPassword: foobar 34 | # enable/disable with credentials headers 35 | withCredentials: 36 | # mark as default datasource. Max one per org 37 | isDefault: 38 | # fields that will be converted to json and stored in json_data 39 | jsonData: 40 | graphiteVersion: "1.1" 41 | tlsAuth: false 42 | tlsAuthWithCACert: false 43 | # json object of data that will be encrypted. 44 | secureJsonData: 45 | tlsCACert: "..." 46 | tlsClientCert: "..." 47 | tlsClientKey: "..." 48 | version: 1 49 | # allow users to edit datasources from the UI. 50 | editable: true 51 | -------------------------------------------------------------------------------- /monitoring/prometheus/.gitignore: -------------------------------------------------------------------------------- 1 | targets.json 2 | -------------------------------------------------------------------------------- /monitoring/prometheus/alert.rules: -------------------------------------------------------------------------------- 1 | groups: 2 | - name: example 3 | rules: 4 | 5 | # Alert for any instance that is unreachable for >5 minutes. 6 | - alert: service_down 7 | expr: up == 0 8 | for: 2m 9 | labels: 10 | severity: page 11 | annotations: 12 | summary: "Instance {{ $labels.instance }} down" 13 | description: "{{ $labels.instance }} of job {{ $labels.job }} has been down for more than 2 minutes." 14 | 15 | - alert: high_load 16 | expr: node_load1 > 0.5 17 | for: 2m 18 | labels: 19 | severity: page 20 | annotations: 21 | summary: "Instance {{ $labels.instance }} under high load" 22 | description: "{{ $labels.instance }} of job {{ $labels.job }} is under high load." -------------------------------------------------------------------------------- /monitoring/prometheus/prometheus.yml: -------------------------------------------------------------------------------- 1 | # my global config 2 | global: 3 | scrape_interval: 15s # By default, scrape targets every 15 seconds. 4 | evaluation_interval: 15s # By default, scrape targets every 15 seconds. 5 | # scrape_timeout is set to the global default (10s). 6 | 7 | # Attach these labels to any time series or alerts when communicating with 8 | # external systems (federation, remote storage, Alertmanager). 9 | external_labels: 10 | monitor: 'moleculer' 11 | 12 | # Load and evaluate rules in this file every 'evaluation_interval' seconds. 13 | rule_files: 14 | - 'alert.rules' 15 | 16 | # alert 17 | alerting: 18 | alertmanagers: 19 | - scheme: http 20 | static_configs: 21 | - targets: 22 | - "alertmanager:9093" 23 | 24 | # A scrape configuration containing exactly one endpoint to scrape: 25 | # Here it's Prometheus itself. 26 | scrape_configs: 27 | # The job name is added as a label `job=` to any timeseries scraped from this config. 28 | 29 | # Prometheus internal exporter 30 | - job_name: 'prometheus' 31 | scrape_interval: 5s 32 | static_configs: 33 | - targets: ['localhost:9090'] 34 | 35 | # NATS 36 | - job_name: 'nats' 37 | static_configs: 38 | - targets: ['nats_exporter:7777'] 39 | 40 | # Redis 41 | - job_name: 'redis' 42 | static_configs: 43 | - targets: ['redis_exporter:9121'] 44 | 45 | # Mongo 46 | - job_name: 'mongo' 47 | static_configs: 48 | - targets: ['mongo_exporter:9216'] 49 | 50 | # Traefik 51 | - job_name: 'traefik' 52 | static_configs: 53 | - targets: ['traefik:8082'] 54 | 55 | # Moleculer exporter 56 | - job_name: 'moleculer' 57 | file_sd_configs: 58 | - files: 59 | - "/etc/prometheus/targets.json" 60 | refresh_interval: 5s 61 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | useTabs: true, 3 | printWidth: 100, 4 | trailingComma: "none", 5 | arrowParens: "avoid", 6 | tabWidth: 4, 7 | singleQuote: false, 8 | semi: true, 9 | bracketSpacing: true 10 | }; 11 | -------------------------------------------------------------------------------- /repl-commands/accounts.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const C = require("../backend/constants"); 4 | 5 | const _ = require("lodash"); 6 | const kleur = require("kleur"); 7 | 8 | function getStatusString(sta) { 9 | switch (sta) { 10 | case C.STATUS_ACTIVE: 11 | return kleur.green().bold("Active"); 12 | case C.STATUS_INACTIVE: 13 | return kleur.yellow().bold("Inactive"); 14 | case C.STATUS_DELETED: 15 | return kleur.red().bold("Deleted"); 16 | default: 17 | return sta; 18 | } 19 | } 20 | 21 | module.exports = { 22 | command: "accounts", 23 | description: "List KanTab accounts", 24 | alias: ["users", "u"], 25 | // options: [ 26 | // { 27 | // option: "-f, --filter ", 28 | // description: "filter aliases (e.g.: 'users')" 29 | // } 30 | // ], 31 | async action(broker, args, { table, kleur, getBorderCharacters }) { 32 | //const { options } = args; 33 | //console.log(options); 34 | const users = await broker.call( 35 | "v1.accounts.find", 36 | { sort: "username" }, 37 | { meta: { $repl: true } } 38 | ); 39 | 40 | const data = [ 41 | [ 42 | kleur.bold("ID"), 43 | kleur.bold("Username"), 44 | kleur.bold("Full name"), 45 | kleur.bold("E-mail"), 46 | kleur.bold("Roles"), 47 | kleur.bold("Verified"), 48 | kleur.bold("Status") 49 | ] 50 | ]; 51 | 52 | let hLines = []; 53 | 54 | users.forEach(user => { 55 | // if ( 56 | // args.options.filter && 57 | // !item.fullPath.toLowerCase().includes(args.options.filter.toLowerCase()) 58 | // ) 59 | // return; 60 | 61 | data.push([ 62 | user.id, 63 | user.username, 64 | user.fullName, 65 | user.email, 66 | user.roles, 67 | user.verified ? kleur.green().bold("✔") : kleur.yellow().bold("✖"), 68 | getStatusString(user.status) 69 | ]); 70 | }); 71 | 72 | const tableConf = { 73 | border: _.mapValues(getBorderCharacters("honeywell"), char => kleur.gray(char)), 74 | columns: { 75 | 5: { alignment: "center" }, 76 | 6: { alignment: "center" } 77 | }, 78 | drawHorizontalLine: (index, count) => 79 | index == 0 || index == 1 || index == count || hLines.indexOf(index) !== -1 80 | }; 81 | 82 | console.log(table(data, tableConf)); 83 | } 84 | }; 85 | -------------------------------------------------------------------------------- /repl-commands/boards.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const kleur = require("kleur"); 5 | 6 | function getStatusString(board) { 7 | if (board.deletedAt) return kleur.bgRed().white().bold("Deleted"); 8 | if (board.archived) return kleur.magenta().bold("Archived"); 9 | return kleur.green().bold("Active"); 10 | } 11 | 12 | module.exports = { 13 | command: "boards", 14 | description: "List KanTab boards", 15 | alias: ["b"], 16 | options: [ 17 | { 18 | option: "-u, --user ", 19 | description: "User ID" 20 | } 21 | ], 22 | async action(broker, args, { table, kleur, getBorderCharacters }) { 23 | const { options } = args; 24 | console.log(options); 25 | const boards = await broker.call( 26 | "v1.boards.find", 27 | { 28 | sort: "title", 29 | populate: ["owner", "members"], 30 | scope: options.user ? true : ["notDeleted"] 31 | }, 32 | { meta: { userID: options.user, $repl: true } } 33 | ); 34 | 35 | const data = [ 36 | [ 37 | kleur.bold("ID"), 38 | kleur.bold("Title"), 39 | kleur.bold("Owner"), 40 | kleur.bold("Members"), 41 | kleur.bold("Public"), 42 | kleur.bold("Status") 43 | ] 44 | ]; 45 | 46 | let hLines = []; 47 | 48 | boards.forEach(board => { 49 | data.push([ 50 | board.id, 51 | board.title, 52 | board.owner.fullName, 53 | board.members.map(member => member.fullName), 54 | board.public ? kleur.magenta().bold("YES") : "Private", 55 | getStatusString(board) 56 | ]); 57 | }); 58 | 59 | const tableConf = { 60 | border: _.mapValues(getBorderCharacters("honeywell"), char => kleur.gray(char)), 61 | columns: { 62 | 3: { alignment: "center" }, 63 | 4: { alignment: "center" } 64 | }, 65 | drawHorizontalLine: (index, count) => 66 | index == 0 || index == 1 || index == count || hLines.indexOf(index) !== -1 67 | }; 68 | 69 | console.log(table(data, tableConf)); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /repl-commands/create-board.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const Haikunator = require("haikunator"); 5 | const haikunator = new Haikunator(); 6 | 7 | module.exports = { 8 | command: "create-board", 9 | description: "Create a board", 10 | alias: ["cb"], 11 | options: [ 12 | { 13 | option: "-u, --user ", 14 | description: "User ID" 15 | }, 16 | { 17 | option: "-t, --title ", 18 | description: "Title of board" 19 | }, 20 | { 21 | option: "-l, --list ", 22 | description: "Number of lists" 23 | }, 24 | { 25 | option: "-c, --card ", 26 | description: "Number of cards" 27 | } 28 | ], 29 | async action(broker, args, { table, kleur, getBorderCharacters }) { 30 | const { options } = args; 31 | console.log(options); 32 | const board = await broker.call( 33 | "v1.boards.create", 34 | { 35 | title: options.title || haikunator.haikunate({ tokenLength: 3, delimiter: " " }), 36 | description: "This is a generated board" 37 | }, 38 | { meta: { userID: options.user, $repl: true } } 39 | ); 40 | 41 | const lists = await broker.Promise.mapSeries(_.times(options.list || 10), i => { 42 | return broker.call( 43 | "v1.lists.create", 44 | { 45 | title: "List " + (i + 1), 46 | position: i + 1, 47 | board: board.id 48 | }, 49 | { meta: { userID: options.user, $repl: true } } 50 | ); 51 | }); 52 | let cards = []; 53 | 54 | await Promise.mapSeries(lists, async (list, j) => { 55 | const res = await broker.Promise.mapSeries(_.times(options.list || 50), i => { 56 | return broker.call( 57 | "v1.cards.create", 58 | { 59 | title: `Card ${j + 1}-${i + 1}`, 60 | position: i + 1, 61 | list: list.id 62 | }, 63 | { meta: { userID: options.user, $repl: true } } 64 | ); 65 | }); 66 | cards.push(...res); 67 | }); 68 | 69 | console.log(board /*, lists, cards*/); 70 | } 71 | }; 72 | -------------------------------------------------------------------------------- /repl-commands/index.js: -------------------------------------------------------------------------------- 1 | const glob = require("glob").sync; 2 | 3 | const folder = "./repl-commands"; 4 | module.exports = glob("*.js", { cwd: folder, absolute: true }) 5 | .filter(filename => !filename.includes("index.js")) 6 | .map(filename => require(filename)); 7 | -------------------------------------------------------------------------------- /repl-commands/lists.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | const kleur = require("kleur"); 5 | 6 | function getStatusString(board) { 7 | if (board.deletedAt) return kleur.bgRed().white().bold("Deleted"); 8 | return kleur.green().bold("Active"); 9 | } 10 | 11 | module.exports = { 12 | command: "lists", 13 | description: "List KanTab board lists", 14 | alias: ["l"], 15 | options: [ 16 | { 17 | option: "-u, --user ", 18 | description: "User ID" 19 | }, 20 | { 21 | option: "-b, --board ", 22 | description: "Board ID" 23 | } 24 | ], 25 | async action(broker, args, { table, kleur, getBorderCharacters }) { 26 | const { options } = args; 27 | try { 28 | //console.log(options); 29 | const lists = await broker.call( 30 | "v1.lists.find", 31 | { 32 | sort: "title", 33 | populate: ["board"], 34 | scope: ["notDeleted"], 35 | board: options.board, 36 | query: { board: options.board } 37 | }, 38 | { meta: { userID: options.user, $repl: true } } 39 | ); 40 | 41 | const data = [ 42 | [kleur.bold("ID"), kleur.bold("Title"), kleur.bold("Board"), kleur.bold("Status")] 43 | ]; 44 | 45 | let hLines = []; 46 | 47 | lists.forEach(list => { 48 | data.push([list.id, list.title, list.board.title, getStatusString(list)]); 49 | }); 50 | 51 | const tableConf = { 52 | border: _.mapValues(getBorderCharacters("honeywell"), char => kleur.gray(char)), 53 | columns: { 54 | 3: { alignment: "center" } 55 | }, 56 | drawHorizontalLine: (index, count) => 57 | index == 0 || index == 1 || index == count || hLines.indexOf(index) !== -1 58 | }; 59 | 60 | console.log(table(data, tableConf)); 61 | } catch (err) { 62 | broker.logger.error(err); 63 | } 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /repl-commands/rest.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const _ = require("lodash"); 4 | 5 | module.exports = { 6 | command: "rest", 7 | description: "List REST API aliases", 8 | options: [ 9 | { 10 | option: "-f, --filter ", 11 | description: "filter aliases (e.g.: 'users')" 12 | } 13 | ], 14 | async action(broker, args, { table, kleur, getBorderCharacters }) { 15 | const { options } = args; 16 | //console.log(options); 17 | const aliases = await broker.call("api.listAliases"); 18 | 19 | const data = [[kleur.bold("Method"), kleur.bold("Path"), kleur.bold("Action")]]; 20 | 21 | let hLines = []; 22 | 23 | aliases.sort((a, b) => a.fullPath.localeCompare(b.fullPath)); 24 | 25 | let lastRoutePath; 26 | 27 | aliases.forEach(item => { 28 | if ( 29 | options.filter && 30 | !item.fullPath.toLowerCase().includes(options.filter.toLowerCase()) 31 | ) 32 | return; 33 | 34 | // Draw a separator line 35 | if (lastRoutePath && item.routePath != lastRoutePath) hLines.push(data.length); 36 | lastRoutePath = item.routePath; 37 | 38 | data.push([item.methods, item.fullPath, item.actionName ? item.actionName : "-"]); 39 | }); 40 | 41 | const tableConf = { 42 | border: _.mapValues(getBorderCharacters("honeywell"), char => kleur.gray(char)), 43 | columns: { 44 | 0: { alignment: "right" }, 45 | 1: { alignment: "left" } 46 | }, 47 | drawHorizontalLine: (index, count) => 48 | index == 0 || index == 1 || index == count || hLines.indexOf(index) !== -1 49 | }; 50 | 51 | console.log(table(data, tableConf)); 52 | } 53 | }; 54 | -------------------------------------------------------------------------------- /tests/e2e/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: [ 3 | "cypress" 4 | ], 5 | env: { 6 | mocha: true, 7 | "cypress/globals": true 8 | }, 9 | rules: { 10 | strict: "off" 11 | } 12 | }; 13 | -------------------------------------------------------------------------------- /tests/e2e/.gitignore: -------------------------------------------------------------------------------- 1 | videos/ 2 | screenshots/ 3 | -------------------------------------------------------------------------------- /tests/e2e/bootstrap.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const kleur = require("kleur"); 4 | 5 | module.exports = broker => { 6 | 7 | console.log(kleur.magenta().bold("Starting Cypress for End-to-End testing...")); 8 | 9 | // Redirect Cypress logs to Moleculer logger 10 | /* 11 | const logger = broker.getLogger("CYPRESS"); 12 | const stream = require("stream"); 13 | const logStream = new stream.Stream(); 14 | 15 | logStream.writable = true; 16 | logStream.write = data => logger.info(kleur.bgMagenta().bold().white("CYPRESS:"), data.toString("utf8").trim()); 17 | */ 18 | 19 | // Execute Cypress 20 | const execa = require("execa"); 21 | const args = []; 22 | if (process.env.TEST_E2E == "run") 23 | args.push("run"/*, "--record", "--key", "920a1001-30cb-4471-8d5d-066843b6a9a3"*/); 24 | else 25 | args.push("open"); 26 | const runner = execa(require.resolve("cypress/bin/cypress"), args, { stdin: "inherit", stdout: "inherit" }); 27 | //runner.stdout.pipe(logStream); 28 | 29 | //runner.on("exit", async () => await broker.stop()); 30 | runner.on("error", async () => await broker.stop()); 31 | runner.on("exit", async code => { 32 | console.log("Cypress exited", code); 33 | broker.stop(); 34 | process.exit(code); 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /tests/e2e/maildev.service.js: -------------------------------------------------------------------------------- 1 | const MailDev = require("maildev"); 2 | 3 | module.exports = { 4 | name: "maildev", 5 | 6 | settings: { 7 | rest: true 8 | }, 9 | 10 | actions: { 11 | getTokenFromMessage: { 12 | rest: "/getTokenFromMessage", 13 | // call maildev.getTokenFromMessage --recipients demo9@moleculer.services --pattern "passwordless\?token=(\w+)" 14 | // call maildev.getTokenFromMessage --recipients demo9@moleculer.services --pattern "verify-account\?token=(\w+)" 15 | // call maildev.getTokenFromMessage --recipients demo9@moleculer.services --pattern "reset-password\?token=(\w+)" 16 | params: { 17 | recipient: "string", 18 | subject: "string|optional", 19 | pattern: "string" 20 | }, 21 | async handler(ctx) { 22 | let emails = await ctx.call("maildev.getAllEmail"); 23 | 24 | // Filter by recipient 25 | emails = emails.filter(email => { 26 | return email.to.some(to => to.address == ctx.params.recipient); 27 | }); 28 | 29 | // Filter by subject 30 | if (ctx.params.subject) { 31 | emails = emails.filter(email => email.subject == ctx.params.subject); 32 | } 33 | 34 | if (emails.length == 0) return null; 35 | 36 | // Sort by time descendant 37 | emails.sort((a, b) => b.time - a.time); 38 | 39 | const content = emails[0].html; 40 | 41 | const re = new RegExp(ctx.params.pattern, "g"); 42 | 43 | const match = re.exec(content); 44 | return match && match.length > 1 ? match[1] : null; 45 | } 46 | }, 47 | 48 | getAllEmail: { 49 | rest: "GET /getAllEmail", 50 | params: { 51 | recipient: "string|optional", 52 | subject: "string|optional" 53 | }, 54 | handler(ctx) { 55 | return new Promise((resolve, reject) => { 56 | this.maildev.getAllEmail((err, store) => { 57 | if (err) reject(err); 58 | else resolve(store); 59 | }); 60 | }).then(emails => { 61 | // Filter by recipient 62 | if (ctx.params.recipient) { 63 | emails = emails.filter(email => { 64 | return email.to.some(to => to.address == ctx.params.recipient); 65 | }); 66 | } 67 | 68 | // Filter by subject 69 | if (ctx.params.subject) { 70 | emails = emails.filter(email => email.subject == ctx.params.subject); 71 | } 72 | return emails; 73 | }); 74 | } 75 | }, 76 | 77 | deleteAllEmail: { 78 | rest: "POST /deleteAllEmail", 79 | handler(ctx) { 80 | return new Promise((resolve, reject) => { 81 | this.maildev.deleteAllEmail(err => { 82 | if (err) reject(err); 83 | else resolve(); 84 | }); 85 | }); 86 | } 87 | } 88 | }, 89 | 90 | created() { 91 | this.maildev = new MailDev({ 92 | smtp: 1025, // incoming SMTP port - default is 1025 93 | disableWeb: true 94 | //mailDirectory: "./mails" 95 | }); 96 | }, 97 | 98 | async started() { 99 | await new Promise((resolve, reject) => { 100 | this.maildev.listen(err => { 101 | if (err) reject(err); 102 | else resolve(); 103 | }); 104 | }); 105 | 106 | this.maildev.on("new", email => { 107 | this.logger.info("Received new email with subject: %s", email.subject); 108 | }); 109 | }, 110 | 111 | stopped() { 112 | return new Promise((resolve, reject) => { 113 | this.maildev.close(err => { 114 | if (err) reject(err); 115 | else resolve(); 116 | }); 117 | }); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /tests/e2e/plugins/index.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/guides/guides/plugins-guide.html 2 | 3 | module.exports = (on, config) => Object.assign({}, config, { 4 | fixturesFolder: "tests/e2e/fixtures", 5 | integrationFolder: "tests/e2e/specs", 6 | screenshotsFolder: "tests/e2e/screenshots", 7 | videosFolder: "tests/e2e/videos", 8 | supportFile: "tests/e2e/support/index.js", 9 | }); 10 | -------------------------------------------------------------------------------- /tests/e2e/specs/accounts/login.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | describe("Test login page with username & password", () => { 4 | //beforeEach(() => cy.visit("/login")); 5 | 6 | const baseUrl = Cypress.config("baseUrl"); 7 | it("Check the login page", () => { 8 | cy.visit("/login"); 9 | cy.contains("h3", "Sign In"); 10 | }); 11 | 12 | it("Try to login with wrong username", () => { 13 | cy.login("unknow", "test"); 14 | cy.url().should("equal", `${baseUrl}/login`); 15 | cy.get(".alert.bg-negative").should("contain", "Account is not registered."); 16 | }); 17 | 18 | it("Try to login with wrong password", () => { 19 | cy.login("test", "wrongpass"); 20 | cy.url().should("equal", `${baseUrl}/login`); 21 | cy.get(".alert.bg-negative").should("contain", "Wrong password"); 22 | }); 23 | 24 | it("Login with correct data", () => { 25 | cy.login("test", "test"); 26 | cy.url().should("equal", `${baseUrl}/`); 27 | }); 28 | 29 | it("Logout", () => { 30 | cy.get("header #link-logout").click(); 31 | 32 | cy.url().should("contain", `${baseUrl}/login`); 33 | }); 34 | }); 35 | 36 | describe("Test login page with passwordless", () => { 37 | //beforeEach(() => cy.visit("/login")); 38 | 39 | const baseUrl = Cypress.config("baseUrl"); 40 | it("Login without password", () => { 41 | cy.login("test"); 42 | cy.url().should("equal", `${baseUrl}/login`); 43 | cy.get(".alert.bg-positive").should("contain", "Magic link has been sent to 'test@kantab.io'. Use it to sign in."); 44 | 45 | cy.wait(2000); 46 | cy.get(".alert.bg-positive").then(() => { 47 | 48 | cy.request("POST", `${baseUrl}/api/maildev/getTokenFromMessage`, { 49 | recipient: "test@kantab.io", 50 | pattern: "passwordless\\?token=(\\w+)" 51 | }).then(response => { 52 | expect(response.status).to.eq(200); 53 | expect(response.body).to.be.a("string"); 54 | const token = response.body; 55 | 56 | cy.visit(`/passwordless?token=${token}`); 57 | cy.url().should("equal", `${baseUrl}/`); 58 | cy.get('#add-board-button'); 59 | 60 | cy.request("POST", `${baseUrl}/api/maildev/deleteAllEmail`) 61 | }); 62 | }); 63 | }); 64 | }); 65 | -------------------------------------------------------------------------------- /tests/e2e/specs/accounts/reset-password.js: -------------------------------------------------------------------------------- 1 | // https://docs.cypress.io/api/introduction/api.html 2 | 3 | const mailtrap = require("../../util/mailtrap"); 4 | let fakerator = require("fakerator")(); 5 | 6 | const EMAIL_PAUSE = 3000; 7 | 8 | describe("Test forgot password flow", () => { 9 | 10 | const user = fakerator.entity.user(); 11 | user.fullName = user.firstName + " " + user.lastName; 12 | const baseUrl = Cypress.config("baseUrl"); 13 | 14 | it("Create a temp user", () => { 15 | cy.signup(user.fullName, user.email, user.userName); 16 | cy.get(".alert.bg-positive").should("contain", "Account created. Please activate now."); 17 | 18 | cy.wait(EMAIL_PAUSE); 19 | cy.get(".alert.bg-positive").then(() => { 20 | // Check token in sent email 21 | cy.request("POST", `${baseUrl}/api/maildev/getTokenFromMessage`, { 22 | recipient: user.email, 23 | pattern: "verify-account\\?token=(\\w+)" 24 | }).then(response => { 25 | expect(response.status).to.eq(200); 26 | expect(response.body).to.be.a("string"); 27 | const token = response.body; 28 | 29 | cy.visit(`/verify-account?token=${token}`); 30 | cy.url().should("equal", `${baseUrl}/`); 31 | cy.get('#add-board-button'); 32 | 33 | cy.request("POST", `${baseUrl}/api/maildev/deleteAllEmail`) 34 | }); 35 | }); 36 | 37 | cy.logout(); 38 | }); 39 | 40 | it("Check the forgot page", () => { 41 | cy.visit("/forgot-password"); 42 | cy.contains("h3", "Forgot Password"); 43 | }); 44 | 45 | it("Try with wrong email", () => { 46 | cy.forgotPassword("chuck.norris@notfound.me"); 47 | 48 | cy.url().should("equal", `${baseUrl}/forgot-password`); 49 | cy.get(".alert.bg-negative").should("contain", "Account is not registered."); 50 | }); 51 | 52 | it("Try with correct email", () => { 53 | cy.forgotPassword(user.email); 54 | 55 | cy.url().should("equal", `${baseUrl}/forgot-password`); 56 | cy.get(".alert.bg-positive").should("contain", "E-mail sent."); 57 | 58 | cy.wait(EMAIL_PAUSE); 59 | cy.get(".alert.bg-positive").then(() => { 60 | // Check token in sent email 61 | cy.request("POST", `${baseUrl}/api/maildev/getTokenFromMessage`, { 62 | recipient: user.email, 63 | pattern: "reset-password\\?token=(\\w+)" 64 | }).then(response => { 65 | expect(response.status).to.eq(200); 66 | expect(response.body).to.be.a("string"); 67 | const token = response.body; 68 | 69 | cy.resetPassword(token, "newpassword"); 70 | cy.url().should("equal", `${baseUrl}/`); 71 | cy.get('#add-board-button'); 72 | 73 | cy.request("POST", `${baseUrl}/api/maildev/deleteAllEmail`) 74 | }); 75 | 76 | cy.logout(); 77 | }); 78 | }); 79 | 80 | it("Try login with old password", () => { 81 | cy.login(user.email, user.password); 82 | cy.url().should("equal", `${baseUrl}/login`); 83 | cy.get(".alert.bg-negative").should("contain", "Wrong password."); 84 | }); 85 | 86 | it("Login with new password", () => { 87 | cy.login(user.email, "newpassword"); 88 | cy.url().should("equal", `${baseUrl}/`); 89 | cy.get('#add-board-button'); 90 | cy.logout(); 91 | }); 92 | }); 93 | -------------------------------------------------------------------------------- /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("login", (email, password) => { 28 | cy.visit("/login"); 29 | 30 | if (email) 31 | cy.get("input[name='email']").clear().type(email); 32 | else 33 | cy.get(".input[name='email']").clear(); 34 | 35 | if (password) 36 | cy.get("input[name='password']").clear().type(password); 37 | else 38 | cy.get("input[name='password']").clear(); 39 | 40 | cy.get("button[type=submit]").click(); 41 | }); 42 | 43 | Cypress.Commands.add("logout", () => { 44 | cy.get("header #link-logout").click(); 45 | 46 | cy.url().should("contain", `${Cypress.config("baseUrl")}/login`); 47 | }); 48 | 49 | Cypress.Commands.add("signup", (fullName, email, username, password) => { 50 | cy.visit("/signup"); 51 | 52 | if (fullName) 53 | cy.get("input[name='fullName']").clear().type(fullName); 54 | else 55 | cy.get("input[name='fullName']").clear(); 56 | 57 | if (email) 58 | cy.get("input[name='email']").clear().type(email); 59 | else 60 | cy.get("input[name='email']").clear(); 61 | 62 | if (username) 63 | cy.get("input[name='username']").clear().type(username); 64 | else 65 | cy.get("input[name='username']").clear(); 66 | 67 | if (password) 68 | cy.get("input[name='password']").clear().type(password); 69 | else 70 | cy.get("input[name='password']").clear(); 71 | 72 | cy.get("button[type=submit]").click(); 73 | }); 74 | 75 | Cypress.Commands.add("forgotPassword", (email) => { 76 | cy.visit("/forgot-password"); 77 | 78 | if (email) 79 | cy.get("input[name='email']").clear().type(email); 80 | else 81 | cy.get(".input[name='email']").clear(); 82 | 83 | cy.get("button[type=submit]").click(); 84 | }); 85 | 86 | Cypress.Commands.add("resetPassword", (token, password) => { 87 | cy.visit(`/reset-password?token=${token}`); 88 | 89 | if (password) 90 | cy.get("input[name='password']").clear().type(password); 91 | else 92 | cy.get("input[name='password']").clear(); 93 | 94 | cy.get("button[type=submit]").click(); 95 | }); 96 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /tests/e2e/util/mailtrap.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | const axios = require("axios").create({ 4 | baseURL: "https://mailtrap.io/api/v1/", 5 | headers: { 6 | "Content-Type": "application/json", 7 | "Api-Token": Cypress.env("MAILTRAP_API") 8 | } 9 | }); 10 | 11 | function getMessages(inboxID, email) { 12 | inboxID = inboxID || Cypress.env("MAILTRAP_INBOX"); 13 | 14 | return axios.get(`/inboxes/${inboxID}/messages`).then(res => { 15 | return res.data.filter(msg => email == null || msg.to_email == email); 16 | }); 17 | } 18 | 19 | function getTokenFromMessage(inboxID, email, re) { 20 | inboxID = inboxID || Cypress.env("MAILTRAP_INBOX"); 21 | 22 | return getMessages(null, email).then(messages => { 23 | if (messages.length < 1) 24 | throw new Error("Passwordless email not received!"); 25 | 26 | const msg = messages[0]; 27 | 28 | // Get the last email body 29 | return axios.get(`/inboxes/${inboxID}/messages/${msg.id}/body.html`).then(({ data }) => { 30 | const match = re.exec(data); 31 | if (!match) 32 | throw new Error("Token missing from email! " + data); 33 | 34 | console.log(match, data); 35 | return { token: match[1], messageID: msg.id }; 36 | }); 37 | }).catch(err => { 38 | console.log(err); 39 | throw err; 40 | }); 41 | } 42 | 43 | function deleteMessage(inboxID, messageID) { 44 | inboxID = inboxID || Cypress.env("MAILTRAP_INBOX"); 45 | 46 | return axios.delete(`/inboxes/${inboxID}/messages/${messageID}`); 47 | } 48 | 49 | function cleanInbox(inboxID) { 50 | inboxID = inboxID || Cypress.env("MAILTRAP_INBOX"); 51 | 52 | return axios.patch(`/inboxes/${inboxID}/clean`); 53 | } 54 | 55 | /* 56 | (async function() { 57 | try { 58 | //console.log(await getMessages(null, "test@kantab.io")); 59 | //console.log(await getTokenFromMessage(null, "test@kantab.io", /passwordless\?token=(\w+)/g)); 60 | //console.log(await deleteMessage(null, "936046392")); 61 | console.log(await cleanInbox()); 62 | } catch(err) { 63 | console.error(err); 64 | } 65 | })(); 66 | */ 67 | 68 | module.exports = { 69 | getMessages, 70 | getTokenFromMessage, 71 | cleanInbox, 72 | deleteMessage 73 | }; 74 | --------------------------------------------------------------------------------