Browse Source

feat: register validation + create + admin improvements

Nicolas Giard 6 years ago
parent
commit
901dbb98e0

+ 4 - 0
.editorconfig

@@ -9,3 +9,7 @@ insert_final_newline = true
 
 [*.{jade,pug,md}]
 trim_trailing_whitespace = false
+
+[Makefile]
+indent_style = tab
+indent_size = 4

+ 1 - 0
Makefile

@@ -30,6 +30,7 @@ docker-dev-down: ## Shutdown dockerized dev environment
 	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down
 
 docker-dev-rebuild: ## Rebuild dockerized dev image
+	rm -rf ./node_modules
 	docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm
 
 docker-build: ## Run assets generation build in docker

+ 2 - 0
client/client-app.js

@@ -163,10 +163,12 @@ Vue.component('admin', () => import(/* webpackChunkName: "admin" */ './component
 Vue.component('editor', () => import(/* webpackPrefetch: -100, webpackChunkName: "editor" */ './components/editor.vue'))
 Vue.component('history', () => import(/* webpackChunkName: "history" */ './components/history.vue'))
 Vue.component('page-source', () => import(/* webpackChunkName: "source" */ './components/source.vue'))
+Vue.component('loader', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/loader.vue'))
 Vue.component('login', () => import(/* webpackPrefetch: true, webpackChunkName: "login" */ './components/login.vue'))
 Vue.component('nav-header', () => import(/* webpackMode: "eager" */ './components/common/nav-header.vue'))
 Vue.component('page-selector', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/page-selector.vue'))
 Vue.component('profile', () => import(/* webpackChunkName: "profile" */ './components/profile.vue'))
+Vue.component('register', () => import(/* webpackChunkName: "register" */ './components/register.vue'))
 Vue.component('v-card-chin', () => import(/* webpackPrefetch: true, webpackChunkName: "ui-extra" */ './components/common/v-card-chin.vue'))
 
 Vue.component('nav-footer', () => import(/* webpackChunkName: "theme-page"  */ './themes/' + process.env.CURRENT_THEME + '/components/nav-footer.vue'))

+ 12 - 0
client/components/admin/admin-auth.vue

@@ -151,6 +151,18 @@
                       multiple
                       chips
                       )
+                  template(v-if='strategy.key === `local`')
+                    v-divider.mt-3
+                    v-subheader.pl-0 Security
+                    .pr-3
+                      v-switch.ml-3(
+                        :disabled='true'
+                        v-model='strategy.recaptcha'
+                        label='Use reCAPTCHA by Google'
+                        color='primary'
+                        hint='Protects against spam robots and malicious registrations.'
+                        persistent-hint
+                      )
 </template>
 
 <script>

+ 53 - 1
client/components/admin/admin-contribute.vue

@@ -51,7 +51,7 @@
             .subheading Sponsors
             v-spacer
             v-btn(outline, small, href='https://opencollective.com/wikijs/order/1273') Become a Sponsor
-          v-list(two-line, dense)
+          v-list(two-line)
             template(v-for='(sponsor, idx) in sponsors')
               v-list-tile(:key='sponsor.id')
                 v-list-tile-avatar
@@ -89,6 +89,58 @@
                   v-btn(icon, :href='backer.website', target='_blank')
                     v-icon(color='grey') public
               v-divider(v-if='idx < backers.length - 1')
+          v-toolbar(color='primary', dense, dark)
+            .subheading Special Thanks
+          v-list(two-line)
+            v-list-tile
+              v-list-tile-avatar
+                img(src='https://static.requarks.io/logo/algolia.svg', alt='Algolia')
+              v-list-tile-content
+                v-list-tile-title Algolia
+                v-list-tile-sub-title Algolia is a powerful search-as-a-service solution, made easy to use with API clients, UI libraries, and pre-built integrations.
+              v-list-tile-action
+                v-btn(icon, href='https://www.algolia.com/', target='_blank')
+                  v-icon(color='grey') public
+            v-divider
+            v-list-tile
+              v-list-tile-avatar
+                img(src='https://static.requarks.io/logo/browserstack.svg', alt='Browserstack')
+              v-list-tile-content
+                v-list-tile-title BrowserStack
+                v-list-tile-sub-title BrowserStack is a cloud web and mobile testing platform that enables developers to test their websites and mobile applications.
+              v-list-tile-action
+                v-btn(icon, href='https://www.browserstack.com/', target='_blank')
+                  v-icon(color='grey') public
+            v-divider
+            v-list-tile
+              v-list-tile-avatar
+                img(src='https://static.requarks.io/logo/cloudflare.svg', alt='Cloudflare')
+              v-list-tile-content
+                v-list-tile-title Cloudflare
+                v-list-tile-sub-title Providing content delivery network services, DDoS mitigation, Internet security and distributed domain name server services.
+              v-list-tile-action
+                v-btn(icon, href='https://www.cloudflare.com/', target='_blank')
+                  v-icon(color='grey') public
+            v-divider
+            v-list-tile
+              v-list-tile-avatar
+                img(src='https://static.requarks.io/logo/digitalocean.svg', alt='DigitalOcean')
+              v-list-tile-content
+                v-list-tile-title DigitalOcean
+                v-list-tile-sub-title Providing developers and businesses a reliable, easy-to-use cloud computing platform of virtual servers (Droplets), object storage (Spaces), and more.
+              v-list-tile-action
+                v-btn(icon, href='https://m.do.co/c/5f7445bfa4d0', target='_blank')
+                  v-icon(color='grey') public
+            v-divider
+            v-list-tile
+              v-list-tile-avatar
+                img(src='/svg/logo-icons8.svg', alt='Icons8')
+              v-list-tile-content
+                v-list-tile-title Icons8
+                v-list-tile-sub-title All the Icons You Need. Guaranteed.
+              v-list-tile-action
+                v-btn(icon, href='https://icons8.com', target='_blank')
+                  v-icon(color='grey') public
 
 </template>
 

+ 5 - 1
client/components/admin/admin-rendering.vue

@@ -47,7 +47,7 @@
                   avatar
                   :key='rdr.key'
                   @click='selectRenderer(rdr.key)'
-                  :class='currentRenderer.key === rdr.key ? "blue lighten-5" : ""'
+                  :class='currentRenderer.key === rdr.key ? (darkMode ? `grey darken-4-l4` : `blue lighten-5`) : ``'
                   )
                   v-list-tile-avatar
                     v-icon(:color='currentRenderer.key === rdr.key ? "primary" : "grey"') {{rdr.icon}}
@@ -120,6 +120,7 @@
 <script>
 import _ from 'lodash'
 import { DepGraph } from 'dependency-graph'
+import { get } from 'vuex-pathify'
 
 import { StatusIndicator } from 'vue-status-indicator'
 
@@ -136,6 +137,9 @@ export default {
       currentRenderer: {}
     }
   },
+  computed: {
+    darkMode: get('site/dark'),
+  },
   watch: {
     renderers(newValue, oldValue) {
       _.delay(() => {

+ 2 - 2
client/components/admin/admin-users-edit.vue

@@ -37,7 +37,7 @@
               v-list-tile-content
                 v-list-tile-title Email
                 v-list-tile-sub-title {{ user.email }}
-              v-list-tile-action
+              v-list-tile-action(v-if='!user.isSystem')
                   v-btn(icon, color='grey', flat)
                     v-icon edit
             v-divider
@@ -50,7 +50,7 @@
               v-list-tile-action
                   v-btn(icon, color='grey', flat)
                     v-icon edit
-        v-card.mt-3
+        v-card.mt-3(v-if='!user.isSystem')
           v-toolbar(color='primary', dense, dark, flat)
             v-icon.mr-2 lock_outline
             span Authentication

+ 51 - 0
client/components/common/loader.vue

@@ -0,0 +1,51 @@
+<template lang='pug'>
+  v-dialog(v-model='value', persistent, max-width='350')
+    v-card.loader-dialog.radius-7(:color='color', dark)
+      v-card-text.text-xs-center.py-4
+        atom-spinner.is-inline(
+          :animation-duration='1000'
+          :size='60'
+          color='#FFF'
+          )
+        .subheading {{ title }}
+        .caption {{ subtitle }}
+</template>
+
+<script>
+import { AtomSpinner } from 'epic-spinners'
+
+export default {
+  components: {
+    AtomSpinner
+  },
+  props: {
+    value: {
+      type: Boolean,
+      default: false
+    },
+    color: {
+      type: String,
+      default: 'blue darken-3'
+    },
+    title: {
+      type: String,
+      default: 'Working...'
+    },
+    subtitle: {
+      type: String,
+      default: 'Please wait'
+    }
+  }
+}
+</script>
+
+<style lang='scss'>
+  .loader-dialog {
+    .atom-spinner.is-inline {
+      display: inline-block;
+    }
+    .caption {
+      color: rgba(255,255,255,.7);
+    }
+  }
+</style>

+ 79 - 0
client/components/common/password-strength.vue

@@ -0,0 +1,79 @@
+<template lang="pug">
+  .password-strength
+    v-progress-linear(
+      :color='passwordStrengthColor'
+      v-model='passwordStrength'
+      height='2'
+    )
+    .caption(v-if='!hideText', :class='passwordStrengthColor + "--text"') {{passwordStrengthText}}
+</template>
+
+<script>
+import zxcvbn from 'zxcvbn'
+import _ from 'lodash'
+
+export default {
+  props: {
+    value: {
+      type: String,
+      default: ''
+    },
+    hideText: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      passwordStrength: 0,
+      passwordStrengthColor: 'grey',
+      passwordStrengthText: ''
+    }
+  },
+  watch: {
+    value(newValue) {
+      this.checkPasswordStrength(newValue)
+    }
+  },
+  methods: {
+    checkPasswordStrength: _.debounce(function (pwd) {
+      if (!pwd || pwd.length < 1) {
+        this.passwordStrength = 0
+        this.passwordStrengthColor = 'grey'
+        this.passwordStrengthText = ''
+        return
+      }
+      const strength = zxcvbn(pwd)
+      this.passwordStrength = _.round((strength.score + 1 ) / 5 * 100)
+      if (this.passwordStrength <= 20) {
+        this.passwordStrengthColor = 'red'
+        this.passwordStrengthText = 'Very Weak'
+      } else if (this.passwordStrength <= 40) {
+        this.passwordStrengthColor = 'orange'
+        this.passwordStrengthText = 'Weak'
+      } else if (this.passwordStrength <= 60) {
+        this.passwordStrengthColor = 'teal'
+        this.passwordStrengthText = 'Average'
+      } else if (this.passwordStrength <= 80) {
+        this.passwordStrengthColor = 'green'
+        this.passwordStrengthText = 'Strong'
+      } else {
+        this.passwordStrengthColor = 'green'
+        this.passwordStrengthText = 'Very Strong'
+      }
+    }, 100)
+  }
+}
+</script>
+
+<style lang="scss">
+
+.password-strength > .caption {
+  width: 100%;
+  left: 0;
+  margin: 0;
+  position: absolute;
+  top: calc(100% + 5px);
+}
+
+</style>

+ 1 - 10
client/components/editor.vue

@@ -30,16 +30,6 @@
     v-content
       component(:is='currentEditor')
       editor-modal-properties(v-model='dialogProps')
-      v-dialog(v-model='dialogProgress', persistent, max-width='350')
-        v-card(color='blue darken-3', dark)
-          v-card-text.text-xs-center.py-4
-            atom-spinner.is-inline(
-              :animation-duration='1000'
-              :size='60'
-              color='#FFF'
-              )
-            .subheading {{ $t('editor:save.processing') }}
-            .caption.blue--text.text--lighten-3 {{ $t('editor:save.pleaseWait') }}
       v-dialog(v-model='dialogEditorSelector', persistent, max-width='700')
         v-card.radius-7(color='blue darken-3', dark)
           v-card-text.text-xs-center.py-4
@@ -88,6 +78,7 @@
                       .caption.grey--text.text--darken-1 Drag-n-drop
             .caption.blue--text.text--lighten-2 This cannot be changed once the page is created.
 
+    loader(v-model='dialogProgress', :title='$t(`editor:save.processing`)', :subtitle='$t(`editor:save.pleaseWait`)')
     v-snackbar(
       :color='notification.style'
       bottom,

+ 18 - 15
client/components/login.vue

@@ -15,7 +15,7 @@
                 v-toolbar(color='primary', flat, dense, dark)
                   v-spacer
                   .subheading(v-if='screen === "tfa"') {{ $t('auth:tfa.subtitle') }}
-                  .subheading(v-else-if='selectedStrategy.key !== "local"') Login using {{ selectedStrategy.title }}
+                  .subheading(v-else-if='selectedStrategy.key !== "local"') {{ $t('auth:loginUsingStrategy', { strategy: selectedStrategy.title }) }}
                   .subheading(v-else) {{ $t('auth:loginRequired') }}
                   v-spacer
                 v-card-text.text-xs-center
@@ -80,12 +80,12 @@
                   v-spacer
                 v-card-actions.pb-3(v-if='selectedStrategy.key === "local"')
                   v-spacer
-                  a.caption(href='') Forgot your password?
+                  a.caption(href='') {{ $t('auth:forgotPasswordLink') }}
                   v-spacer
                 template(v-if='isSocialShown')
                   v-divider
                   v-card-text.grey.lighten-4.text-xs-center
-                    .pb-2.body-2.text-xs-center.grey--text.text--darken-2 or login using...
+                    .pb-2.body-2.text-xs-center.grey--text.text--darken-2 {{ $t('auth:orLoginUsingStrategy') }}
                     v-tooltip(top, v-for='strategy in strategies', :key='strategy.key')
                       .social-login-btn.mr-2(
                         slot='activator'
@@ -99,8 +99,11 @@
                   v-divider
                   v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
                     v-spacer
-                    .caption Don't have an account yet? #[a.caption(href='') Create an account]
+                    i18next.caption(path='auth:switchToRegister.text', tag='div')
+                      a.caption(href='/register', place='link') {{ $t('auth:switchToRegister.link') }}
                     v-spacer
+
+    loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
     nav-footer(color='grey darken-4')
 </template>
 
@@ -128,6 +131,8 @@ export default {
       securityCode: '',
       loginToken: '',
       isLoading: false,
+      loaderColor: 'grey darken-4',
+      loaderTitle: 'Working...',
       isShown: false
     }
   },
@@ -173,18 +178,20 @@ export default {
       if (this.username.length < 2) {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: 'Enter a valid email / username.',
+          message: this.$t('auth:invalidEmailUsername'),
           icon: 'warning'
         })
         this.$refs.iptEmail.focus()
       } else if (this.password.length < 2) {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: 'Enter a valid password.',
+          message: this.$t('auth:invalidPassword'),
           icon: 'warning'
         })
         this.$refs.iptPassword.focus()
       } else {
+        this.loaderColor = 'grey darken-4'
+        this.loaderTitle = this.$t('auth:signingIn')
         this.isLoading = true
         try {
           let resp = await this.$apollo.mutate({
@@ -205,23 +212,20 @@ export default {
                 this.$nextTick(() => {
                   this.$refs.iptTFA.focus()
                 })
+                this.isLoading = false
               } else {
-                this.$store.commit('showNotification', {
-                  message: 'Login Successful! Redirecting...',
-                  style: 'success',
-                  icon: 'check'
-                })
+                this.loaderColor = 'green darken-1'
+                this.loaderTitle = this.$t('auth:loginSuccess')
                 Cookies.set('jwt', respObj.jwt, { expires: 365 })
                 _.delay(() => {
                   window.location.replace('/') // TEMPORARY - USE RETURNURL
                 }, 1000)
               }
-              this.isLoading = false
             } else {
               throw new Error(respObj.responseResult.message)
             }
           } else {
-            throw new Error('Authentication is unavailable.')
+            throw new Error(this.$t('auth:genericError'))
           }
         } catch (err) {
           console.error(err)
@@ -270,7 +274,7 @@ export default {
               throw new Error(respObj.responseResult.message)
             }
           } else {
-            throw new Error('Authentication is unavailable.')
+            throw new Error(this.$t('auth:genericError'))
           }
         }).catch(err => {
           console.error(err)
@@ -289,7 +293,6 @@ export default {
       query: strategiesQuery,
       update: (data) => data.authentication.strategies,
       watchLoading (isLoading) {
-        this.isLoading = isLoading
         this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'login-strategies-refresh')
       }
     }

+ 303 - 0
client/components/register.vue

@@ -0,0 +1,303 @@
+<template lang="pug">
+  v-app
+    .register
+      v-container(grid-list-lg)
+        v-layout(row, wrap)
+          v-flex(
+            xs12
+            offset-sm1, sm10
+            offset-md2, md8
+            offset-lg3, lg6
+            offset-xl4, xl4
+            )
+            transition(name='zoom')
+              v-card.elevation-5.md2(v-show='isShown')
+                v-toolbar(color='indigo', flat, dense, dark)
+                  v-spacer
+                  .subheading {{ $t('auth:registerTitle') }}
+                  v-spacer
+                v-card-text.text-xs-center
+                  h1.display-1.indigo--text.py-2 {{ siteTitle }}
+                  .body-2 {{ $t('auth:registerSubTitle') }}
+                  v-text-field.md2.mt-3(
+                    solo
+                    flat
+                    prepend-icon='email'
+                    background-color='grey lighten-4'
+                    hide-details
+                    ref='iptEmail'
+                    v-model='email'
+                    :placeholder='$t("auth:fields.email")'
+                    color='indigo'
+                    )
+                  v-text-field.md2.mt-2(
+                    solo
+                    flat
+                    prepend-icon='vpn_key'
+                    background-color='grey lighten-4'
+                    ref='iptPassword'
+                    v-model='password'
+                    :append-icon='hidePassword ? "visibility" : "visibility_off"'
+                    @click:append='() => (hidePassword = !hidePassword)'
+                    :type='hidePassword ? "password" : "text"'
+                    :placeholder='$t("auth:fields.password")'
+                    color='indigo'
+                    loading
+                    )
+                    password-strength(slot='progress', v-model='password')
+                  v-text-field.md2.mt-2(
+                    solo
+                    flat
+                    prepend-icon='vpn_key'
+                    background-color='grey lighten-4'
+                    hide-details
+                    ref='iptVerifyPassword'
+                    v-model='verifyPassword'
+                    @click:append='() => (hidePassword = !hidePassword)'
+                    type='password'
+                    :placeholder='$t("auth:fields.verifyPassword")'
+                    color='indigo'
+                  )
+                  v-text-field.md2.mt-2(
+                    solo
+                    flat
+                    prepend-icon='person'
+                    background-color='grey lighten-4'
+                    hide-details
+                    ref='iptName'
+                    v-model='name'
+                    :placeholder='$t("auth:fields.name")'
+                    @keyup.enter='register'
+                    color='indigo'
+                    )
+                v-card-actions.pb-4
+                  v-spacer
+                  v-btn.md2(
+                    block
+                    large
+                    dark
+                    color='indigo'
+                    @click='register'
+                    round
+                    :loading='isLoading'
+                    ) {{ $t('auth:actions.register') }}
+                  v-spacer
+                v-divider
+                v-card-actions.py-3.grey.lighten-4
+                  v-spacer
+                  i18next.caption(path='auth:switchToLogin.text', tag='div')
+                    a.caption(href='/login', place='link') {{ $t('auth:switchToLogin.link') }}
+                  v-spacer
+
+    loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
+    nav-footer(color='grey darken-4', dark-color='grey darken-4')
+</template>
+
+<script>
+/* global siteConfig */
+
+import _ from 'lodash'
+import Cookies from 'js-cookie'
+import validate from 'validate.js'
+import PasswordStrength from './common/password-strength.vue'
+
+import registerMutation from 'gql/register/register-mutation-create.gql'
+
+export default {
+  i18nOptions: { namespaces: 'auth' },
+  components: {
+    PasswordStrength
+  },
+  data () {
+    return {
+      email: '',
+      password: '',
+      verifyPassword: '',
+      name: '',
+      hidePassword: true,
+      isLoading: false,
+      isShown: false
+    }
+  },
+  computed: {
+    siteTitle () {
+      return siteConfig.title
+    }
+  },
+  mounted () {
+    this.isShown = true
+    this.$nextTick(() => {
+      this.$refs.iptEmail.focus()
+    })
+  },
+  methods: {
+    /**
+     * REGISTER
+     */
+    async register () {
+      const validation = validate({
+        email: this.email,
+        password: this.password,
+        verifyPassword: this.verifyPassword,
+        name: this.name
+      }, {
+        email: {
+          presence: {
+            message: this.$t('auth:missingEmail'),
+            allowEmpty: false
+          },
+          email: {
+            message: this.$t('auth:invalidEmail')
+          }
+        },
+        password: {
+          presence: {
+            message: this.$t('auth:missingPassword'),
+            allowEmpty: false
+          },
+          length: {
+            minimum: 6,
+            tooShort: this.$t('auth:passwordTooShort')
+          }
+        },
+        verifyPassword: {
+          equality: {
+            attribute: 'password',
+            message: this.$t('auth:passwordNotMatch')
+          }
+        },
+        name: {
+          presence: {
+            message: this.$t('auth:missingName'),
+            allowEmpty: false
+          },
+          length: {
+            minimum: 2,
+            maximum: 255,
+            tooShort: this.$t('auth:nameTooShort'),
+            tooLong: this.$t('auth:nameTooLong')
+          }
+        },
+      }, { fullMessages: false })
+
+      if (validation) {
+        if(validation.email) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.email[0],
+            icon: 'warning'
+          })
+          this.$refs.iptEmail.focus()
+        } else if (validation.password) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.password[0],
+            icon: 'warning'
+          })
+          this.$refs.iptPassword.focus()
+        } else if (validation.verifyPassword) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.verifyPassword[0],
+            icon: 'warning'
+          })
+          this.$refs.iptVerifyPassword.focus()
+        } else {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.name[0],
+            icon: 'warning'
+          })
+          this.$refs.iptName.focus()
+        }
+      } else {
+        this.isLoading = true
+        try {
+          let resp = await this.$apollo.mutate({
+            mutation: registerMutation,
+            variables: {
+              email: this.email,
+              password: this.password,
+              name: this.name
+            }
+          })
+          if (_.has(resp, 'data.authentication.register')) {
+            let respObj = _.get(resp, 'data.authentication.register', {})
+            if (respObj.responseResult.succeeded === true) {
+              this.$store.commit('showNotification', {
+                message: 'Account created successfully! Redirecting...',
+                style: 'success',
+                icon: 'check'
+              })
+              Cookies.set('jwt', respObj.jwt, { expires: 365 })
+              _.delay(() => {
+                window.location.replace('/')
+              }, 1000)
+            } else {
+              throw new Error(respObj.responseResult.message)
+            }
+          } else {
+            throw new Error('Registration is unavailable at this time.')
+          }
+        } catch (err) {
+          console.error(err)
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: err.message,
+            icon: 'warning'
+          })
+          this.isLoading = false
+        }
+      }
+    }
+  }
+}
+</script>
+
+<style lang="scss">
+  .register {
+    background-color: mc('indigo', '900');
+    background-image: url('../static/svg/motif-blocks.svg');
+    background-repeat: repeat;
+    background-size: 200px;
+    width: 100%;
+    height: 100%;
+    animation: loginBgReveal 20s linear infinite;
+
+    @include keyframes(loginBgReveal) {
+      0% {
+        background-position-x: 0;
+      }
+      100% {
+        background-position-x: 800px;
+      }
+    }
+
+    &::before {
+      content: '';
+      position: absolute;
+      background-image: url('../static/svg/motif-overlay.svg');
+      background-attachment: fixed;
+      background-size: cover;
+      opacity: .5;
+      top: 0;
+      left: 0;
+      width: 100vw;
+      height: 100vh;
+    }
+
+    > .container {
+      height: 100%;
+      align-items: center;
+      display: flex;
+    }
+
+    h1 {
+      font-family: 'Varela Round' !important;
+    }
+
+    .v-text-field.centered input {
+      text-align: center;
+    }
+  }
+</style>

+ 13 - 0
client/graph/register/register-mutation-create.gql

@@ -0,0 +1,13 @@
+mutation($email: String!, $password: String!, $name: String!) {
+  authentication {
+    register(email: $email, password: $password, name: $name) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+      jwt
+    }
+  }
+}

BIN
client/static/img/icon-browse.png


BIN
client/static/img/icon-people.png


BIN
client/static/img/icon-unlock.png


+ 18 - 0
client/static/svg/logo-icons8.svg

@@ -0,0 +1,18 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" width="80pt" height="80pt" viewBox="0 0 80 80" version="1.1">
+<g id="surface1">
+<path style=" stroke:none;fill-rule:nonzero;fill:#78909C;fill-opacity:1;" d="M 16.667969 73.332031 C 15.667969 73.332031 15 72.667969 15 71.667969 C 15 70.667969 15.667969 70 16.667969 70 C 17.5 70 18.332031 69.832031 18.332031 68.167969 L 18.332031 61.667969 C 18.332031 60.667969 19 60 20 60 C 21 60 21.667969 60.667969 21.667969 61.667969 L 21.667969 68.167969 C 21.667969 71.332031 19.667969 73.332031 16.667969 73.332031 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#CFD8DC;fill-opacity:1;" d="M 67.832031 25 C 68.167969 24 68.332031 22.832031 68.332031 21.667969 C 68.332031 16.167969 63.832031 11.667969 58.332031 11.667969 C 55.5 11.667969 53 12.832031 51.167969 14.832031 C 49.667969 10 45.167969 6.667969 40 6.667969 C 34.667969 6.667969 30.332031 10.167969 28.832031 15 C 27.332031 14 25.332031 13.332031 23.332031 13.332031 C 17.832031 13.332031 13.332031 17.832031 13.332031 23.332031 C 13.332031 23.832031 13.332031 24.5 13.5 25 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#32C24D;fill-opacity:1;" d="M 10 21.667969 L 70 21.667969 L 70 58.332031 L 10 58.332031 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#FFFFFF;fill-opacity:1;" d="M 43.332031 40 C 43.332031 41.832031 41.832031 43.332031 40 43.332031 C 38.167969 43.332031 36.667969 41.832031 36.667969 40 C 36.667969 38.167969 38.167969 36.667969 40 36.667969 C 41.832031 36.667969 43.332031 38.167969 43.332031 40 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#FFFFFF;fill-opacity:1;" d="M 56.667969 40 C 56.667969 41.832031 55.167969 43.332031 53.332031 43.332031 C 51.5 43.332031 50 41.832031 50 40 C 50 38.167969 51.5 36.667969 53.332031 36.667969 C 55.167969 36.667969 56.667969 38.167969 56.667969 40 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#FFFFFF;fill-opacity:1;" d="M 30 40 C 30 41.832031 28.5 43.332031 26.667969 43.332031 C 24.832031 43.332031 23.332031 41.832031 23.332031 40 C 23.332031 38.167969 24.832031 36.667969 26.667969 36.667969 C 28.5 36.667969 30 38.167969 30 40 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#546E7A;fill-opacity:1;" d="M 18.332031 58.332031 L 21.667969 58.332031 L 21.667969 61.667969 L 18.332031 61.667969 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#78909C;fill-opacity:1;" d="M 26.667969 73.332031 C 25.667969 73.332031 25 72.667969 25 71.667969 C 25 70.667969 25.667969 70 26.667969 70 C 27.5 70 28.332031 69.832031 28.332031 68.167969 L 28.332031 61.667969 C 28.332031 60.667969 29 60 30 60 C 31 60 31.667969 60.667969 31.667969 61.667969 L 31.667969 68.167969 C 31.667969 71.332031 29.667969 73.332031 26.667969 73.332031 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#546E7A;fill-opacity:1;" d="M 28.332031 58.332031 L 31.667969 58.332031 L 31.667969 61.667969 L 28.332031 61.667969 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#78909C;fill-opacity:1;" d="M 48.332031 73.332031 C 47.332031 73.332031 46.667969 72.667969 46.667969 71.667969 C 46.667969 70.667969 47.332031 70 48.332031 70 C 49.167969 70 50 69.832031 50 68.167969 L 50 61.667969 C 50 60.667969 50.667969 60 51.667969 60 C 52.667969 60 53.332031 60.667969 53.332031 61.667969 L 53.332031 68.167969 C 53.332031 71.332031 51.332031 73.332031 48.332031 73.332031 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#546E7A;fill-opacity:1;" d="M 50 58.332031 L 53.332031 58.332031 L 53.332031 61.667969 L 50 61.667969 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#78909C;fill-opacity:1;" d="M 58.332031 73.332031 C 57.332031 73.332031 56.667969 72.667969 56.667969 71.667969 C 56.667969 70.667969 57.332031 70 58.332031 70 C 59.167969 70 60 69.832031 60 68.167969 L 60 61.667969 C 60 60.667969 60.667969 60 61.667969 60 C 62.667969 60 63.332031 60.667969 63.332031 61.667969 L 63.332031 68.167969 C 63.332031 71.332031 61.332031 73.332031 58.332031 73.332031 Z "/>
+<path style=" stroke:none;fill-rule:nonzero;fill:#546E7A;fill-opacity:1;" d="M 60 58.332031 L 63.332031 58.332031 L 63.332031 61.667969 L 60 61.667969 Z "/>
+</g>
+</svg>

+ 0 - 2
dev/docker/docker-compose.yml

@@ -30,8 +30,6 @@ services:
 
   adminer:
     image: adminer:latest
-    environment:
-      ADMINER_DESIGN: pappu687
     logging:
       driver: "none"
     networks:

+ 5 - 2
package.json

@@ -13,7 +13,7 @@
     "test": "eslint --format codeframe --ext .js,.vue . && pug-lint server/views && jest",
     "docker:dev:up": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . up -d && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . exec wiki yarn dev",
     "docker:dev:down": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down",
-    "docker:dev:rebuild": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm",
+    "docker:dev:rebuild": "rmdir node_modules /s /q && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . build --no-cache --force-rm",
     "docker:build": "docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . run wiki yarn build && docker-compose -f ./dev/docker/docker-compose.yml -p wiki --project-directory . down"
   },
   "bin": {
@@ -59,6 +59,7 @@
     "connect-redis": "3.4.0",
     "cookie-parser": "1.4.3",
     "cors": "2.8.5",
+    "custom-error-instance": "2.1.1",
     "dependency-graph": "0.7.2",
     "diff": "3.5.0",
     "diff2html": "2.5.0",
@@ -154,6 +155,7 @@
     "subscriptions-transport-ws": "0.9.15",
     "uslug": "1.0.4",
     "uuid": "3.3.2",
+    "validate.js": "0.12.0",
     "validator": "10.9.0",
     "validator-as-promised": "1.0.2",
     "winston": "3.1.0",
@@ -284,7 +286,8 @@
     "webpack-subresource-integrity": "1.3.0",
     "whatwg-fetch": "3.0.0",
     "write-file-webpack-plugin": "4.4.1",
-    "xterm": "3.8.0"
+    "xterm": "3.8.0",
+    "zxcvbn": "4.4.2"
   },
   "browserslist": [
     "> 1%",

+ 7 - 0
server/controllers/auth.js

@@ -18,6 +18,13 @@ router.get('/logout', function (req, res) {
   res.redirect('/')
 })
 
+/**
+ * Register form
+ */
+router.get('/register', function (req, res, next) {
+  res.render('register')
+})
+
 /**
  * JWT Public Endpoints
  */

+ 18 - 2
server/graph/resolvers/authentication.js

@@ -38,7 +38,7 @@ module.exports = {
   AuthenticationMutation: {
     async login(obj, args, context) {
       try {
-        let authResult = await WIKI.models.users.login(args, context)
+        const authResult = await WIKI.models.users.login(args, context)
         return {
           ...authResult,
           responseResult: graphHelper.generateSuccess('Login success')
@@ -49,7 +49,7 @@ module.exports = {
     },
     async loginTFA(obj, args, context) {
       try {
-        let authResult = await WIKI.models.users.loginTFA(args, context)
+        const authResult = await WIKI.models.users.loginTFA(args, context)
         return {
           ...authResult,
           responseResult: graphHelper.generateSuccess('TFA success')
@@ -58,6 +58,22 @@ module.exports = {
         return graphHelper.generateError(err)
       }
     },
+    async register(obj, args, context) {
+      try {
+        await WIKI.models.users.register(args, context)
+        const authResult = await WIKI.models.users.login({
+          username: args.email,
+          password: args.password,
+          strategy: 'local'
+        }, context)
+        return {
+          jwt: authResult.jwt,
+          responseResult: graphHelper.generateSuccess('Registration success')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
     async updateStrategies(obj, args, context) {
       try {
         for (let str of args.strategies) {

+ 11 - 0
server/graph/schemas/authentication.graphql

@@ -36,6 +36,12 @@ type AuthenticationMutation {
     securityCode: String!
   ): DefaultResponse
 
+  register(
+    email: String!
+    password: String!
+    name: String!
+  ): AuthenticationRegisterResponse
+
   updateStrategies(
     strategies: [AuthenticationStrategyInput]
   ): DefaultResponse @auth(requires: ["manage:system"])
@@ -69,6 +75,11 @@ type AuthenticationLoginResponse {
   tfaLoginToken: String
 }
 
+type AuthenticationRegisterResponse {
+  responseResult: ResponseStatus
+  jwt: String
+}
+
 input AuthenticationStrategyInput {
   isEnabled: Boolean!
   key: String!

+ 41 - 27
server/helpers/error.js

@@ -1,30 +1,44 @@
-class BaseError extends Error {
-  constructor (message) {
-    super(message)
-    this.name = this.constructor.name
-    Error.captureStackTrace(this, this.constructor)
-  }
-}
-
-class AuthGenericError extends BaseError { constructor (message = 'An unexpected error occured during login.') { super(message) } }
-class AuthLoginFailed extends BaseError { constructor (message = 'Invalid email / username or password.') { super(message) } }
-class AuthProviderInvalid extends BaseError { constructor (message = 'Invalid authentication provider.') { super(message) } }
-class AuthTFAFailed extends BaseError { constructor (message = 'Incorrect TFA Security Code.') { super(message) } }
-class AuthTFAInvalid extends BaseError { constructor (message = 'Invalid TFA Security Code or Login Token.') { super(message) } }
-class BruteInstanceIsInvalid extends BaseError { constructor (message = 'Invalid Brute Force Instance.') { super(message) } }
-class BruteTooManyAttempts extends BaseError { constructor (message = 'Too many attempts! Try again later.') { super(message) } }
-class LocaleInvalidNamespace extends BaseError { constructor (message = 'Invalid locale or namespace.') { super(message) } }
-class UserCreationFailed extends BaseError { constructor (message = 'An unexpected error occured during user creation.') { super(message) } }
+const CustomError = require('custom-error-instance')
 
 module.exports = {
-  BaseError,
-  AuthGenericError,
-  AuthLoginFailed,
-  AuthProviderInvalid,
-  AuthTFAFailed,
-  AuthTFAInvalid,
-  BruteInstanceIsInvalid,
-  BruteTooManyAttempts,
-  LocaleInvalidNamespace,
-  UserCreationFailed
+  AuthGenericError: CustomError('AuthGenericError', {
+    message: 'An unexpected error occured during login.',
+    code: 1001
+  }),
+  AuthLoginFailed: CustomError('AuthLoginFailed', {
+    message: 'Invalid email / username or password.',
+    code: 1002
+  }),
+  AuthProviderInvalid: CustomError('AuthProviderInvalid', {
+    message: 'Invalid authentication provider.',
+    code: 1003
+  }),
+  AuthAccountAlreadyExists: CustomError('AuthAccountAlreadyExists', {
+    message: 'An account already exists using this email address.',
+    code: 1004
+  }),
+  AuthTFAFailed: CustomError('AuthTFAFailed', {
+    message: 'Incorrect TFA Security Code.',
+    code: 1005
+  }),
+  AuthTFAInvalid: CustomError('AuthTFAInvalid', {
+    message: 'Invalid TFA Security Code or Login Token.',
+    code: 1006
+  }),
+  BruteInstanceIsInvalid: CustomError('BruteInstanceIsInvalid', {
+    message: 'Invalid Brute Force Instance.',
+    code: 1007
+  }),
+  BruteTooManyAttempts: CustomError('BruteTooManyAttempts', {
+    message: 'Too many attempts! Try again later.',
+    code: 1008
+  }),
+  LocaleInvalidNamespace: CustomError('LocaleInvalidNamespace', {
+    message: 'Invalid locale or namespace.',
+    code: 1009
+  }),
+  UserCreationFailed: CustomError('UserCreationFailed', {
+    message: 'An unexpected error occured during user creation.',
+    code: 1010
+  })
 }

+ 19 - 0
server/models/users.js

@@ -292,4 +292,23 @@ module.exports = class User extends Model {
     }
     throw new WIKI.Error.AuthTFAInvalid()
   }
+
+  static async register ({ email, password, name }, context) {
+    const usr = await WIKI.models.users.query().findOne({ email, providerKey: 'local' })
+    if (!usr) {
+      await WIKI.models.users.query().insert({
+        provider: 'local',
+        email,
+        name,
+        password,
+        locale: 'en',
+        defaultEditor: 'markdown',
+        tfaIsActive: false,
+        isSystem: false
+      })
+      return true
+    } else {
+      throw new WIKI.Error.AuthAccountAlreadyExists()
+    }
+  }
 }

+ 5 - 0
server/views/register.pug

@@ -0,0 +1,5 @@
+extends master.pug
+
+block body
+  #root.is-fullscreen
+    register