├── client ├── Procfile ├── assets │ ├── styles │ │ ├── main.scss │ │ ├── base │ │ │ └── _ui.scss │ │ └── tailwind.pcss │ └── README.md ├── .prettierrc ├── config │ └── example │ │ ├── test.env.example │ │ └── development.env.example ├── plugins │ ├── vue-moment.js │ ├── vuelidate.js │ └── README.md ├── static │ ├── favicon.ico │ └── README.md ├── entrypoint.sh ├── pages │ ├── index.vue │ ├── README.md │ ├── users │ │ ├── sign_in.vue │ │ ├── sign_up.vue │ │ └── profile.vue │ └── stories │ │ └── index.vue ├── dev.Dockerfile ├── middleware │ ├── check-auth.js │ ├── check-auth-for-sign-in.js │ ├── README.md │ └── setup-auth.js ├── components │ ├── README.md │ ├── Logo.vue │ ├── Header.vue │ ├── StoriesList.vue │ └── AuthForm.vue ├── tailwind.config.js ├── .editorconfig ├── test │ └── Logo.spec.js ├── .babelrc ├── layouts │ ├── README.md │ └── default.vue ├── store │ ├── README.md │ └── users.js ├── README.md ├── jest.config.js ├── queries │ ├── stories.js │ └── users.js ├── .eslintrc.js ├── .gitignore ├── utils │ └── index.js ├── package.json └── nuxt.config.js ├── server ├── src │ ├── app │ │ ├── graphql │ │ │ ├── scalar │ │ │ │ ├── schema.gql │ │ │ │ └── resolver.js │ │ │ ├── story │ │ │ │ ├── permissions.js │ │ │ │ ├── schema.gql │ │ │ │ └── resolver.js │ │ │ └── user │ │ │ │ ├── permissions.js │ │ │ │ ├── schema.gql │ │ │ │ └── resolver.js │ │ ├── services │ │ │ ├── Story │ │ │ │ ├── createStory.js │ │ │ │ ├── deleteStory.js │ │ │ │ └── updateStory.js │ │ │ └── User │ │ │ │ ├── createUser.js │ │ │ │ ├── updateUser.js │ │ │ │ └── signinUser.js │ │ ├── errors │ │ │ ├── ApplicationError.js │ │ │ └── models │ │ │ │ ├── StoryErrors.js │ │ │ │ └── UserErrors.js │ │ ├── models │ │ │ ├── Story │ │ │ │ ├── Validation.js │ │ │ │ └── Model.js │ │ │ ├── BaseModel.js │ │ │ └── User │ │ │ │ ├── Model.js │ │ │ │ └── Validation.js │ │ └── auth │ │ │ ├── rules.js │ │ │ ├── permissions.js │ │ │ └── jwt.js │ ├── config │ │ └── example │ │ │ ├── test.env.example │ │ │ └── development.env.example │ ├── db │ │ ├── index.js │ │ ├── migrations │ │ │ ├── 20190318100243_create_stories.js │ │ │ └── 20190317232951_create_users.js │ │ ├── seeds │ │ │ └── users.js │ │ ├── config.js │ │ └── tasks.js │ ├── index.js │ └── server.js ├── .sgcrc ├── entrypoint.sh ├── dev.Dockerfile ├── .babelrc ├── tests │ ├── jest │ │ ├── globalTeardown.js │ │ └── globalSetup.js │ ├── utils │ │ └── getClient.js │ ├── factories │ │ ├── StoryFactory.js │ │ └── UserFactory.js │ └── graphql │ │ ├── story │ │ ├── getStories.test.js │ │ ├── createStory.test.js │ │ ├── deleteStory.test.js │ │ └── updateStory.test.js │ │ └── user │ │ ├── getCurrentUser.test.js │ │ ├── getUsers.test.js │ │ ├── signinUser.test.js │ │ ├── updateUser.test.js │ │ └── createUser.test.js ├── .eslintrc.js ├── .gitignore └── package.json ├── .sgcrc ├── screenshot.png ├── .vscode ├── launch.json └── settings.json ├── .circleci ├── setup-heroku.sh └── config.yml ├── package.json ├── .gitignore ├── docker-compose.yml └── README.md /client/Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start 2 | -------------------------------------------------------------------------------- /client/assets/styles/main.scss: -------------------------------------------------------------------------------- 1 | @import "base/ui" 2 | -------------------------------------------------------------------------------- /server/src/app/graphql/scalar/schema.gql: -------------------------------------------------------------------------------- 1 | scalar DateTime 2 | -------------------------------------------------------------------------------- /.sgcrc: -------------------------------------------------------------------------------- 1 | { 2 | "scope": true, 3 | "lowercaseTypes": true 4 | } 5 | -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "singleQuote": true 4 | } 5 | -------------------------------------------------------------------------------- /server/.sgcrc: -------------------------------------------------------------------------------- 1 | { 2 | "scope": true, 3 | "lowercaseTypes": true 4 | } 5 | -------------------------------------------------------------------------------- /client/config/example/test.env.example: -------------------------------------------------------------------------------- 1 | API_URL=http://server.graphql-auth.local:4000 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergey-pt/graphql-auth/HEAD/screenshot.png -------------------------------------------------------------------------------- /client/config/example/development.env.example: -------------------------------------------------------------------------------- 1 | API_URL=http://server.graphql-auth.local:4000 2 | -------------------------------------------------------------------------------- /client/plugins/vue-moment.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | 3 | Vue.use(require('vue-moment')) 4 | -------------------------------------------------------------------------------- /client/static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sergey-pt/graphql-auth/HEAD/client/static/favicon.ico -------------------------------------------------------------------------------- /client/assets/styles/base/_ui.scss: -------------------------------------------------------------------------------- 1 | hr { 2 | border: 0; 3 | height: 1px; 4 | background: #edf2f7; 5 | } 6 | -------------------------------------------------------------------------------- /client/plugins/vuelidate.js: -------------------------------------------------------------------------------- 1 | import Vue from 'vue' 2 | import Vuelidate from 'vuelidate' 3 | 4 | Vue.use(Vuelidate) 5 | -------------------------------------------------------------------------------- /client/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f config/$NODE_ENV.env ]; then 4 | cp config/example/$NODE_ENV.env.example config/$NODE_ENV.env 5 | fi 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /server/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | if [ ! -f src/config/$NODE_ENV.env ]; then 4 | cp src/config/example/$NODE_ENV.env.example src/config/$NODE_ENV.env 5 | fi 6 | 7 | exec "$@" 8 | -------------------------------------------------------------------------------- /client/pages/index.vue: -------------------------------------------------------------------------------- 1 | 4 | 5 | 12 | -------------------------------------------------------------------------------- /client/dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11-slim 2 | 3 | ENV APP_NAME /graphql-auth-client 4 | RUN mkdir /$APP_NAME 5 | WORKDIR /$APP_NAME 6 | 7 | COPY package.json /$APP_NAME 8 | RUN npm install 9 | 10 | COPY . /$APP_NAME 11 | -------------------------------------------------------------------------------- /client/middleware/check-auth.js: -------------------------------------------------------------------------------- 1 | export default function({ store, req, redirect }) { 2 | if (process.server && !req) return 3 | 4 | if (!store.getters['users/isAuthenticated']) { 5 | return redirect('/') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /server/dev.Dockerfile: -------------------------------------------------------------------------------- 1 | FROM node:11-slim 2 | 3 | ENV APP_NAME /graphql-auth-server 4 | RUN mkdir /$APP_NAME 5 | WORKDIR /$APP_NAME 6 | 7 | COPY package.json /$APP_NAME 8 | RUN npm install 9 | 10 | COPY . /$APP_NAME 11 | -------------------------------------------------------------------------------- /client/middleware/check-auth-for-sign-in.js: -------------------------------------------------------------------------------- 1 | export default function({ store, req, redirect }) { 2 | if (process.server && !req) return 3 | 4 | if (store.getters['users/isAuthenticated']) { 5 | return redirect('/') 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /client/components/README.md: -------------------------------------------------------------------------------- 1 | # COMPONENTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | The components directory contains your Vue.js Components. 6 | 7 | _Nuxt.js doesn't supercharge these components._ 8 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | prefix: '', 3 | important: false, 4 | separator: ':', 5 | theme: { 6 | container: { 7 | center: true, 8 | padding: '2rem', 9 | }, 10 | }, 11 | variants: {}, 12 | plugins: [] 13 | } 14 | -------------------------------------------------------------------------------- /client/.editorconfig: -------------------------------------------------------------------------------- 1 | # editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | 12 | [*.md] 13 | trim_trailing_whitespace = false 14 | -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "0.2.0", 3 | "configurations": [ 4 | { 5 | "type": "node", 6 | "request": "attach", 7 | "name": "Node.js 6+", 8 | "address": "localhost", 9 | "port": 9229, 10 | "stopOnEntry": false 11 | } 12 | ] 13 | } 14 | -------------------------------------------------------------------------------- /client/test/Logo.spec.js: -------------------------------------------------------------------------------- 1 | import { mount } from '@vue/test-utils' 2 | import Logo from '@/components/Logo.vue' 3 | 4 | describe('Logo', () => { 5 | test('is a Vue instance', () => { 6 | const wrapper = mount(Logo) 7 | expect(wrapper.isVueInstance()).toBeTruthy() 8 | }) 9 | }) 10 | -------------------------------------------------------------------------------- /client/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "test": { 4 | "presets": [ 5 | [ 6 | "@babel/preset-env", 7 | { 8 | "targets": { 9 | "node": "current" 10 | } 11 | } 12 | ] 13 | ] 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/layouts/README.md: -------------------------------------------------------------------------------- 1 | # LAYOUTS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Application Layouts. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/views#layouts). 8 | -------------------------------------------------------------------------------- /server/src/app/services/Story/createStory.js: -------------------------------------------------------------------------------- 1 | import { 2 | Story 3 | } from '~/src/app/models/Story/Model' 4 | 5 | export default async ({ data }, ctx) => { 6 | const userId = ctx.currentUser.id 7 | 8 | return await Story.query().insert({ 9 | userId, 10 | ...data 11 | }).returning('*') 12 | } 13 | -------------------------------------------------------------------------------- /client/pages/README.md: -------------------------------------------------------------------------------- 1 | # PAGES 2 | 3 | This directory contains your Application Views and Routes. 4 | The framework reads all the `*.vue` files inside this directory and creates the router of your application. 5 | 6 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing). 7 | -------------------------------------------------------------------------------- /server/src/config/example/test.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=postgres 2 | DATABASE_PORT=5432 3 | DATABASE_NAME=graphql-auth-test 4 | DATABASE_USER=postgres 5 | PORT=5000 6 | LOG_ENABLED=false 7 | JWT_SECRET=aed1139c4234e38df2d1941d6f3bf8ae833eda23e6fe73ba055859b4ee5bfcfb1892abfb1cbbecd1c6387a59c7103b4b450846ce972026828b9c38fa3fd3003b 8 | -------------------------------------------------------------------------------- /client/assets/README.md: -------------------------------------------------------------------------------- 1 | # ASSETS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your un-compiled assets such as LESS, SASS, or JavaScript. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#webpacked). 8 | -------------------------------------------------------------------------------- /server/src/config/example/development.env.example: -------------------------------------------------------------------------------- 1 | DATABASE_HOST=postgres 2 | DATABASE_PORT=5432 3 | DATABASE_NAME=graphql-auth-development 4 | DATABASE_USER=postgres 5 | PORT=4000 6 | NODE_ENV=development 7 | JWT_SECRET=df3becc077da26cdb4859a00cca47a827ef271226ac751edc2ae68267bf721d8ae034dcff5f732fd7386f097e02ae9688cc438de5a8b9c1c4d5aea69b9788d84 8 | -------------------------------------------------------------------------------- /server/src/db/index.js: -------------------------------------------------------------------------------- 1 | import Knex from 'knex' 2 | import dbConfig from '~/src/db/config.js' 3 | import { 4 | Model 5 | } from 'objection' 6 | 7 | const environment = process.env.NODE_ENV || 'development' 8 | 9 | const knex = Knex(dbConfig[environment]) 10 | 11 | Model.knex(knex) 12 | 13 | export { 14 | knex, 15 | Model 16 | } 17 | -------------------------------------------------------------------------------- /client/pages/users/sign_in.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /server/src/app/graphql/story/permissions.js: -------------------------------------------------------------------------------- 1 | import { 2 | isAuthenticated 3 | } from '~/src/app/auth/rules' 4 | 5 | const storiesPermissions = { 6 | Mutation: { 7 | createStory: isAuthenticated, 8 | updateStory: isAuthenticated, 9 | deleteStory: isAuthenticated 10 | } 11 | } 12 | 13 | export { 14 | storiesPermissions 15 | } 16 | -------------------------------------------------------------------------------- /client/plugins/README.md: -------------------------------------------------------------------------------- 1 | # PLUGINS 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains Javascript plugins that you want to run before mounting the root Vue.js application. 6 | 7 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/plugins). 8 | -------------------------------------------------------------------------------- /client/pages/users/sign_up.vue: -------------------------------------------------------------------------------- 1 | 6 | 7 | 18 | -------------------------------------------------------------------------------- /.circleci/setup-heroku.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | ssh-keyscan -H heroku.com >> ~/.ssh/known_hosts 3 | 4 | wget -qO- https://cli-assets.heroku.com/install-ubuntu.sh | sh 5 | 6 | cat > ~/.netrc << EOF 7 | machine api.heroku.com 8 | login $HEROKU_LOGIN 9 | password $HEROKU_API_KEY 10 | EOF 11 | 12 | cat >> ~/.ssh/config << EOF 13 | VerifyHostKeyDNS yes 14 | StrictHostKeyChecking no 15 | EOF 16 | -------------------------------------------------------------------------------- /server/src/app/graphql/user/permissions.js: -------------------------------------------------------------------------------- 1 | import { 2 | isAuthenticated, 3 | isCurrentUser 4 | } from '~/src/app/auth/rules' 5 | 6 | const usersPermissions = { 7 | Query: { 8 | getCurrentUser: isAuthenticated 9 | }, 10 | Mutation: { 11 | updateUser: isAuthenticated 12 | }, 13 | User: { 14 | email: isCurrentUser 15 | } 16 | } 17 | 18 | export { 19 | usersPermissions 20 | } 21 | -------------------------------------------------------------------------------- /client/middleware/README.md: -------------------------------------------------------------------------------- 1 | # MIDDLEWARE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your application middleware. 6 | Middleware let you define custom functions that can be run before rendering either a page or a group of pages. 7 | 8 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/routing#middleware). 9 | -------------------------------------------------------------------------------- /server/src/app/services/User/createUser.js: -------------------------------------------------------------------------------- 1 | import { 2 | User 3 | } from '~/src/app/models/User/Model' 4 | 5 | import { 6 | generateToken 7 | } from '~/src/app/auth/jwt' 8 | 9 | export default async ({ data }, ctx) => { 10 | const user = await User.query().insert({ 11 | ...data 12 | }) 13 | 14 | ctx.currentUser = user 15 | 16 | return { 17 | user, 18 | token: generateToken(user.id) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /server/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "node": "current" 8 | } 9 | } 10 | ] 11 | ], 12 | "plugins": [ 13 | ["babel-plugin-root-import"], 14 | ["@babel/plugin-proposal-class-properties"], 15 | ["@babel/plugin-proposal-optional-chaining"] 16 | ], 17 | "retainLines": true, 18 | "sourceMaps": true 19 | } 20 | -------------------------------------------------------------------------------- /client/store/README.md: -------------------------------------------------------------------------------- 1 | # STORE 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your Vuex Store files. 6 | Vuex Store option is implemented in the Nuxt.js framework. 7 | 8 | Creating a file in this directory automatically activates the option in the framework. 9 | 10 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/vuex-store). 11 | -------------------------------------------------------------------------------- /server/src/app/graphql/scalar/resolver.js: -------------------------------------------------------------------------------- 1 | import { 2 | GraphQLScalarType 3 | } from 'graphql' 4 | 5 | const resolver = { 6 | DateTime: new GraphQLScalarType({ 7 | name: 'DateTime', 8 | description: 'A date and time, represented as an ISO-8601 string', 9 | serialize: (value) => value.toISOString(), 10 | parseValue: (value) => new Date(value), 11 | parseLiteral: (ast) => new Date(ast.value) 12 | }) 13 | } 14 | 15 | export { 16 | resolver 17 | } 18 | -------------------------------------------------------------------------------- /server/tests/jest/globalTeardown.js: -------------------------------------------------------------------------------- 1 | const knexMigrate = require('knex-migrate') 2 | 3 | const log = ({ 4 | action, 5 | migration 6 | }) => { 7 | console.log('Doing ' + action + ' on ' + migration) 8 | } 9 | 10 | module.exports = async () => { 11 | console.log('\n') 12 | await knexMigrate('down', { 13 | to: 0, 14 | knexfile: 'src/db/config.js' 15 | }, log) 16 | 17 | await global.apollo.server.close() 18 | console.log('\n🏁 Server closed') 19 | } 20 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # client 2 | 3 | > My gnarly Nuxt.js project 4 | 5 | ## Build Setup 6 | 7 | ``` bash 8 | # install dependencies 9 | $ npm install 10 | 11 | # serve with hot reload at localhost:3000 12 | $ npm run dev 13 | 14 | # build for production and launch server 15 | $ npm run build 16 | $ npm start 17 | 18 | # generate static project 19 | $ npm run generate 20 | ``` 21 | 22 | For detailed explanation on how things work, checkout [Nuxt.js docs](https://nuxtjs.org). 23 | -------------------------------------------------------------------------------- /server/tests/utils/getClient.js: -------------------------------------------------------------------------------- 1 | import ApolloBoost from 'apollo-boost' 2 | 3 | const getClient = (jwt) => { 4 | return new ApolloBoost({ 5 | uri: `http://localhost:${process.env.PORT}`, 6 | request(operation) { 7 | if (jwt) { 8 | operation.setContext({ 9 | headers: { 10 | Authorization: `Bearer ${jwt}` 11 | } 12 | }) 13 | } 14 | } 15 | }) 16 | } 17 | 18 | export { 19 | getClient as 20 | default 21 | } 22 | -------------------------------------------------------------------------------- /client/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | moduleNameMapper: { 3 | '^@/(.*)$': '/$1', 4 | '^~/(.*)$': '/$1', 5 | '^vue$': 'vue/dist/vue.common.js' 6 | }, 7 | moduleFileExtensions: ['js', 'vue', 'json'], 8 | transform: { 9 | '^.+\\.js$': 'babel-jest', 10 | '.*\\.(vue)$': 'vue-jest' 11 | }, 12 | collectCoverage: true, 13 | collectCoverageFrom: [ 14 | '/components/**/*.vue', 15 | '/pages/**/*.vue' 16 | ] 17 | } 18 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.validate": [{ 3 | "language": "vue", 4 | "autoFix": true 5 | }, 6 | { 7 | "language": "javascript", 8 | "autoFix": true 9 | }, 10 | { 11 | "language": "javascriptreact", 12 | "autoFix": true 13 | } 14 | ], 15 | "eslint.autoFixOnSave": true, 16 | "editor.formatOnSave": false, 17 | "editor.formatOnPaste": false, 18 | "javascript.format.enable": false, 19 | "vetur.validation.template": false 20 | } 21 | -------------------------------------------------------------------------------- /client/layouts/default.vue: -------------------------------------------------------------------------------- 1 | 12 | 13 | 22 | -------------------------------------------------------------------------------- /client/static/README.md: -------------------------------------------------------------------------------- 1 | # STATIC 2 | 3 | **This directory is not required, you can delete it if you don't want to use it.** 4 | 5 | This directory contains your static files. 6 | Each file inside this directory is mapped to `/`. 7 | Thus you'd want to delete this README.md before deploying to production. 8 | 9 | Example: `/static/robots.txt` is mapped as `/robots.txt`. 10 | 11 | More information about the usage of this directory in [the documentation](https://nuxtjs.org/guide/assets#static). 12 | -------------------------------------------------------------------------------- /server/src/db/migrations/20190318100243_create_stories.js: -------------------------------------------------------------------------------- 1 | const up = (knex) => { 2 | return knex.schema.createTable('stories', (t) => { 3 | t.increments().primary().unique() 4 | t.string('uuid').notNull().unique() 5 | t.string('title').notNull() 6 | t.integer('userId').unsigned().references('users.id').notNull() 7 | t.timestamps() 8 | }) 9 | } 10 | 11 | const down = (knex) => { 12 | return knex.schema.dropTable('stories') 13 | } 14 | 15 | export { 16 | up, 17 | down 18 | } 19 | -------------------------------------------------------------------------------- /server/src/db/migrations/20190317232951_create_users.js: -------------------------------------------------------------------------------- 1 | const up = (knex) => { 2 | return knex.schema.createTable('users', (t) => { 3 | t.increments().primary().unique() 4 | t.string('uuid').notNull().unique() 5 | t.string('email').notNull().unique() 6 | t.string('username').notNull().unique() 7 | t.string('password').notNull() 8 | t.timestamps() 9 | }) 10 | } 11 | 12 | const down = (knex) => { 13 | return knex.schema.dropTable('users') 14 | } 15 | 16 | export { 17 | up, 18 | down 19 | } 20 | -------------------------------------------------------------------------------- /server/tests/factories/StoryFactory.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import { 3 | factory 4 | } from 'factory-girl' 5 | import ObjectionAdapter from 'factory-girl-objection-adapter' 6 | 7 | import { 8 | Story 9 | } from '~/src/app/models/Story/Model' 10 | 11 | factory.setAdapter(new ObjectionAdapter()) 12 | 13 | factory.define('story', Story, { 14 | title: factory.sequence('Story.title', () => faker.lorem.sentence()), 15 | userId: factory.sequence('Story.userId', (n) => n), 16 | }) 17 | 18 | export default factory 19 | -------------------------------------------------------------------------------- /server/src/app/errors/ApplicationError.js: -------------------------------------------------------------------------------- 1 | import { 2 | ApolloError 3 | } from 'apollo-server' 4 | 5 | class ApplicationError extends ApolloError { 6 | constructor({ 7 | message, 8 | code, 9 | data 10 | }) { 11 | const errorMessage = message || 'Something went wrong. Please try again.' 12 | const errorCode = code || 'INTERNAL_SERVER_ERROR' 13 | const errorData = { 14 | data 15 | } || {} 16 | 17 | super(errorMessage, errorCode, errorData) 18 | } 19 | } 20 | 21 | export default ApplicationError 22 | -------------------------------------------------------------------------------- /server/src/app/models/Story/Validation.js: -------------------------------------------------------------------------------- 1 | import { 2 | User 3 | } from '~/src/app/models/User/Model' 4 | 5 | const storyValidate = async ({ instance }) => { 6 | let errors = [] 7 | 8 | const user = await User 9 | .query() 10 | .skipUndefined() 11 | .where('id', instance.userId) 12 | .first() 13 | 14 | if (!user) { 15 | errors.push({ 16 | key: 'userId', 17 | keyword: 'notFound', 18 | message: 'Story should belongs to existing user' 19 | }) 20 | } 21 | 22 | return errors 23 | } 24 | 25 | export default storyValidate 26 | -------------------------------------------------------------------------------- /server/src/app/models/BaseModel.js: -------------------------------------------------------------------------------- 1 | const guid = require('objection-guid')({ 2 | field: 'uuid' 3 | }) 4 | 5 | import { 6 | Model 7 | } from '~/src/db/index' 8 | 9 | class BaseModel extends guid(Model) { 10 | async $beforeInsert(context) { 11 | await super.$beforeInsert(context) 12 | this.created_at = new Date().toISOString() 13 | this.updated_at = new Date().toISOString() 14 | } 15 | 16 | async $beforeUpdate(options, context) { 17 | await super.$beforeUpdate(options, context) 18 | this.updated_at = new Date().toISOString() 19 | } 20 | } 21 | 22 | export { 23 | BaseModel 24 | } 25 | -------------------------------------------------------------------------------- /server/src/app/services/User/updateUser.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | import { 4 | User 5 | } from '~/src/app/models/User/Model' 6 | 7 | import { 8 | UserNotFoundError 9 | } from '~/src/app/errors/models/UserErrors' 10 | 11 | export default async ({ 12 | data, 13 | ctx 14 | }) => { 15 | const user = await User 16 | .query() 17 | .skipUndefined() 18 | .where('id', _.get(ctx, 'currentUser.id', undefined)) 19 | .first() 20 | 21 | if (!user) { 22 | throw new UserNotFoundError({}) 23 | } 24 | 25 | return await user.$query().updateAndFetch({ 26 | ...data 27 | }) 28 | } 29 | -------------------------------------------------------------------------------- /server/src/index.js: -------------------------------------------------------------------------------- 1 | import '@babel/polyfill/noConflict' 2 | import server from '~/src/server' 3 | 4 | const environment = process.env.NODE_ENV || 'development' 5 | 6 | require('dotenv').config({ 7 | path: __dirname + `/config/${environment}.env` 8 | }) 9 | 10 | const port = process.env.PORT || '4000' 11 | const host = process.env.HOST || '0.0.0.0' 12 | 13 | server.listen({ 14 | port, 15 | host 16 | }).then(({ url, subscriptionsUrl }) => { 17 | console.log(`🚀 Server ready at ${url} NODE_ENV=${environment}`) 18 | console.log(`🚀 Subscriptions ready at ${subscriptionsUrl} NODE_ENV=${environment}`) 19 | }) 20 | -------------------------------------------------------------------------------- /server/tests/factories/UserFactory.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import { 3 | factory 4 | } from 'factory-girl' 5 | import ObjectionAdapter from 'factory-girl-objection-adapter' 6 | 7 | import { 8 | User 9 | } from '~/src/app/models/User/Model' 10 | 11 | factory.setAdapter(new ObjectionAdapter()) 12 | 13 | let email 14 | 15 | factory.define('user', User, { 16 | email: factory.sequence('User.email', () => { 17 | email = faker.internet.email() 18 | return email 19 | }), 20 | password: factory.sequence('User.password', () => email), 21 | username: factory.sequence('User.username', () => faker.internet.userName()) 22 | }) 23 | 24 | export default factory 25 | -------------------------------------------------------------------------------- /server/src/app/auth/rules.js: -------------------------------------------------------------------------------- 1 | import _ from 'lodash' 2 | 3 | import { 4 | rule 5 | } from 'graphql-shield' 6 | 7 | import { 8 | AccessDeniedError 9 | } from '~/src/app/errors/models/UserErrors' 10 | 11 | const isAuthenticated = rule()(async (parent, args, ctx) => { 12 | if (ctx.currentUser) { 13 | return true 14 | } else { 15 | return false 16 | } 17 | }) 18 | 19 | const isCurrentUser = rule()(async (parent, args, ctx) => { 20 | if (_.get(ctx, 'currentUser.id', undefined) === parent.id) { 21 | return true 22 | } else { 23 | return new AccessDeniedError({}) 24 | } 25 | }) 26 | 27 | 28 | export { 29 | isAuthenticated, 30 | isCurrentUser 31 | } 32 | -------------------------------------------------------------------------------- /server/src/app/graphql/story/schema.gql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | createStory(data: CreateStoryInput): Story! 3 | updateStory(data: UpdateStoryInput): Story! 4 | deleteStory(uuid: ID!): Story! 5 | } 6 | 7 | type Query { 8 | getStories(page: Int): PaginatedStories! 9 | } 10 | 11 | type Story { 12 | uuid: ID! 13 | title: String! 14 | user: User! 15 | created_at: DateTime! 16 | updated_at: DateTime! 17 | } 18 | 19 | input CreateStoryInput { 20 | title: String! 21 | } 22 | 23 | input UpdateStoryInput { 24 | uuid: ID! 25 | title: String! 26 | } 27 | 28 | type PaginatedStories { 29 | results: [Story]! 30 | total: Int! 31 | totalPages: Int! 32 | currentPage: Int! 33 | } 34 | -------------------------------------------------------------------------------- /server/src/app/auth/permissions.js: -------------------------------------------------------------------------------- 1 | import { 2 | shield, 3 | } from 'graphql-shield' 4 | 5 | import { 6 | AuthenticationRequiredError 7 | } from '~/src/app/errors/models/UserErrors' 8 | 9 | import { 10 | usersPermissions 11 | } from '~/src/app/graphql/user/permissions' 12 | 13 | import { 14 | storiesPermissions 15 | } from '~/src/app/graphql/story/permissions' 16 | 17 | const permissions = shield({ 18 | Query: { 19 | ...usersPermissions.Query, 20 | ...storiesPermissions.Query 21 | }, 22 | Mutation: { 23 | ...usersPermissions.Mutation, 24 | ...storiesPermissions.Mutation 25 | }, 26 | User: { 27 | ...usersPermissions.User, 28 | } 29 | }, { 30 | fallbackError: new AuthenticationRequiredError({}) 31 | }) 32 | 33 | export { 34 | permissions 35 | } 36 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-auth", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "server": "cd server && npm run dev", 7 | "client": "cd client && npm run dev", 8 | "dev": "concurrently --names \"server,client\" \"npm run server --silent\" \"npm run client --silent\"" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/pearce89/graphql-auth.git" 13 | }, 14 | "author": "Sergei Anisimov", 15 | "license": "UNLICENSED", 16 | "private": true, 17 | "bugs": { 18 | "url": "https://github.com/pearce89/graphql-auth/issues" 19 | }, 20 | "homepage": "https://github.com/pearce89/graphql-auth#readme", 21 | "devDependencies": { 22 | "concurrently": "^4.1.0" 23 | }, 24 | "dependencies": {} 25 | } 26 | -------------------------------------------------------------------------------- /server/src/app/auth/jwt.js: -------------------------------------------------------------------------------- 1 | import jwt from 'jsonwebtoken' 2 | 3 | import { 4 | User 5 | } from '~/src/app/models/User/Model' 6 | 7 | const generateToken = (userId) => { 8 | return jwt.sign({ 9 | userId 10 | }, process.env.JWT_SECRET, { 11 | expiresIn: '7d' 12 | }) 13 | } 14 | 15 | const decodeToken = async (token) => { 16 | if (token) { 17 | token = token.replace('Bearer ', '') 18 | try { 19 | const decoded = jwt.verify(token, process.env.JWT_SECRET) 20 | const user = await User 21 | .query() 22 | .where('id', decoded.userId) 23 | .first() 24 | 25 | if (user) { 26 | return user 27 | } else { 28 | return null 29 | } 30 | } catch (error) { 31 | return null 32 | } 33 | } 34 | } 35 | 36 | export { 37 | generateToken, 38 | decodeToken 39 | } 40 | -------------------------------------------------------------------------------- /server/src/app/services/Story/deleteStory.js: -------------------------------------------------------------------------------- 1 | import { 2 | Story 3 | } from '~/src/app/models/Story/Model' 4 | 5 | import { 6 | StoryNotFoundError 7 | } from '~/src/app/errors/models/StoryErrors' 8 | import { 9 | AccessDeniedError 10 | } from '../../errors/models/UserErrors' 11 | 12 | export default async ({ uuid }, ctx) => { 13 | const story = await Story.query() 14 | .where('uuid', uuid) 15 | .first() 16 | 17 | if (!story) { 18 | throw new StoryNotFoundError({ 19 | data: { 20 | uuid 21 | } 22 | }) 23 | } 24 | 25 | if ( 26 | typeof ctx.currentUser === 'undefined' || 27 | ctx.currentUser === null || 28 | story.userId !== ctx.currentUser.id 29 | ) { 30 | throw new AccessDeniedError({ 31 | data: { 32 | uuid 33 | } 34 | }) 35 | } 36 | 37 | return await story.$query().delete().returning('*') 38 | } 39 | -------------------------------------------------------------------------------- /client/assets/styles/tailwind.pcss: -------------------------------------------------------------------------------- 1 | /** 2 | * This injects Tailwind's base styles, which is a combination of 3 | * Normalize.css and some additional base styles. 4 | */ 5 | @tailwind base; 6 | 7 | h1 { 8 | @apply text-xl; 9 | } 10 | h2 { 11 | @apply text-xl; 12 | } 13 | h3 { 14 | @apply text-lg; 15 | } 16 | 17 | /** 18 | * This injects any component classes registered by plugins. 19 | */ 20 | @tailwind components; 21 | 22 | /** 23 | * This injects all of Tailwind's utility classes, generated based on your 24 | * config file. It also injects any utility classes registered by plugins. 25 | */ 26 | @tailwind utilities; 27 | 28 | /** 29 | * Use this directive to control where Tailwind injects the responsive 30 | * variations of each utility. 31 | * 32 | * If omitted, Tailwind will append these classes to the very end of 33 | * your stylesheet by default. 34 | */ 35 | @tailwind screens; 36 | -------------------------------------------------------------------------------- /client/middleware/setup-auth.js: -------------------------------------------------------------------------------- 1 | import jwtDecode from 'jwt-decode' 2 | 3 | import { getUserFromCookie, getUserFromLocalStorage } from '~/utils' 4 | 5 | export default function({ store, req }) { 6 | if (process.server && !req) return 7 | 8 | const userData = process.server 9 | ? getUserFromCookie(req) 10 | : getUserFromLocalStorage() 11 | 12 | if (!userData) { 13 | 14 | } else if (!userData.jwt) { 15 | store.commit('users/clearToken') 16 | store.commit('users/clearUserEmail') 17 | } else if ((new Date).getTime() > (new Date(0)).setUTCSeconds(jwtDecode(userData.jwt).exp)) { 18 | // logout user if token expired 19 | store.commit('users/clearToken') 20 | store.commit('users/clearUserEmail') 21 | } else { 22 | store.commit('users/setToken', userData.jwt) 23 | store.commit('users/setUserEmail', userData.userEmail) 24 | store.commit('users/setUserUuid', userData.userUuid) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/app/services/Story/updateStory.js: -------------------------------------------------------------------------------- 1 | import { 2 | Story 3 | } from '~/src/app/models/Story/Model' 4 | 5 | import { 6 | StoryNotFoundError 7 | } from '~/src/app/errors/models/StoryErrors' 8 | import { 9 | AccessDeniedError 10 | } from '../../errors/models/UserErrors' 11 | 12 | export default async ({ data }, ctx) => { 13 | const { 14 | uuid, 15 | ...updateParams 16 | } = data 17 | 18 | const story = await Story.query() 19 | .where('uuid', uuid) 20 | .first() 21 | 22 | if (!story) { 23 | throw new StoryNotFoundError({ 24 | data: { 25 | uuid 26 | } 27 | }) 28 | } 29 | 30 | if ( 31 | typeof ctx.currentUser === 'undefined' || 32 | ctx.currentUser === null || 33 | story.userId !== ctx.currentUser.id 34 | ) { 35 | throw new AccessDeniedError({ 36 | data: { 37 | uuid 38 | } 39 | }) 40 | } 41 | 42 | return await story.$query().updateAndFetch({ 43 | ...updateParams 44 | }) 45 | } 46 | -------------------------------------------------------------------------------- /server/src/app/services/User/signinUser.js: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcrypt' 2 | 3 | import { 4 | User 5 | } from '~/src/app/models/User/Model' 6 | 7 | import { 8 | generateToken 9 | } from '~/src/app/auth/jwt' 10 | 11 | import { 12 | InvalidAuthCredentialsError 13 | } from '~/src/app/errors/models/UserErrors' 14 | 15 | function credentialsError(data) { 16 | throw new InvalidAuthCredentialsError({ 17 | data: { 18 | email: data.email, 19 | password: data.password 20 | } 21 | }) 22 | } 23 | 24 | export default async ({ data }, ctx) => { 25 | const user = await User 26 | .query() 27 | .where('email', data.email) 28 | .first() 29 | 30 | if (!user) { 31 | credentialsError(data) 32 | } 33 | 34 | const passwordMatch = await bcrypt.compare(data.password, user.password) 35 | 36 | if (!passwordMatch) { 37 | credentialsError(data) 38 | } 39 | 40 | ctx.currentUser = user 41 | 42 | return { 43 | user, 44 | token: generateToken(user.id) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /client/queries/stories.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost' 2 | 3 | export const GET_STORIES = gql` 4 | query($page: Int) { 5 | getStories(page: $page) { 6 | results { 7 | uuid 8 | title 9 | user { 10 | uuid 11 | username 12 | } 13 | updated_at 14 | } 15 | totalPages 16 | currentPage 17 | } 18 | } 19 | ` 20 | 21 | export const CREATE_STORY = gql` 22 | mutation($data: CreateStoryInput!) { 23 | createStory(data: $data) { 24 | uuid 25 | title 26 | updated_at 27 | user { 28 | username 29 | uuid 30 | } 31 | } 32 | } 33 | ` 34 | 35 | export const UPDATE_STORY = gql` 36 | mutation($data: UpdateStoryInput!) { 37 | updateStory(data: $data) { 38 | uuid 39 | title 40 | updated_at 41 | } 42 | } 43 | ` 44 | 45 | export const DELETE_STORY = gql` 46 | mutation($uuid: ID!) { 47 | deleteStory(uuid: $uuid) { 48 | uuid 49 | title 50 | } 51 | } 52 | ` 53 | -------------------------------------------------------------------------------- /server/src/app/errors/models/StoryErrors.js: -------------------------------------------------------------------------------- 1 | import ApplicationError from '~/src/app/errors/ApplicationError' 2 | 3 | class StoryNotFoundError extends ApplicationError { 4 | constructor({ 5 | message, 6 | code, 7 | data 8 | }) { 9 | const errorMessage = message || 'Story not found' 10 | const errorCode = code || 'STORY_NOT_FOUND_ERROR' 11 | const errorData = data || {} 12 | 13 | super({ 14 | message: errorMessage, 15 | code: errorCode, 16 | data: errorData 17 | }) 18 | } 19 | } 20 | 21 | class StoryValidationError extends ApplicationError { 22 | constructor({ 23 | message, 24 | code, 25 | data 26 | }) { 27 | const errorMessage = message || 'Invalid Story Data' 28 | const errorCode = code || 'VALIDATION_ERROR' 29 | const errorData = data || {} 30 | 31 | super({ 32 | message: errorMessage, 33 | code: errorCode, 34 | data: errorData 35 | }) 36 | } 37 | } 38 | 39 | export { 40 | StoryNotFoundError, 41 | StoryValidationError 42 | } 43 | -------------------------------------------------------------------------------- /server/src/db/seeds/users.js: -------------------------------------------------------------------------------- 1 | import faker from 'faker' 2 | import bcrypt from 'bcrypt' 3 | import uuidv4 from 'uuid/v4' 4 | 5 | const seed = async function (knex) { 6 | // Deletes ALL existing entries 7 | await knex('stories').del() 8 | await knex('users').del() 9 | 10 | for (var index = 0; index < 3; index++) { 11 | let email = faker.internet.email() 12 | let userId = await knex('users').insert([{ 13 | uuid: uuidv4(), 14 | email, 15 | password: await bcrypt.hash(email, 10), 16 | username: faker.internet.userName(), 17 | created_at: new Date().toISOString(), 18 | updated_at: new Date().toISOString(), 19 | }]).returning('id') 20 | 21 | for (var i = 0; i < 3; i++) { 22 | await knex('stories').insert([{ 23 | uuid: uuidv4(), 24 | title: faker.lorem.words(), 25 | userId: userId[0], 26 | created_at: new Date().toISOString(), 27 | updated_at: new Date().toISOString(), 28 | }]) 29 | } 30 | } 31 | } 32 | 33 | export { 34 | seed 35 | } 36 | -------------------------------------------------------------------------------- /server/src/app/graphql/user/schema.gql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | createUser(data: CreateUserInput!): AuthPayload! 3 | signinUser(data: SignInUserInput!): AuthPayload! 4 | updateUser(data: UpdateUserInput!): UserWithStories! 5 | } 6 | 7 | type Query { 8 | getUsers: [User]! 9 | getCurrentUser(storiesPage: Int): UserWithStories! 10 | } 11 | 12 | type UserWithStories { 13 | uuid: ID! 14 | email: String! 15 | username: String! 16 | stories: PaginatedStories! 17 | created_at: DateTime! 18 | updated_at: DateTime! 19 | } 20 | 21 | type User { 22 | uuid: ID! 23 | email: String! 24 | username: String! 25 | created_at: DateTime! 26 | updated_at: DateTime! 27 | } 28 | 29 | type AuthPayload { 30 | token: String! 31 | user: User! 32 | } 33 | 34 | input CreateUserInput { 35 | email: String! 36 | username: String! 37 | password: String! 38 | } 39 | 40 | input UpdateUserInput { 41 | email: String 42 | username: String 43 | password: String 44 | } 45 | 46 | input SignInUserInput { 47 | email: String! 48 | password: String! 49 | } 50 | -------------------------------------------------------------------------------- /server/tests/jest/globalSetup.js: -------------------------------------------------------------------------------- 1 | require('@babel/register') 2 | require('@babel/polyfill/noConflict') 3 | 4 | const knexMigrate = require('knex-migrate') 5 | const log = ({ 6 | action, 7 | migration 8 | }) => { 9 | console.log(`Doing ${action} on ${migration}`) 10 | } 11 | 12 | const environment = process.env.NODE_ENV || 'test' 13 | 14 | const server = require('../../src/server').default 15 | const dotenv = require('dotenv') 16 | const fs = require('fs') 17 | const path = require('path') 18 | 19 | if (!process.env.CIRCLECI) { 20 | const envConfig = dotenv.parse( 21 | fs.readFileSync( 22 | path.resolve(__dirname, `../../src/config/${environment}.env`) 23 | ) 24 | ) 25 | 26 | for (let k in envConfig) { 27 | process.env[k] = envConfig[k] 28 | } 29 | } 30 | 31 | module.exports = async () => { 32 | console.log('\n') 33 | await knexMigrate('up', { 34 | knexfile: 'src/db/config.js' 35 | }, log) 36 | 37 | const port = process.env.PORT 38 | global.apollo = await server.listen({ 39 | port 40 | }) 41 | console.log(`\n🚀 Server is ready at ${global.apollo.url} NODE_ENV=${environment}\n`) 42 | } 43 | -------------------------------------------------------------------------------- /client/queries/users.js: -------------------------------------------------------------------------------- 1 | import { gql } from 'apollo-boost' 2 | 3 | export const SIGNIN_USER = gql` 4 | mutation($data: SignInUserInput!) { 5 | signinUser( 6 | data: $data 7 | ){ 8 | user { 9 | uuid 10 | email 11 | username 12 | } 13 | token 14 | } 15 | } 16 | ` 17 | 18 | export const CREATE_USER = gql` 19 | mutation($data: CreateUserInput!) { 20 | createUser( 21 | data: $data 22 | ){ 23 | user { 24 | uuid 25 | email 26 | username 27 | } 28 | token 29 | } 30 | } 31 | ` 32 | 33 | export const UPDATE_USER = gql` 34 | mutation($data: UpdateUserInput!) { 35 | updateUser( 36 | data: $data 37 | ){ 38 | uuid 39 | email 40 | username 41 | } 42 | } 43 | ` 44 | 45 | export const GET_CURRENT_USER = gql` 46 | query($storiesPage: Int) { 47 | getCurrentUser(storiesPage: $storiesPage) { 48 | uuid 49 | email 50 | username 51 | created_at 52 | stories { 53 | results { 54 | uuid 55 | title 56 | updated_at 57 | user { 58 | uuid 59 | } 60 | } 61 | total 62 | totalPages 63 | currentPage 64 | } 65 | } 66 | } 67 | ` 68 | -------------------------------------------------------------------------------- /server/src/db/config.js: -------------------------------------------------------------------------------- 1 | const environment = process.env.NODE_ENV || 'development' 2 | 3 | const sourcePath = (['development', 'test'].includes(environment)) ? 4 | 'src' : 'dist' 5 | 6 | process.chdir(__dirname.replace(`${sourcePath}/db`, '')) // hack for wrong knex/bin/cli.js:61 behavior 7 | 8 | const dotenv = require('dotenv') 9 | const path = require('path') 10 | 11 | dotenv.config({ 12 | path: path.resolve(__dirname, `../config/${environment}.env`) 13 | }) 14 | 15 | const host = process.env.DATABASE_HOST || 'localhost' 16 | const port = process.env.DATABASE_PORT || '5432' 17 | const database = process.env.DATABASE_NAME || 'graphql-auth-development' 18 | const user = process.env.DATABASE_USER || 'postgres' 19 | const password = process.env.DATABASE_PASSWORD || '' 20 | 21 | const pgConfig = { 22 | client: 'postgres', 23 | connection: { 24 | host, 25 | port, 26 | database, 27 | user, 28 | password 29 | }, 30 | pool: { 31 | min: 0, 32 | max: 10, 33 | idleTimeoutMillis: 500 34 | }, 35 | migrations: { 36 | directory: `./${sourcePath}/db/migrations` 37 | }, 38 | seeds: { 39 | directory: `./${sourcePath}/db/seeds` 40 | } 41 | } 42 | 43 | module.exports = { 44 | test: pgConfig, 45 | development: pgConfig, 46 | production: pgConfig 47 | } 48 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "root": true, 3 | "env": { 4 | "browser": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | $nuxt: true 9 | }, 10 | "parserOptions": { 11 | "parser": "babel-eslint" 12 | }, 13 | "extends": [ 14 | "@nuxtjs", 15 | "plugin:nuxt/recommended", 16 | "prettier" 17 | ], 18 | "plugins": [ 19 | "prettier" 20 | ], 21 | // add your custom rules here 22 | "rules": { 23 | "no-path-concat": "off", 24 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 25 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off", 26 | "indent": [ 27 | "error", 28 | 2 29 | ], 30 | "linebreak-style": [ 31 | "error", 32 | "unix" 33 | ], 34 | "quotes": [ 35 | "error", 36 | "single" 37 | ], 38 | "vue/max-attributes-per-line": ["error", { 39 | "singleline": 1, 40 | "multiline": { 41 | "max": 1, 42 | "allowFirstLine": false 43 | } 44 | }], 45 | "vue/html-quotes": [ 46 | "error", 47 | "double" 48 | ], 49 | "semi": [ 50 | "error", 51 | "never" 52 | ], 53 | "vue/html-closing-bracket-newline": ["error", { 54 | "singleline": "never", 55 | "multiline": "always" 56 | }] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /server/src/app/graphql/story/resolver.js: -------------------------------------------------------------------------------- 1 | import { 2 | Story 3 | } from '~/src/app/models/Story/Model' 4 | 5 | import createStory from '~/src/app/services/Story/createStory' 6 | import updateStory from '~/src/app/services/Story/updateStory' 7 | import deleteStory from '~/src/app/services/Story/deleteStory' 8 | 9 | const resolver = { 10 | Query: { 11 | getStories: async (_, { page }) => { 12 | const perPage = 5 13 | const currentPage = page - 1 || 0 14 | 15 | let stories = await Story.query() 16 | .orderBy('updated_at', 'desc') 17 | .eager('user') 18 | .page(currentPage, perPage) 19 | 20 | stories = { 21 | ...stories, 22 | currentPage: currentPage + 1, 23 | 24 | totalPages: Math.ceil( 25 | (stories.total / perPage) 26 | ) 27 | } 28 | 29 | return { 30 | ...stories 31 | } 32 | } 33 | }, 34 | 35 | Mutation: { 36 | createStory: async (_, { data }, ctx) => { 37 | return await createStory({ 38 | data 39 | }, ctx) 40 | }, 41 | 42 | updateStory: async (_, { data }, ctx) => { 43 | return await updateStory({ 44 | data 45 | }, ctx) 46 | }, 47 | 48 | deleteStory: async (_, { uuid }, ctx) => { 49 | return await deleteStory({ 50 | uuid 51 | }, ctx) 52 | } 53 | } 54 | } 55 | 56 | export { 57 | resolver 58 | } 59 | -------------------------------------------------------------------------------- /server/src/app/models/User/Model.js: -------------------------------------------------------------------------------- 1 | import obau from 'objection-before-and-unique' 2 | import bcrypt from 'bcrypt' 3 | 4 | import { 5 | BaseModel 6 | } from '~/src/app/models/BaseModel' 7 | import { 8 | Story 9 | } from '~/src/app/models/Story/Model' 10 | 11 | import userValidate from '~/src/app/models/User/Validation' 12 | import { 13 | UserValidationError 14 | } from '~/src/app/errors/models/UserErrors' 15 | 16 | const opts = { 17 | before: [ 18 | async ({ 19 | instance, 20 | old, 21 | operation 22 | }) => { 23 | const userErrors = await userValidate({ 24 | instance, 25 | old, 26 | operation 27 | }) 28 | 29 | if (userErrors.length) { 30 | throw new UserValidationError({ 31 | data: userErrors 32 | }) 33 | } 34 | 35 | const password = instance.password || old.password 36 | instance.password = await bcrypt.hash(password, 10) 37 | } 38 | ] 39 | } 40 | 41 | class User extends obau(opts)(BaseModel) { 42 | static tableName = 'users' 43 | 44 | stories() { 45 | return this.$relatedQuery('stories') 46 | } 47 | 48 | static relationMappings = () => ({ 49 | stories: { 50 | relation: BaseModel.HasManyRelation, 51 | modelClass: Story, 52 | join: { 53 | from: 'users.id', 54 | to: 'stories.userId' 55 | } 56 | } 57 | }) 58 | } 59 | 60 | export { 61 | User 62 | } 63 | -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parser": "babel-eslint", 3 | "env": { 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "extends": [ 9 | "eslint:recommended", 10 | "plugin:node/recommended", 11 | "prettier" 12 | ], 13 | "parserOptions": { 14 | "ecmaVersion": 2015, 15 | "sourceType": "module" 16 | }, 17 | "rules": { 18 | "node/no-unsupported-features/es-syntax": "off", 19 | "node/no-unpublished-require": "off", 20 | "no-console": process.env.NODE_ENV === "production" ? "error" : "off", 21 | "no-debugger": process.env.NODE_ENV === "production" ? "error" : "off", 22 | "indent": [ 23 | "error", 24 | 2 25 | ], 26 | "linebreak-style": [ 27 | "error", 28 | "unix" 29 | ], 30 | "quotes": [ 31 | "error", 32 | "single" 33 | ], 34 | "semi": [ 35 | "error", 36 | "never" 37 | ], 38 | "object-curly-newline": ["error", { 39 | "ObjectExpression": { 40 | "minProperties": 1, 41 | "multiline": true 42 | }, 43 | "ObjectPattern": { "multiline": true }, 44 | "ImportDeclaration": "always", 45 | "ExportDeclaration": "always" 46 | }], 47 | "object-property-newline": [ 48 | "error", { 49 | "allowAllPropertiesOnSameLine": false 50 | } 51 | ], 52 | "object-curly-spacing": [ 53 | "error", 54 | "always" 55 | ] 56 | } 57 | }; 58 | -------------------------------------------------------------------------------- /server/tests/graphql/story/getStories.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | 3 | import { 4 | knex 5 | } from '~/src/db/index' 6 | 7 | import { 8 | gql 9 | } from 'apollo-boost' 10 | 11 | import userFactory from '~/tests/factories/UserFactory' 12 | import storyFactory from '~/tests/factories/StoryFactory' 13 | import getClient from '~/tests/utils/getClient' 14 | 15 | let client, users, stories 16 | 17 | const getStories = gql ` 18 | query { 19 | getStories { 20 | results { 21 | uuid 22 | title 23 | user { 24 | uuid 25 | username 26 | } 27 | } 28 | } 29 | } 30 | ` 31 | 32 | describe('getStories', () => { 33 | beforeAll(async () => { 34 | users = await userFactory.createMany('user', 2) 35 | stories = await storyFactory.createMany('story', 2, [{ 36 | userId: users[0].id 37 | }, { 38 | userId: users[1].id 39 | }]) 40 | }) 41 | 42 | afterAll(async () => { 43 | await knex.destroy() 44 | }) 45 | 46 | test('Should return stories list', async () => { 47 | client = getClient() 48 | 49 | const response = await client.query({ 50 | query: getStories 51 | }) 52 | 53 | expect(response.data.getStories.results).toEqual( 54 | expect.arrayContaining([ 55 | expect.objectContaining({ 56 | user: expect.objectContaining({ 57 | username: users[0].username, 58 | }), 59 | title: stories[0].title 60 | }) 61 | ]) 62 | ) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /server/tests/graphql/user/getCurrentUser.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | 3 | import { 4 | knex 5 | } from '~/src/db/index' 6 | 7 | import { 8 | gql 9 | } from 'apollo-boost' 10 | 11 | import { 12 | generateToken 13 | } from '~/src/app/auth/jwt' 14 | 15 | import userFactory from '~/tests/factories/UserFactory' 16 | import getClient from '~/tests/utils/getClient' 17 | 18 | let client, user, jwtToken 19 | const getCurrentUser = gql ` 20 | query { 21 | getCurrentUser { 22 | uuid 23 | email 24 | username 25 | } 26 | } 27 | ` 28 | 29 | describe('getUsers', () => { 30 | beforeAll(async () => { 31 | user = await userFactory.create('user') 32 | jwtToken = generateToken(user.id) 33 | }) 34 | 35 | afterAll(async () => { 36 | await knex.destroy() 37 | }) 38 | 39 | test('Should return current user', async () => { 40 | client = getClient(jwtToken) 41 | 42 | const response = await client.query({ 43 | query: getCurrentUser 44 | }) 45 | 46 | expect(response.data.getCurrentUser.uuid).toEqual(user.uuid) 47 | expect(response.data.getCurrentUser.email).toEqual(user.email) 48 | expect(response.data.getCurrentUser.username).toEqual(user.username) 49 | }) 50 | 51 | test('Should not return current user without valid authentication', async () => { 52 | client = getClient() 53 | 54 | await expect( 55 | client.query({ 56 | query: getCurrentUser 57 | }) 58 | ).rejects.toThrow('Authentication Required') 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /server/src/app/models/Story/Model.js: -------------------------------------------------------------------------------- 1 | import obau from 'objection-before-and-unique' 2 | 3 | import { 4 | BaseModel 5 | } from '~/src/app/models/BaseModel' 6 | import { 7 | User 8 | } from '~/src/app/models/User/Model' 9 | 10 | import storyValidate from '~/src/app/models/Story/Validation' 11 | import { 12 | StoryValidationError 13 | } from '~/src/app/errors/models/StoryErrors' 14 | 15 | const opts = { 16 | schema: { 17 | type: 'object', 18 | 19 | properties: { 20 | id: { 21 | type: 'integer' 22 | }, 23 | title: { 24 | type: 'string' 25 | }, 26 | userId: { 27 | type: 'integer' 28 | } 29 | }, 30 | }, 31 | before: [ 32 | async ({ 33 | instance, 34 | old, 35 | operation 36 | }) => { 37 | const storyErrors = await storyValidate({ 38 | instance, 39 | old, 40 | operation 41 | }) 42 | 43 | if (storyErrors.length) { 44 | throw new StoryValidationError({ 45 | data: storyErrors 46 | }) 47 | } 48 | } 49 | ] 50 | } 51 | 52 | class Story extends obau(opts)(BaseModel) { 53 | static tableName = 'stories' 54 | 55 | user() { 56 | return this.$relatedQuery('user') 57 | } 58 | 59 | static relationMappings = () => ({ 60 | user: { 61 | relation: BaseModel.BelongsToOneRelation, 62 | modelClass: User, 63 | join: { 64 | from: 'stories.userId', 65 | to: 'users.id' 66 | } 67 | } 68 | }) 69 | } 70 | 71 | export { 72 | Story 73 | } 74 | -------------------------------------------------------------------------------- /server/src/app/graphql/user/resolver.js: -------------------------------------------------------------------------------- 1 | import { 2 | User 3 | } from '~/src/app/models/User/Model' 4 | 5 | import createUser from '~/src/app/services/User/createUser' 6 | import signinUser from '~/src/app/services/User/signinUser' 7 | import updateUser from '~/src/app/services/User/updateUser' 8 | 9 | const resolver = { 10 | Query: { 11 | getCurrentUser: async (_, { storiesPage }, ctx) => { 12 | const perPage = 5 13 | const currentPage = storiesPage - 1 || 0 14 | const user = ctx.currentUser 15 | 16 | let stories = await user.$relatedQuery('stories') 17 | .orderBy('updated_at', 'desc') 18 | .page(currentPage, perPage) 19 | 20 | stories = { 21 | ...stories, 22 | currentPage: currentPage + 1, 23 | 24 | totalPages: Math.ceil( 25 | (stories.total / perPage) 26 | ) 27 | } 28 | 29 | return { 30 | ...user, 31 | stories 32 | } 33 | }, 34 | 35 | getUsers: async () => { 36 | return await User.query().eager('stories') 37 | }, 38 | }, 39 | 40 | Mutation: { 41 | createUser: async (_, { data }, ctx) => { 42 | return await createUser({ 43 | data 44 | }, ctx) 45 | }, 46 | 47 | signinUser: async (_, { data }, ctx) => { 48 | return await signinUser({ 49 | data 50 | }, ctx) 51 | }, 52 | 53 | updateUser: async (_, { data }, ctx) => { 54 | return await updateUser({ 55 | data, 56 | ctx 57 | }) 58 | } 59 | } 60 | } 61 | 62 | export { 63 | resolver 64 | } 65 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Node template 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | 10 | # Runtime data 11 | pids 12 | *.pid 13 | *.seed 14 | *.pid.lock 15 | 16 | # Directory for instrumented libs generated by jscoverage/JSCover 17 | lib-cov 18 | 19 | # Coverage directory used by tools like istanbul 20 | coverage 21 | 22 | # nyc test coverage 23 | .nyc_output 24 | 25 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 26 | .grunt 27 | 28 | # Bower dependency directory (https://bower.io/) 29 | bower_components 30 | 31 | # node-waf configuration 32 | .lock-wscript 33 | 34 | # Compiled binary addons (https://nodejs.org/api/addons.html) 35 | build/Release 36 | 37 | # Dependency directories 38 | node_modules/ 39 | jspm_packages/ 40 | 41 | # TypeScript v1 declaration files 42 | typings/ 43 | 44 | # Optional npm cache directory 45 | .npm 46 | 47 | # Optional eslint cache 48 | .eslintcache 49 | 50 | # Optional REPL history 51 | .node_repl_history 52 | 53 | # Output of 'npm pack' 54 | *.tgz 55 | 56 | # Yarn Integrity file 57 | .yarn-integrity 58 | 59 | # dotenv environment variables file 60 | **.env 61 | 62 | # parcel-bundler cache (https://parceljs.org/) 63 | .cache 64 | 65 | # next.js build output 66 | .next 67 | 68 | # nuxt.js build output 69 | .nuxt 70 | 71 | # Nuxt generate 72 | dist 73 | 74 | # vuepress build output 75 | .vuepress/dist 76 | 77 | # Serverless directories 78 | .serverless 79 | 80 | # IDE 81 | .idea 82 | 83 | # Service worker 84 | sw.* 85 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | junit.xml 3 | 4 | # Logs 5 | logs 6 | *.log 7 | npm-debug.log* 8 | yarn-debug.log* 9 | yarn-error.log* 10 | lerna-debug.log* 11 | 12 | # Diagnostic reports (https://nodejs.org/api/report.html) 13 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # TypeScript v1 declaration files 47 | typings/ 48 | 49 | # Optional npm cache directory 50 | .npm 51 | 52 | # Optional eslint cache 53 | .eslintcache 54 | 55 | # Optional REPL history 56 | .node_repl_history 57 | 58 | # Output of 'npm pack' 59 | *.tgz 60 | 61 | # Yarn Integrity file 62 | .yarn-integrity 63 | 64 | # dotenv environment variables file 65 | **.env 66 | 67 | # parcel-bundler cache (https://parceljs.org/) 68 | .cache 69 | 70 | # next.js build output 71 | .next 72 | 73 | # nuxt.js build output 74 | .nuxt 75 | 76 | # vuepress build output 77 | .vuepress/dist 78 | 79 | # Serverless directories 80 | .serverless/ 81 | 82 | # FuseBox cache 83 | .fusebox/ 84 | 85 | # DynamoDB Local files 86 | .dynamodb/ 87 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | junit.xml 3 | 4 | .env 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | yarn-debug.log* 11 | yarn-error.log* 12 | lerna-debug.log* 13 | 14 | # Diagnostic reports (https://nodejs.org/api/report.html) 15 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 16 | 17 | # Runtime data 18 | pids 19 | *.pid 20 | *.seed 21 | *.pid.lock 22 | 23 | # Directory for instrumented libs generated by jscoverage/JSCover 24 | lib-cov 25 | 26 | # Coverage directory used by tools like istanbul 27 | coverage 28 | 29 | # nyc test coverage 30 | .nyc_output 31 | 32 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 33 | .grunt 34 | 35 | # Bower dependency directory (https://bower.io/) 36 | bower_components 37 | 38 | # node-waf configuration 39 | .lock-wscript 40 | 41 | # Compiled binary addons (https://nodejs.org/api/addons.html) 42 | build/Release 43 | 44 | # Dependency directories 45 | node_modules/ 46 | jspm_packages/ 47 | 48 | # TypeScript v1 declaration files 49 | typings/ 50 | 51 | # Optional npm cache directory 52 | .npm 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Optional REPL history 58 | .node_repl_history 59 | 60 | # Output of 'npm pack' 61 | *.tgz 62 | 63 | # Yarn Integrity file 64 | .yarn-integrity 65 | 66 | # dotenv environment variables file 67 | .env 68 | .env.test 69 | 70 | # parcel-bundler cache (https://parceljs.org/) 71 | .cache 72 | 73 | # next.js build output 74 | .next 75 | 76 | # nuxt.js build output 77 | .nuxt 78 | 79 | # vuepress build output 80 | .vuepress/dist 81 | 82 | # Serverless directories 83 | .serverless/ 84 | 85 | # FuseBox cache 86 | .fusebox/ 87 | 88 | # DynamoDB Local files 89 | .dynamodb/ 90 | -------------------------------------------------------------------------------- /server/src/db/tasks.js: -------------------------------------------------------------------------------- 1 | const dbConfig = require('./config') 2 | 3 | const environment = process.env.NODE_ENV || 'development' 4 | console.log(dbConfig[environment].connection) 5 | 6 | let dbManager = require('knex-db-manager').databaseManagerFactory({ 7 | knex: dbConfig[environment], 8 | dbManager: { 9 | // db manager related configuration 10 | superUser: 'postgres', 11 | superPassword: '' 12 | } 13 | }) 14 | 15 | const createDb = async () => { 16 | try { 17 | await dbManager.createDb() 18 | console.log('Database has been successfully CREATED') 19 | } catch (error) { 20 | console.error(error.message) 21 | } 22 | dbManager.close().then(() => console.log('Connection closed')) 23 | } 24 | 25 | const truncateDb = async () => { 26 | try { 27 | await dbManager.truncateDb() 28 | console.log('Database has been successfully TRUNCATED') 29 | } catch (error) { 30 | console.error(error.message) 31 | } 32 | dbManager.close().then(() => console.log('Connection closed')) 33 | } 34 | 35 | const migrateDb = async () => { 36 | try { 37 | await dbManager.migrateDb() 38 | console.log('Database has been successfully MIGRATED') 39 | } catch (error) { 40 | console.error(error.message) 41 | } 42 | dbManager.close().then(() => console.log('Connection closed')) 43 | } 44 | 45 | const dropDb = async () => { 46 | try { 47 | await dbManager.dropDb() 48 | console.log('Database has been successfully DROPPED') 49 | } catch (error) { 50 | console.error(error.message) 51 | } 52 | dbManager.close().then(() => console.log('Connection closed')) 53 | } 54 | 55 | module.exports = { 56 | createDb, 57 | truncateDb, 58 | migrateDb, 59 | dropDb 60 | } 61 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3" 2 | 3 | volumes: 4 | postgres-data: 5 | driver: local 6 | server-node-modules: 7 | driver: local 8 | client-node-modules: 9 | driver: local 10 | 11 | services: 12 | postgres: 13 | container_name: postgres.graphql-auth.local 14 | image: postgres:11 # We'll use the official postgres image. 15 | volumes: 16 | # Mounts a persistable volume inside the postgres data folder, so we 17 | # don't lose the created databases when this container is removed. 18 | - postgres-data:/var/lib/postgresql/data 19 | ports: 20 | - "5432:5432" 21 | 22 | server: 23 | container_name: server.graphql-server.local 24 | build: 25 | context: ./server 26 | dockerfile: dev.Dockerfile 27 | image: graphql-auth-server 28 | entrypoint: /graphql-auth-server/entrypoint.sh 29 | command: npm run dev 30 | environment: 31 | - NODE_ENV=development 32 | - HOST=0.0.0.0 33 | - PORT=4000 34 | ports: 35 | - "4000:4000" 36 | volumes: 37 | - ./server:/graphql-auth-server 38 | - server-node-modules:/graphql-auth-server/node_modules 39 | depends_on: 40 | - postgres 41 | 42 | client: 43 | container_name: client.graphql-auth.local 44 | build: 45 | context: ./client 46 | dockerfile: dev.Dockerfile 47 | image: graphql-auth-client 48 | entrypoint: /graphql-auth-client/entrypoint.sh 49 | command: npm run dev 50 | environment: 51 | - NODE_ENV=development 52 | - HOST=0.0.0.0 53 | - PORT=5000 54 | ports: 55 | - "5000:5000" 56 | volumes: 57 | - ./client:/graphql-auth-client 58 | - client-node-modules:/graphql-auth-client/node_modules 59 | -------------------------------------------------------------------------------- /server/src/server.js: -------------------------------------------------------------------------------- 1 | import pino from 'pino' 2 | import glue from 'schemaglue' 3 | import _ from 'lodash' 4 | 5 | import { 6 | ApolloServer 7 | } from 'apollo-server' 8 | 9 | import { 10 | makeExecutableSchema 11 | } from 'graphql-tools' 12 | 13 | import { 14 | applyMiddleware 15 | } from 'graphql-middleware' 16 | 17 | import { 18 | permissions 19 | } from '~/src/app/auth/permissions' 20 | 21 | import { 22 | decodeToken, 23 | } from '~/src/app/auth/jwt' 24 | 25 | const logger = pino({ 26 | enabled: !(process.env.LOG_ENABLED === 'false') 27 | }) 28 | const environment = process.env.NODE_ENV || 'development' 29 | const graphqlSchemaPath = (['development', 'test'].includes(environment)) ? 30 | './src/app/graphql' : './dist/app/graphql' 31 | 32 | const { 33 | schema, 34 | resolver 35 | } = glue(graphqlSchemaPath) 36 | 37 | let graphQLSchema = makeExecutableSchema({ 38 | typeDefs: schema, 39 | resolvers: resolver, 40 | }) 41 | 42 | graphQLSchema = applyMiddleware(graphQLSchema, permissions) 43 | 44 | const server = new ApolloServer({ 45 | schema: graphQLSchema, 46 | introspection: true, 47 | playground: true, 48 | context: async ({ req }) => { 49 | logger.info(req.headers, '[HEADERS]') 50 | logger.info(req.body.query, '[QUERY]') 51 | logger.info(req.body.variables, '[VARIABLES]') 52 | 53 | return { 54 | ...req, 55 | currentUser: await decodeToken(_.get(req, 'headers.authorization', null)), 56 | } 57 | }, 58 | formatResponse: (response) => { 59 | logger.info(response, '[RESPONSE]') 60 | return response 61 | }, 62 | formatError: (error) => { 63 | logger.error(error, '[ERROR]') 64 | return error 65 | }, 66 | }) 67 | 68 | export { 69 | server as 70 | default 71 | } 72 | -------------------------------------------------------------------------------- /client/utils/index.js: -------------------------------------------------------------------------------- 1 | import Cookie from 'js-cookie' 2 | 3 | export const saveUserData = (signinUserData) => { 4 | localStorage.setItem('jwt', signinUserData.token) 5 | localStorage.setItem('userEmail', signinUserData.user.email) 6 | localStorage.setItem('userUuid', signinUserData.user.uuid) 7 | Cookie.set('jwt', signinUserData.token) 8 | Cookie.set('userEmail', signinUserData.user.email) 9 | Cookie.set('userUuid', signinUserData.user.uuid) 10 | } 11 | 12 | export const getUserFromCookie = req => { 13 | if (!req.headers.cookie) return 14 | 15 | const jwtCookie = req.headers.cookie 16 | .split(';') 17 | .find(c => c.trim().startsWith('jwt=')) 18 | const userEmailCookie = req.headers.cookie 19 | .split(';') 20 | .find(c => c.trim().startsWith('userEmail=')) 21 | const userUuidCookie = req.headers.cookie 22 | .split(';') 23 | .find(c => c.trim().startsWith('userUuid=')) 24 | 25 | if (!jwtCookie || !userEmailCookie || !userUuidCookie) return 26 | 27 | const jwt = jwtCookie.split('=')[1] 28 | const userEmail = userEmailCookie.split('=')[1] 29 | const userUuid = userUuidCookie.split('=')[1] 30 | 31 | return { jwt, userEmail, userUuid } 32 | } 33 | 34 | export const getUserFromLocalStorage = () => { 35 | if (localStorage) { 36 | const jwt = localStorage.getItem('jwt') 37 | const userEmail = localStorage.getItem('userEmail') 38 | const userUuid = localStorage.getItem('userUuid') 39 | 40 | return { jwt, userEmail, userUuid } 41 | } 42 | } 43 | 44 | export const clearUserData = () => { 45 | if (!process.server) { 46 | localStorage.removeItem('jwt') 47 | localStorage.removeItem('userEmail') 48 | localStorage.removeItem('userUuid') 49 | } 50 | Cookie.remove('jwt') 51 | Cookie.remove('userEmail') 52 | Cookie.remove('userUuid') 53 | } 54 | -------------------------------------------------------------------------------- /client/components/Logo.vue: -------------------------------------------------------------------------------- 1 | 9 | 10 | 80 | -------------------------------------------------------------------------------- /server/tests/graphql/story/createStory.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import faker from 'faker' 3 | 4 | import { 5 | knex 6 | } from '~/src/db/index' 7 | 8 | import { 9 | gql 10 | } from 'apollo-boost' 11 | 12 | import { 13 | generateToken, 14 | } from '~/src/app/auth/jwt' 15 | 16 | import userFactory from '~/tests/factories/UserFactory' 17 | import getClient from '~/tests/utils/getClient' 18 | 19 | let client, user, jwtToken, newStoryTitle = faker.lorem.sentence() 20 | 21 | const createStory = gql ` 22 | mutation($data: CreateStoryInput!) { 23 | createStory( 24 | data: $data 25 | ){ 26 | uuid 27 | title 28 | user { 29 | email 30 | } 31 | } 32 | } 33 | ` 34 | 35 | describe('createStory', () => { 36 | beforeAll(async () => { 37 | user = await userFactory.create('user') 38 | jwtToken = generateToken(user.id) 39 | }) 40 | 41 | afterAll(async () => { 42 | await knex.destroy() 43 | }) 44 | 45 | test('Should create a new user Story', async () => { 46 | client = getClient(jwtToken) 47 | 48 | const data = { 49 | title: newStoryTitle 50 | } 51 | 52 | const response = await client.mutate({ 53 | mutation: createStory, 54 | variables: { 55 | data 56 | } 57 | }) 58 | 59 | expect(response.data.createStory.title).toEqual(newStoryTitle) 60 | expect(response.data.createStory.user.email).toEqual(user.email) 61 | }) 62 | 63 | test('Should not create story without valid authentication', async () => { 64 | client = getClient() 65 | 66 | const data = { 67 | title: newStoryTitle 68 | } 69 | 70 | await expect( 71 | client.mutate({ 72 | mutation: createStory, 73 | variables: { 74 | data 75 | } 76 | }) 77 | ).rejects.toThrow('Authentication Required') 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /server/tests/graphql/user/getUsers.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | 3 | import { 4 | knex 5 | } from '~/src/db/index' 6 | 7 | import { 8 | gql 9 | } from 'apollo-boost' 10 | 11 | import userFactory from '~/tests/factories/UserFactory' 12 | import getClient from '~/tests/utils/getClient' 13 | 14 | let client, users, jwtToken 15 | 16 | describe('getUsers', () => { 17 | beforeAll(async () => { 18 | users = await userFactory.createMany('user', 2) 19 | }) 20 | 21 | afterAll(async () => { 22 | await knex.destroy() 23 | }) 24 | 25 | test('Should return users list', async () => { 26 | client = getClient() 27 | 28 | const getUsers = gql ` 29 | query { 30 | getUsers { 31 | uuid 32 | username 33 | } 34 | } 35 | ` 36 | 37 | const response = await client.query({ 38 | query: getUsers 39 | }) 40 | 41 | expect(response.data.getUsers).toEqual( 42 | expect.arrayContaining([ 43 | expect.objectContaining({ 44 | uuid: users[0].uuid, 45 | username: users[0].username 46 | }) 47 | ]) 48 | ) 49 | }) 50 | 51 | test('Should not return with sensitive emails of other users', async () => { 52 | client = getClient(jwtToken) 53 | 54 | const getUsers = gql ` 55 | query { 56 | getUsers { 57 | uuid 58 | email 59 | username 60 | } 61 | } 62 | ` 63 | 64 | try { 65 | await client.query({ 66 | query: getUsers 67 | }) 68 | } catch (error) { 69 | expect(error.graphQLErrors).toEqual( 70 | expect.arrayContaining([ 71 | expect.objectContaining({ 72 | extensions: expect.objectContaining({ 73 | code: 'ACCESS_DENIED_ERROR', 74 | }), 75 | path: expect.arrayContaining([ 76 | 'email' 77 | ]) 78 | }) 79 | ]) 80 | ) 81 | } 82 | }) 83 | }) 84 | -------------------------------------------------------------------------------- /server/tests/graphql/user/signinUser.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | 3 | import { 4 | knex 5 | } from '~/src/db/index' 6 | 7 | import { 8 | gql 9 | } from 'apollo-boost' 10 | 11 | import { 12 | decodeToken, 13 | } from '~/src/app/auth/jwt' 14 | 15 | import userFactory from '~/tests/factories/UserFactory' 16 | import getClient from '~/tests/utils/getClient' 17 | 18 | let client = getClient() 19 | let user 20 | 21 | const signinUser = gql ` 22 | mutation($data: SignInUserInput!) { 23 | signinUser( 24 | data: $data 25 | ){ 26 | user { 27 | uuid 28 | email 29 | username 30 | } 31 | token 32 | } 33 | } 34 | ` 35 | 36 | describe('signinUser', () => { 37 | beforeAll(async () => { 38 | user = await userFactory.create('user') 39 | }) 40 | 41 | afterAll(async () => { 42 | await knex.destroy() 43 | }) 44 | 45 | test('Should signin an existing User and return JWT token', async () => { 46 | const data = { 47 | email: user.email, 48 | password: user.email 49 | } 50 | 51 | const response = await client.mutate({ 52 | mutation: signinUser, 53 | variables: { 54 | data 55 | } 56 | }) 57 | 58 | const decodedToken = await decodeToken(response.data.signinUser.token) 59 | 60 | expect(decodedToken).toBeDefined() 61 | expect(decodedToken.email).toEqual(user.email) 62 | expect(response.data.signinUser.user.uuid).toEqual(user.uuid) 63 | expect(response.data.signinUser.user.email).toEqual(user.email) 64 | expect(response.data.signinUser.user.username).toEqual(user.username) 65 | }) 66 | 67 | test('Should not sign in with invalid credentials', async () => { 68 | const data = { 69 | email: 'wrong@email.com', 70 | password: 'wrong@email.com' 71 | } 72 | 73 | await expect( 74 | client.mutate({ 75 | mutation: signinUser, 76 | variables: { 77 | data 78 | } 79 | }) 80 | ).rejects.toThrow('GraphQL error: Invalid auth credentials') 81 | }) 82 | }) 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚀 Full Stack GraphQL Auth Boilerplate 2 | 3 | [![CircleCI](https://circleci.com/gh/pearce89/graphql-auth.svg?style=shield)](https://circleci.com/gh/pearce89/graphql-auth) 4 | 5 | 6 | 7 | ### Backend side: 8 | 9 | - [node](https://nodejs.org/en/) 10 | - [graphql](https://graphql.org/) with [apollo server](https://www.apollographql.com/docs/apollo-server/) 11 | - [graphql-shield](https://github.com/maticzav/graphql-shield) 12 | - [knex](https://knexjs.org/) 13 | - [objection](https://vincit.github.io/objection.js/) 14 | - [babel](https://babeljs.io/) 15 | - [jest](https://jestjs.io/) 16 | 17 | ### Frontend side: 18 | 19 | - [vue](https://vuejs.org) 20 | - [nuxt](https://nuxtjs.org) 21 | - [tailwindcss](https://tailwindcss.com) 22 | 23 | ### Devops part: 24 | 25 | - [circleci](https://circleci.com/gh/pearce89/workflows/graphql-auth) setup 26 | - automated [backend](https://graphql-auth-backend.herokuapp.com/) and [frontend](https://graphql-auth.herokuapp.com/) deploy to **heroku** 27 | 28 | ### Features: 29 | 30 | - Bits and pieces for your graphql schema with [schemaglue](https://github.com/nicolasdao/schemaglue) 31 | - Number of npm tasks for Rails fans: 32 | 33 | ``` 34 | npm run db:create 35 | npm run db:seed 36 | npm run db:migrate 37 | npm run db:clear 38 | npm run db:drop 39 | ``` 40 | 41 | - Nice and easy [permissions](https://github.com/pearce89/graphql-auth/blob/master/server/src/app/graphql/story/permissions.js) with [graphql-shield](https://github.com/maticzav/graphql-shield) 42 | - Errors [codes](https://github.com/pearce89/graphql-auth/tree/master/server/src/app/errors) logic 43 | - Separate [place](https://github.com/pearce89/graphql-auth/tree/master/server/src/app/services) for your business logic 44 | - [Easy setup](https://github.com/pearce89/graphql-auth/blob/master/docker-compose.yml) with Docker and Docker Compose 45 | 46 | --- 47 | 48 | ### [How to get up and running on local machine](https://github.com/pearce89/graphql-auth/wiki/How-to-get-up-and-running) 49 | 50 | -------------------------------------------------------------------------------- /client/components/Header.vue: -------------------------------------------------------------------------------- 1 | 54 | 55 | 73 | -------------------------------------------------------------------------------- /server/tests/graphql/story/deleteStory.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | 3 | import { 4 | knex 5 | } from '~/src/db/index' 6 | 7 | import { 8 | gql 9 | } from 'apollo-boost' 10 | 11 | import { 12 | generateToken 13 | } from '~/src/app/auth/jwt' 14 | 15 | import userFactory from '~/tests/factories/UserFactory' 16 | import storyFactory from '~/tests/factories/StoryFactory' 17 | import getClient from '~/tests/utils/getClient' 18 | 19 | let user, anotherUser, story, anotherStory, client, jwtToken 20 | 21 | const deleteStory = gql ` 22 | mutation($uuid: ID!) { 23 | deleteStory( 24 | uuid: $uuid 25 | ){ 26 | uuid 27 | title 28 | } 29 | } 30 | ` 31 | 32 | describe('deleteStory', () => { 33 | beforeAll(async () => { 34 | user = await userFactory.create('user') 35 | anotherUser = await userFactory.create('user') 36 | jwtToken = generateToken(user.id) 37 | 38 | story = await storyFactory.create('story', { 39 | userId: user.id 40 | }) 41 | 42 | anotherStory = await storyFactory.create('story', { 43 | userId: anotherUser.id 44 | }) 45 | }) 46 | 47 | afterAll(async () => { 48 | await knex.destroy() 49 | }) 50 | 51 | test('Should not update story without valid authentication', async () => { 52 | client = getClient() 53 | 54 | await expect( 55 | client.mutate({ 56 | mutation: deleteStory, 57 | variables: { 58 | uuid: story.uuid 59 | } 60 | }) 61 | ).rejects.toThrow('Authentication Required') 62 | }) 63 | 64 | test('Should not update another user\'s story', async () => { 65 | client = getClient(jwtToken) 66 | 67 | await expect( 68 | client.mutate({ 69 | mutation: deleteStory, 70 | variables: { 71 | uuid: anotherStory.uuid 72 | } 73 | }) 74 | ).rejects.toThrow('Access Denied') 75 | }) 76 | 77 | test('Should delete story', async () => { 78 | client = getClient(jwtToken) 79 | 80 | const response = await client.mutate({ 81 | mutation: deleteStory, 82 | variables: { 83 | uuid: story.uuid 84 | } 85 | }) 86 | 87 | expect(response.data.deleteStory.uuid).toEqual(story.uuid) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /server/tests/graphql/user/updateUser.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import faker from 'faker' 3 | 4 | import { 5 | knex 6 | } from '~/src/db/index' 7 | 8 | import { 9 | gql 10 | } from 'apollo-boost' 11 | 12 | import { 13 | generateToken 14 | } from '~/src/app/auth/jwt' 15 | 16 | import userFactory from '~/tests/factories/UserFactory' 17 | import getClient from '~/tests/utils/getClient' 18 | 19 | let user, client, jwtToken, updateData 20 | 21 | const updateUser = gql ` 22 | mutation($updateData: UpdateUserInput!) { 23 | updateUser( 24 | data: $updateData 25 | ){ 26 | email 27 | username 28 | } 29 | } 30 | ` 31 | 32 | describe('updateUser', () => { 33 | beforeAll(async () => { 34 | user = await userFactory.create('user') 35 | jwtToken = generateToken(user.id) 36 | }) 37 | 38 | afterAll(async () => { 39 | await knex.destroy() 40 | }) 41 | 42 | test('Should update current user', async () => { 43 | client = getClient(jwtToken) 44 | 45 | updateData = { 46 | email: faker.internet.email(), 47 | password: faker.internet.email(), 48 | username: faker.internet.userName() 49 | } 50 | 51 | let response = await client.mutate({ 52 | mutation: updateUser, 53 | variables: { 54 | updateData 55 | } 56 | }) 57 | 58 | expect(response.data.updateUser.email).toEqual(updateData.email) 59 | expect(response.data.updateUser.username).toEqual(updateData.username) 60 | 61 | const getCurrentUser = gql ` 62 | query { 63 | getCurrentUser { 64 | email 65 | username 66 | } 67 | } 68 | ` 69 | 70 | response = await client.query({ 71 | query: getCurrentUser 72 | }) 73 | 74 | expect(response.data.getCurrentUser.email).toEqual(updateData.email) 75 | expect(response.data.getCurrentUser.username).toEqual(updateData.username) 76 | }) 77 | 78 | test('Should not update current user without valid authentication', async () => { 79 | client = getClient() 80 | 81 | updateData = {} 82 | 83 | await expect( 84 | client.mutate({ 85 | mutation: updateUser, 86 | variables: { 87 | updateData 88 | } 89 | }) 90 | ).rejects.toThrow('Authentication Required') 91 | }) 92 | }) 93 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "1.0.0", 4 | "description": "My gnarly Nuxt.js project", 5 | "author": "Sergei Anisimov", 6 | "license": "UNLICENSED", 7 | "private": true, 8 | "scripts": { 9 | "dev": "nuxt", 10 | "build": "nuxt build", 11 | "start": "nuxt start", 12 | "generate": "nuxt generate", 13 | "lint": "eslint --ext .js,.vue --ignore-path .gitignore .", 14 | "precommit": "npm run lint", 15 | "test": "jest --watchAll --runInBand --silent", 16 | "test-debugger": "node --inspect-brk node_modules/.bin/jest --runInBand", 17 | "jest": "jest --ci --runInBand --silent --reporters=default --reporters=jest-junit", 18 | "heroku-postbuild": "npm run build", 19 | "heroku-deploy": "cd ../ && git subtree push --prefix client heroku-client master || true" 20 | }, 21 | "jest": { 22 | "reporters": [ 23 | "default", 24 | "jest-junit" 25 | ], 26 | "verbose": true 27 | }, 28 | "dependencies": { 29 | "@nuxtjs/apollo": "^4.0.0-rc8", 30 | "@nuxtjs/dotenv": "^1.3.0", 31 | "apollo-boost": "^0.4.1", 32 | "cross-env": "^5.2.0", 33 | "cross-fetch": "^3.0.4", 34 | "graphql": "^14.4.1", 35 | "graphql-tag": "^2.10.1", 36 | "js-cookie": "^2.2.0", 37 | "jwt-decode": "^2.2.0", 38 | "nuxt": "^2.8.1", 39 | "nuxt-purgecss": "^0.2.1", 40 | "tailwindcss": "^1.0.4", 41 | "vue-moment": "^4.0.0", 42 | "vuejs-paginate": "^2.1.0", 43 | "vuelidate": "^0.7.4" 44 | }, 45 | "devDependencies": { 46 | "@nuxtjs/eslint-config": "^0.0.1", 47 | "@vue/test-utils": "^1.0.0-beta.27", 48 | "babel-core": "7.0.0-bridge.0", 49 | "babel-eslint": "^10.0.2", 50 | "babel-jest": "^24.8.0", 51 | "eslint": "^5.16.0", 52 | "eslint-config-prettier": "^4.3.0", 53 | "eslint-config-standard": ">=12.0.0", 54 | "eslint-loader": "^2.1.2", 55 | "eslint-plugin-import": "^2.18.0", 56 | "eslint-plugin-jest": "^22.7.1", 57 | "eslint-plugin-node": "^9.1.0", 58 | "eslint-plugin-nuxt": ">=0.4.2", 59 | "eslint-plugin-prettier": "^3.1.0", 60 | "eslint-plugin-promise": "^4.2.1", 61 | "eslint-plugin-standard": ">=4.0.0", 62 | "eslint-plugin-vue": "^5.2.3", 63 | "jest": "^24.8.0", 64 | "jest-junit": "^6.4.0", 65 | "node-sass": "^4.12.0", 66 | "nodemon": "^1.19.1", 67 | "prettier": "^1.18.2", 68 | "sass-loader": "^7.1.0", 69 | "vue-jest": "^3.0.3" 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /server/src/app/errors/models/UserErrors.js: -------------------------------------------------------------------------------- 1 | import ApplicationError from '~/src/app/errors/ApplicationError' 2 | 3 | class UserNotFoundError extends ApplicationError { 4 | constructor({ 5 | message, 6 | code, 7 | data 8 | }) { 9 | const errorMessage = message || 'User not found' 10 | const errorCode = code || 'USER_NOT_FOUND_ERROR' 11 | const errorData = data || {} 12 | 13 | super({ 14 | message: errorMessage, 15 | code: errorCode, 16 | data: errorData 17 | }) 18 | } 19 | } 20 | 21 | class UserValidationError extends ApplicationError { 22 | constructor({ 23 | message, 24 | code, 25 | data 26 | }) { 27 | const errorMessage = message || 'Invalid User Data' 28 | const errorCode = code || 'VALIDATION_ERROR' 29 | const errorData = data || {} 30 | 31 | super({ 32 | message: errorMessage, 33 | code: errorCode, 34 | data: errorData 35 | }) 36 | } 37 | } 38 | 39 | class InvalidAuthCredentialsError extends ApplicationError { 40 | constructor({ 41 | message, 42 | code, 43 | data 44 | }) { 45 | const errorMessage = message || 'Invalid auth credentials' 46 | const errorCode = code || 'INVALID_AUTH_CREDENTIALS_ERROR' 47 | const errorData = data || {} 48 | 49 | super({ 50 | message: errorMessage, 51 | code: errorCode, 52 | data: errorData 53 | }) 54 | } 55 | } 56 | 57 | class AuthenticationRequiredError extends ApplicationError { 58 | constructor({ 59 | message, 60 | code, 61 | data 62 | }) { 63 | const errorMessage = message || 'Authentication Required' 64 | const errorCode = code || 'AUTHENTICATION_REQUIRED_ERROR' 65 | const errorData = data || {} 66 | 67 | super({ 68 | message: errorMessage, 69 | code: errorCode, 70 | data: errorData 71 | }) 72 | } 73 | } 74 | 75 | class AccessDeniedError extends ApplicationError { 76 | constructor({ 77 | message, 78 | code, 79 | data 80 | }) { 81 | const errorMessage = message || 'Access Denied' 82 | const errorCode = code || 'ACCESS_DENIED_ERROR' 83 | const errorData = data || {} 84 | 85 | super({ 86 | message: errorMessage, 87 | code: errorCode, 88 | data: errorData 89 | }) 90 | } 91 | } 92 | 93 | export { 94 | UserNotFoundError, 95 | UserValidationError, 96 | InvalidAuthCredentialsError, 97 | AuthenticationRequiredError, 98 | AccessDeniedError 99 | } 100 | -------------------------------------------------------------------------------- /server/tests/graphql/story/updateStory.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import faker from 'faker' 3 | 4 | import { 5 | knex 6 | } from '~/src/db/index' 7 | 8 | import { 9 | gql 10 | } from 'apollo-boost' 11 | 12 | import { 13 | generateToken 14 | } from '~/src/app/auth/jwt' 15 | 16 | import userFactory from '~/tests/factories/UserFactory' 17 | import storyFactory from '~/tests/factories/StoryFactory' 18 | import getClient from '~/tests/utils/getClient' 19 | 20 | let user, anotherUser, story, anotherStory, client, jwtToken, updateData 21 | 22 | const updateStory = gql ` 23 | mutation($updateData: UpdateStoryInput!) { 24 | updateStory( 25 | data: $updateData 26 | ){ 27 | uuid 28 | title 29 | user { 30 | email 31 | } 32 | } 33 | } 34 | ` 35 | 36 | describe('updateStory', () => { 37 | beforeAll(async () => { 38 | user = await userFactory.create('user') 39 | anotherUser = await userFactory.create('user') 40 | jwtToken = generateToken(user.id) 41 | 42 | story = await storyFactory.create('story', { 43 | userId: user.id 44 | }) 45 | 46 | anotherStory = await storyFactory.create('story', { 47 | userId: anotherUser.id 48 | }) 49 | }) 50 | 51 | afterAll(async () => { 52 | await knex.destroy() 53 | }) 54 | 55 | test('Should update story', async () => { 56 | client = getClient(jwtToken) 57 | 58 | updateData = { 59 | uuid: story.uuid, 60 | title: faker.lorem.sentence() 61 | } 62 | 63 | const response = await client.mutate({ 64 | mutation: updateStory, 65 | variables: { 66 | updateData 67 | } 68 | }) 69 | 70 | expect(response.data.updateStory.title).toEqual(updateData.title) 71 | }) 72 | 73 | test('Should not update story without valid authentication', async () => { 74 | client = getClient() 75 | 76 | updateData = { 77 | uuid: story.uuid, 78 | title: faker.lorem.sentence() 79 | } 80 | 81 | await expect( 82 | client.mutate({ 83 | mutation: updateStory, 84 | variables: { 85 | updateData 86 | } 87 | }) 88 | ).rejects.toThrow('Authentication Required') 89 | }) 90 | 91 | test('Should not update another user\'s story', async () => { 92 | client = getClient(jwtToken) 93 | 94 | updateData = { 95 | uuid: anotherStory.uuid, 96 | title: faker.lorem.sentence() 97 | } 98 | 99 | await expect( 100 | client.mutate({ 101 | mutation: updateStory, 102 | variables: { 103 | updateData 104 | } 105 | }) 106 | ).rejects.toThrow('Access Denied') 107 | }) 108 | }) 109 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "engines": { 7 | "node": "11.x" 8 | }, 9 | "scripts": { 10 | "dev": "nodemon -L --exec babel-node --inspect ./src/index.js --ext js,gql,env | pino-pretty -t -c -f -l", 11 | "test": "NODE_ENV=test jest --watchAll --runInBand --silent", 12 | "test-debugger": "NODE_ENV=test node --inspect-brk node_modules/.bin/jest --runInBand", 13 | "jest": "NODE_ENV=test jest --ci --runInBand --silent --reporters=default --reporters=jest-junit", 14 | "build": "babel src --out-dir dist --copy-files", 15 | "start": "node dist/index.js", 16 | "knex": "babel-node node_modules/.bin/knex --knexfile ./src/db/config.js", 17 | "knex-migrate": "babel-node node_modules/.bin/knex-migrate --knexfile ./src/db/config.js", 18 | "heroku-knex": "knex --knexfile ./dist/db/config.js", 19 | "heroku-migrate": "knex-migrate --knexfile ./dist/db/config.js", 20 | "heroku-deploy": "cd ../ && git subtree push --prefix server heroku-server master || true", 21 | "lint": "eslint --ext .js --ignore-path .gitignore .", 22 | "precommit": "npm run lint", 23 | "db:create": "run-func src/db/tasks.js createDb", 24 | "db:clear": "run-func src/db/tasks.js truncateDb", 25 | "db:drop": "run-func src/db/tasks.js dropDb", 26 | "db:migrate": "babel-node node_modules/.bin/knex-migrate --knexfile ./src/db/config.js", 27 | "db:seed": "babel-node node_modules/.bin/knex --knexfile ./src/db/config.js seed:run" 28 | }, 29 | "jest": { 30 | "globalSetup": "./tests/jest/globalSetup.js", 31 | "globalTeardown": "./tests/jest/globalTeardown.js", 32 | "reporters": [ 33 | "default", 34 | "jest-junit" 35 | ], 36 | "verbose": true 37 | }, 38 | "keywords": [], 39 | "author": "Sergei Anisimov", 40 | "license": "UNLICENSED", 41 | "private": true, 42 | "dependencies": { 43 | "apollo-boost": "^0.4.1", 44 | "apollo-server": "^2.6.7", 45 | "bcrypt": "^3.0.6", 46 | "cross-fetch": "^3.0.2", 47 | "dotenv": "^7.0.0", 48 | "faker": "^4.1.0", 49 | "graphql": "^14.4.2", 50 | "graphql-middleware": "^3.0.2", 51 | "graphql-shield": "^5.7.3", 52 | "graphql-tools": "^4.0.5", 53 | "jsonwebtoken": "^8.5.1", 54 | "knex": "^0.19.5", 55 | "knex-db-manager": "^0.5.0", 56 | "knex-migrate": "^1.7.2", 57 | "lodash": "^4.17.14", 58 | "objection": "^1.6.8", 59 | "objection-before-and-unique": "^0.5.0", 60 | "objection-guid": "^3.0.2", 61 | "password-validator": "^4.1.1", 62 | "pg": "^7.11.0", 63 | "pg-escape": "^0.2.0", 64 | "pino": "^5.12.3", 65 | "pino-pretty": "^2.6.1", 66 | "run-func": "^1.0.2", 67 | "schemaglue": "^4.0.4", 68 | "uuid": "^3.3.2", 69 | "validator": "^10.11.0" 70 | }, 71 | "devDependencies": { 72 | "@babel/cli": "^7.4.4", 73 | "@babel/core": "^7.4.5", 74 | "@babel/node": "^7.4.5", 75 | "@babel/plugin-proposal-class-properties": "^7.4.4", 76 | "@babel/plugin-proposal-optional-chaining": "^7.2.0", 77 | "@babel/polyfill": "^7.4.4", 78 | "@babel/preset-env": "^7.4.5", 79 | "@babel/register": "^7.4.4", 80 | "babel-eslint": "^10.0.2", 81 | "babel-plugin-root-import": "^6.2.0", 82 | "eslint": "^5.16.0", 83 | "eslint-config-prettier": "^4.3.0", 84 | "eslint-plugin-node": "^8.0.1", 85 | "eslint-plugin-prettier": "^3.1.0", 86 | "factory-girl": "^5.0.4", 87 | "factory-girl-objection-adapter": "^1.0.0", 88 | "get-port": "^4.2.0", 89 | "jest": "^24.8.0", 90 | "jest-junit": "^6.4.0", 91 | "nodemon": "^1.19.1", 92 | "prettier": "^1.18.2" 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /.circleci/config.yml: -------------------------------------------------------------------------------- 1 | defaults: &defaults 2 | working_directory: ~/graphql-auth 3 | docker: 4 | - image: circleci/node:11.12.0 5 | - image: circleci/postgres:10 6 | environment: 7 | POSTGRES_USER: circleci 8 | POSTGRES_DB: circleci 9 | environment: 10 | NODE_ENV: test 11 | DATABASE_URL: postgresql://circleci@localhost:5432/circleci 12 | 13 | version: 2 14 | jobs: 15 | test_server: 16 | <<: *defaults 17 | steps: 18 | - checkout 19 | - restore_cache: 20 | key: v1-deps-{{ checksum "server/package.json" }} 21 | - run: cd server && npm install 22 | - save_cache: 23 | paths: 24 | - node_modules 25 | key: v1-deps-{{ checksum "server/package.json" }} 26 | - run: 27 | name: Run tests with JUnit as reporter 28 | command: cd server && npm run jest 29 | environment: 30 | JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml" 31 | - store_test_results: 32 | path: server/reports/junit 33 | - store_artifacts: 34 | path: server/reports/junit 35 | 36 | test_client: 37 | <<: *defaults 38 | steps: 39 | - checkout 40 | - restore_cache: 41 | key: v1-deps-{{ checksum "client/package.json" }} 42 | - run: cd client && npm install 43 | - save_cache: 44 | paths: 45 | - node_modules 46 | key: v1-deps-{{ checksum "client/package.json" }} 47 | - run: 48 | name: Run tests with JUnit as reporter 49 | command: cd client && npm run jest 50 | environment: 51 | JEST_JUNIT_OUTPUT: "reports/junit/js-test-results.xml" 52 | - store_test_results: 53 | path: client/reports/junit 54 | - store_artifacts: 55 | path: client/reports/junit 56 | 57 | deploy_server: 58 | <<: *defaults 59 | steps: 60 | - checkout 61 | - run: bash .circleci/setup-heroku.sh 62 | - add_ssh_keys: 63 | fingerprints: 64 | - $HEROKU_SSH_FINGERPRINT 65 | - run: 66 | name: Deploy to Heroku 67 | command: | 68 | git push git@heroku.com:$HEROKU_SERVER_APP_NAME.git `git subtree split --prefix server master`:master --force 69 | - run: 70 | name: Migrate Database 71 | command: | 72 | cd server && heroku run -a $HEROKU_SERVER_APP_NAME knex-migrate --knexfile ./dist/db/config.js up 73 | - run: 74 | name: Seed Database 75 | command: | 76 | cd server && heroku run -a $HEROKU_SERVER_APP_NAME knex --knexfile ./dist/db/config.js seed:run 77 | 78 | deploy_client: 79 | <<: *defaults 80 | steps: 81 | - checkout 82 | - run: bash .circleci/setup-heroku.sh 83 | - add_ssh_keys: 84 | fingerprints: 85 | - $HEROKU_SSH_FINGERPRINT 86 | - run: 87 | name: Deploy to Heroku 88 | command: | 89 | git push git@heroku.com:$HEROKU_CLIENT_APP_NAME.git `git subtree split --prefix client master`:master --force 90 | 91 | workflows: 92 | version: 2 93 | build-test-deploy: 94 | jobs: 95 | - test_server 96 | - test_client 97 | - deploy_server: 98 | filters: 99 | branches: 100 | only: 101 | - master 102 | requires: 103 | - test_server 104 | - deploy_client: 105 | filters: 106 | branches: 107 | only: 108 | - master 109 | requires: 110 | - test_client 111 | -------------------------------------------------------------------------------- /client/nuxt.config.js: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import pkg from './package' 3 | 4 | const environment = process.env.NODE_ENV || 'development' 5 | require('dotenv').config({ 6 | path: __dirname + `/config/${environment}.env` 7 | }) 8 | 9 | export default { 10 | mode: 'universal', 11 | /* 12 | ** Headers of the page 13 | */ 14 | head: { 15 | title: pkg.name, 16 | meta: [ 17 | { charset: 'utf-8' }, 18 | { name: 'viewport', content: 'width=device-width, initial-scale=1' }, 19 | { hid: 'description', name: 'description', content: pkg.description } 20 | ], 21 | link: [{ rel: 'icon', type: 'image/x-icon', href: '/favicon.ico' }] 22 | }, 23 | 24 | /* 25 | ** Customize the progress-bar color 26 | */ 27 | loading: { color: '#4299e1' }, 28 | 29 | /* 30 | ** Global CSS 31 | */ 32 | css: [ 33 | '@/assets/styles/tailwind.pcss', 34 | { 35 | src :'@/assets/styles/main.scss', 36 | lang: 'scss' 37 | } 38 | ], 39 | 40 | /* 41 | ** Plugins to load before mounting the App 42 | */ 43 | plugins: [ 44 | { src: '~/plugins/vuelidate' }, 45 | { src: '~/plugins/vue-moment' } 46 | ], 47 | 48 | /* 49 | ** Nuxt.js modules 50 | */ 51 | modules: [ 52 | '@nuxtjs/apollo', 53 | 'nuxt-purgecss', 54 | ['@nuxtjs/dotenv', { 55 | path: 'config', 56 | filename: `${environment}.env`, 57 | systemvars: true 58 | }] 59 | ], 60 | 61 | // Give apollo module options 62 | apollo: { 63 | // required 64 | clientConfigs: { 65 | default: { 66 | // required 67 | httpEndpoint: process.env.API_URL || 'http://server.graphql-auth.local:4000', 68 | // You can use `wss` for secure connection (recommended in production) 69 | wsEndpoint: null, 70 | // LocalStorage token 71 | tokenName: 'jwt' // optional 72 | } 73 | } 74 | }, 75 | 76 | env: { 77 | apiUrl: process.env.API_URL || 'http://server.graphql-auth.local:4000' 78 | }, 79 | 80 | /* 81 | ** PurgeCSS 82 | ** https://github.com/Developmint/nuxt-purgecss 83 | */ 84 | purgeCSS: {}, 85 | 86 | /* 87 | ** This option is given directly to the vue-router Router constructor 88 | */ 89 | router: { 90 | base: '', 91 | linkActiveClass: 'is-active', 92 | middleware: ['setup-auth'] 93 | }, 94 | 95 | watchers: { 96 | webpack: { 97 | aggregateTimeout: 300, 98 | poll: 1000 99 | } 100 | }, 101 | 102 | /* 103 | ** Build configuration 104 | */ 105 | build: { 106 | /* 107 | ** PostCSS setup 108 | */ 109 | postcss: { 110 | // Add plugin names as key and arguments as value 111 | // Disable a plugin by passing false as value 112 | plugins: { 113 | 'postcss-url': {}, 114 | tailwindcss: path.resolve(__dirname, './tailwind.config.js'), 115 | cssnano: { 116 | preset: 'default', 117 | discardComments: { removeAll: true }, 118 | zIndex: false 119 | } 120 | }, 121 | // Change the postcss-preset-env settings 122 | preset: { 123 | stage: 0, 124 | autoprefixer: { 125 | cascade: false, 126 | grid: true 127 | } 128 | } 129 | }, 130 | 131 | /* 132 | ** You can extend webpack config here 133 | */ 134 | extend(config, ctx) { 135 | // Run ESLint on save 136 | if (ctx.isDev && ctx.isClient) { 137 | config.module.rules.push({ 138 | enforce: 'pre', 139 | test: /\.(js|vue)$/, 140 | loader: 'eslint-loader', 141 | exclude: /(node_modules)/ 142 | }) 143 | } 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /client/pages/stories/index.vue: -------------------------------------------------------------------------------- 1 | 60 | 61 | 149 | -------------------------------------------------------------------------------- /server/tests/graphql/user/createUser.test.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import faker from 'faker' 3 | 4 | import { 5 | knex 6 | } from '~/src/db/index' 7 | 8 | import { 9 | gql 10 | } from 'apollo-boost' 11 | 12 | import { 13 | decodeToken, 14 | } from '~/src/app/auth/jwt' 15 | 16 | import getClient from '~/tests/utils/getClient' 17 | 18 | let client = getClient() 19 | let email = faker.internet.email() 20 | let username = faker.internet.userName() 21 | 22 | const createUser = gql ` 23 | mutation($data: CreateUserInput!) { 24 | createUser( 25 | data: $data 26 | ){ 27 | user { 28 | uuid 29 | email 30 | username 31 | } 32 | token 33 | } 34 | } 35 | ` 36 | 37 | describe('createUser', () => { 38 | afterAll(async () => { 39 | await knex.destroy() 40 | }) 41 | test('Should create a new User and return a JWT token', async () => { 42 | const data = { 43 | email, 44 | username, 45 | password: email 46 | } 47 | 48 | const response = await client.mutate({ 49 | mutation: createUser, 50 | variables: { 51 | data 52 | } 53 | }) 54 | 55 | const decodedToken = await decodeToken(response.data.createUser.token) 56 | 57 | expect(decodedToken).toBeDefined() 58 | expect(decodedToken.email).toEqual(email) 59 | expect(response.data.createUser.user.email).toEqual(email) 60 | expect(response.data.createUser.user.username).toEqual(username) 61 | }) 62 | 63 | test('Should not create user with invalid data', async () => { 64 | const data = { 65 | email: 'wrongemail', 66 | password: 'wrongpassword', 67 | username 68 | } 69 | 70 | try { 71 | await client.mutate({ 72 | mutation: createUser, 73 | variables: { 74 | data 75 | } 76 | }) 77 | } catch (error) { 78 | expect(error.graphQLErrors).toEqual( 79 | expect.arrayContaining([ 80 | expect.objectContaining({ 81 | extensions: expect.objectContaining({ 82 | code: 'VALIDATION_ERROR', 83 | exception: { 84 | data: expect.arrayContaining([ 85 | expect.objectContaining({ 86 | 'key': 'email', 87 | 'keyword': 'invalid', 88 | 'message': 'Email should have a valid format' 89 | }), 90 | expect.objectContaining({ 91 | 'key': 'password', 92 | 'keyword': 'uppercase', 93 | 'message': 'Password should have uppercase characters' 94 | }), 95 | expect.objectContaining({ 96 | 'key': 'username', 97 | 'keyword': 'unique', 98 | 'message': 'Username have been already taken' 99 | }) 100 | ]) 101 | } 102 | }) 103 | }) 104 | ]) 105 | ) 106 | } 107 | }) 108 | 109 | test('Should not create user with the same email', async () => { 110 | const data = { 111 | email, 112 | password: email, 113 | username: faker.internet.userName() 114 | } 115 | 116 | try { 117 | await client.mutate({ 118 | mutation: createUser, 119 | variables: { 120 | data 121 | } 122 | }) 123 | } catch (error) { 124 | expect(error.graphQLErrors).toEqual( 125 | expect.arrayContaining([ 126 | expect.objectContaining({ 127 | extensions: expect.objectContaining({ 128 | code: 'VALIDATION_ERROR', 129 | exception: { 130 | data: expect.arrayContaining([ 131 | expect.objectContaining({ 132 | 'key': 'email', 133 | 'keyword': 'unique', 134 | 'message': 'Email have been already taken' 135 | }) 136 | ]) 137 | } 138 | }) 139 | }) 140 | ]) 141 | ) 142 | } 143 | }) 144 | }) 145 | -------------------------------------------------------------------------------- /server/src/app/models/User/Validation.js: -------------------------------------------------------------------------------- 1 | import passwordValidator from 'password-validator' 2 | import validator from 'validator' 3 | import _ from 'lodash' 4 | 5 | import { 6 | User 7 | } from '~/src/app/models/User/Model' 8 | 9 | const passwordSchema = new passwordValidator() 10 | 11 | passwordSchema 12 | .is().min(8) 13 | .is().max(64) 14 | .has().not().spaces() 15 | .has().uppercase() 16 | .has().lowercase() 17 | // .has().digits() 18 | 19 | 20 | const passwordErrorsMessages = { 21 | min: 'Password should be at least 8 charachters long', 22 | max: 'Password should be be a maximum of 64 characters long', 23 | uppercase: 'Password should have uppercase characters', 24 | lowercase: 'Password should have lowercase characters', 25 | digits: 'Password should contain digits', 26 | spaces: 'Password should not contain spaces', 27 | blank: 'Password should not be blank' 28 | } 29 | 30 | const emailErrorsMessages = { 31 | blank: 'Email should not be blank', 32 | invalid: 'Email should have a valid format', 33 | unique: 'Email have been already taken' 34 | } 35 | 36 | const usernameErrorsMessages = { 37 | blank: 'Username should not be blank', 38 | invalid: 'Username should have a valid format', 39 | unique: 'Username have been already taken' 40 | } 41 | 42 | const validateEmail = async ({ 43 | instance, 44 | old 45 | }) => { 46 | let emailErrors = [] 47 | 48 | if (!instance.email && !old) { 49 | return [{ 50 | key: 'email', 51 | keyword: 'missing', 52 | message: emailErrorsMessages['blank'] 53 | }] 54 | } else if (!instance.email && old) { 55 | return [] 56 | } else { 57 | if (!validator.isEmail(instance.email)) { 58 | emailErrors.push({ 59 | key: 'email', 60 | keyword: 'invalid', 61 | message: 'Email should have a valid format' 62 | }) 63 | } 64 | 65 | const user = await User 66 | .query() 67 | .skipUndefined() 68 | .where('email', instance.email) 69 | .whereNot('id', _.get(old, 'id', undefined)) 70 | .first() 71 | 72 | if (user) { 73 | emailErrors.push({ 74 | key: 'email', 75 | keyword: 'unique', 76 | message: 'Email have been already taken' 77 | }) 78 | } 79 | } 80 | 81 | return emailErrors 82 | } 83 | 84 | const validateUsername = async ({ 85 | instance, 86 | old 87 | }) => { 88 | let usernameErrors = [] 89 | 90 | if (!instance.username && !old) { 91 | return [{ 92 | key: 'username', 93 | keyword: 'missing', 94 | message: usernameErrorsMessages['blank'] 95 | }] 96 | } else if (!instance.username && old) { 97 | return [] 98 | } else { 99 | const user = await User 100 | .query() 101 | .skipUndefined() 102 | .where('username', instance.username) 103 | .whereNot('id', _.get(old, 'id', undefined)) 104 | .first() 105 | 106 | if (user) { 107 | usernameErrors.push({ 108 | key: 'username', 109 | keyword: 'unique', 110 | message: 'Username have been already taken' 111 | }) 112 | } 113 | } 114 | 115 | return usernameErrors 116 | } 117 | 118 | const validatePassword = ({ 119 | instance, 120 | allowToSkip 121 | }) => { 122 | if (!instance.password) { 123 | if (allowToSkip) { 124 | return [] 125 | } else { 126 | return [{ 127 | key: 'password', 128 | keyword: 'missing', 129 | message: passwordErrorsMessages['blank'] 130 | }] 131 | } 132 | } 133 | 134 | const passwordErrors = passwordSchema.validate(instance.password, { 135 | list: true 136 | }) 137 | 138 | if (Array.isArray(passwordErrors) && passwordErrors.length) { 139 | return passwordErrors.map((error) => { 140 | return { 141 | key: 'password', 142 | keyword: error, 143 | message: passwordErrorsMessages[error] 144 | } 145 | }) 146 | } else { 147 | return [] 148 | } 149 | } 150 | 151 | const userValidate = async ({ 152 | instance, 153 | old, 154 | operation 155 | }) => { 156 | let errors = [] 157 | 158 | const passwordErrors = validatePassword({ 159 | instance, 160 | allowToSkip: !(operation === 'insert') 161 | }) 162 | const emailErrors = await validateEmail({ 163 | instance, 164 | old 165 | }) 166 | 167 | const usernameErrors = await validateUsername({ 168 | instance, 169 | old 170 | }) 171 | 172 | errors.push(...passwordErrors, ...emailErrors, ...usernameErrors) 173 | 174 | return errors 175 | } 176 | 177 | export default userValidate 178 | -------------------------------------------------------------------------------- /client/store/users.js: -------------------------------------------------------------------------------- 1 | import 'cross-fetch/polyfill' 2 | import Cookie from 'js-cookie' 3 | import jwtDecode from 'jwt-decode' 4 | import { SIGNIN_USER, CREATE_USER, UPDATE_USER } from '~/queries/users' 5 | import { saveUserData, clearUserData } from '~/utils' 6 | 7 | export const state = () => { 8 | return { 9 | token: '', 10 | userEmail: '', 11 | userUuid: '', 12 | authError: null, 13 | userErrors: [] 14 | } 15 | } 16 | 17 | export const mutations = { 18 | setUserUuid(state, userUuid) { 19 | state.userUuid = userUuid 20 | }, 21 | 22 | setUserEmail(state, userEmail) { 23 | state.userEmail = userEmail 24 | }, 25 | 26 | setToken(state, token) { 27 | state.token = token 28 | }, 29 | 30 | setError(state, payload) { 31 | state.authError = payload 32 | }, 33 | 34 | setUserErrors(state, payload) { 35 | state.userErrors = payload 36 | }, 37 | 38 | resetUserError(state, payload) { 39 | state.userErrors = state.userErrors.filter((error) => { 40 | return error.key !== payload 41 | }) 42 | }, 43 | 44 | resetError(state) { 45 | state.authError = null 46 | }, 47 | 48 | clearToken: state => (state.token = ''), 49 | clearUserEmail: state => (state.userEmail = null), 50 | clearUserUuid: state => (state.userUuid = null), 51 | clearUserErrors: state => (state.userErrors = []), 52 | clearAuthError: state => (state.authError = null) 53 | } 54 | 55 | export const actions = { 56 | clearUserErrors({ commit }) { 57 | commit('clearUserErrors') 58 | }, 59 | 60 | resetUserError({ commit }, userErrorKey) { 61 | commit('resetUserError', userErrorKey) 62 | }, 63 | 64 | async updateUser({ commit }, userPayload) { 65 | try { 66 | const { data } = await this.app.apolloProvider.defaultClient.mutate({ 67 | mutation: UPDATE_USER, 68 | variables: { 69 | data: { 70 | username: userPayload.username, 71 | email: userPayload.email, 72 | password: userPayload.password 73 | } 74 | } 75 | }) 76 | 77 | commit('setUserEmail', data.updateUser.email) 78 | commit('clearUserErrors') 79 | localStorage.setItem('userEmail', data.updateUser.email) 80 | Cookie.set('userEmail', data.updateUser.email) 81 | } catch (err) { 82 | const errorDetails = err.graphQLErrors.map(function(serverError) { 83 | return serverError.extensions.exception.data.map(function(serverErrorDetail) { 84 | return { 85 | code: serverError.extensions.code, 86 | key: serverErrorDetail.key, 87 | keyword: serverErrorDetail.keyword, 88 | message: serverErrorDetail.message 89 | } 90 | }) 91 | }).flat() 92 | commit('setUserErrors', errorDetails) 93 | } 94 | }, 95 | 96 | async createUser({ commit }, userPayload) { 97 | try { 98 | const { data } = await this.app.apolloProvider.defaultClient.mutate({ 99 | mutation: CREATE_USER, 100 | variables: { 101 | data: { 102 | username: userPayload.username, 103 | email: userPayload.email, 104 | password: userPayload.password 105 | } 106 | } 107 | }) 108 | 109 | commit('setUserEmail', data.createUser.user.email) 110 | commit('setToken', data.createUser.token) 111 | saveUserData(data.createUser) 112 | } catch (err) { 113 | const errorDetails = err.graphQLErrors.map(function(serverError) { 114 | return serverError.extensions.exception.data.map(function(serverErrorDetail) { 115 | return { 116 | code: serverError.extensions.code, 117 | key: serverErrorDetail.key, 118 | keyword: serverErrorDetail.keyword, 119 | message: serverErrorDetail.message 120 | } 121 | }) 122 | }).flat() 123 | commit('setUserErrors', errorDetails) 124 | } 125 | }, 126 | 127 | async authenticateUser({ commit }, userPayload) { 128 | try { 129 | const { data } = await this.app.apolloProvider.defaultClient.mutate({ 130 | mutation: SIGNIN_USER, 131 | variables: { 132 | data: { 133 | email: userPayload.email, 134 | password: userPayload.password 135 | } 136 | } 137 | }) 138 | 139 | commit('setUserEmail', data.signinUser.user.email) 140 | commit('setToken', data.signinUser.token) 141 | saveUserData(data.signinUser) 142 | } catch (err) { 143 | commit('setError', err.graphQLErrors[0]) 144 | } 145 | }, 146 | 147 | logoutUser({ commit }) { 148 | commit('clearToken') 149 | commit('clearUserEmail') 150 | commit('clearUserUuid') 151 | commit('clearUserErrors') 152 | commit('clearAuthError') 153 | clearUserData() 154 | } 155 | } 156 | 157 | export const getters = { 158 | isAuthenticated: (state) => { 159 | if (state.token) { 160 | try { 161 | const tokenExpire = new Date(jwtDecode(state.token).exp * 1000) 162 | if (new Date() < tokenExpire) { 163 | return true 164 | } else { 165 | return false 166 | } 167 | } catch (error) { 168 | return false 169 | } 170 | } else { 171 | return false 172 | } 173 | }, 174 | authError: state => state.authError, 175 | userErrors: state => state.userErrors, 176 | userEmail: state => state.userEmail, 177 | userUuid: state => state.userUuid 178 | } 179 | -------------------------------------------------------------------------------- /client/pages/users/profile.vue: -------------------------------------------------------------------------------- 1 | 110 | 111 | 251 | -------------------------------------------------------------------------------- /client/components/StoriesList.vue: -------------------------------------------------------------------------------- 1 | 181 | 182 | 270 | -------------------------------------------------------------------------------- /client/components/AuthForm.vue: -------------------------------------------------------------------------------- 1 | 148 | 149 | 280 | --------------------------------------------------------------------------------