Browse Source

feat: user profile page - save info + change pwd

NGPixel 5 years ago
parent
commit
1e4d513252

+ 1 - 1
client/components/admin.vue

@@ -123,7 +123,7 @@
             v-list-item-avatar(size='24', tile): v-icon mdi-heart-outline
             v-list-item-avatar(size='24', tile): v-icon mdi-heart-outline
             v-list-item-title {{ $t('admin:contribute.title') }}
             v-list-item-title {{ $t('admin:contribute.title') }}
 
 
-    v-content(:class='darkMode ? "grey darken-4" : ""')
+    v-content(:class='darkMode ? "grey darken-4" : "grey lighten-5"')
       transition(name='admin-router')
       transition(name='admin-router')
         router-view
         router-view
 
 

+ 4 - 2
client/components/admin/admin-groups-edit-rules.vue

@@ -195,7 +195,9 @@
 
 
 <script>
 <script>
 import _ from 'lodash'
 import _ from 'lodash'
-import nanoid from 'nanoid/non-secure/generate'
+import { customAlphabet } from 'nanoid/non-secure'
+
+const nanoid = customAlphabet('1234567890abcdef', 10)
 
 
 export default {
 export default {
   props: {
   props: {
@@ -241,7 +243,7 @@ export default {
   methods: {
   methods: {
     addRule(group) {
     addRule(group) {
       this.group.pageRules.push({
       this.group.pageRules.push({
-        id: nanoid('1234567890abcdef', 10),
+        id: nanoid(),
         path: '',
         path: '',
         roles: [],
         roles: [],
         match: 'START',
         match: 'START',

+ 1 - 1
client/components/admin/admin-system.vue

@@ -166,7 +166,7 @@ export default {
             return 'mdi-linux'
             return 'mdi-linux'
           }
           }
         case 'win32':
         case 'win32':
-          return 'mdi-windows'
+          return 'mdi-microsoft-windows'
         default:
         default:
           return ''
           return ''
       }
       }

+ 6 - 2
client/components/editor.vue

@@ -355,8 +355,12 @@ export default {
     },
     },
     async saveAndClose() {
     async saveAndClose() {
       try {
       try {
-        await this.save({ rethrow: true })
-        await this.exit()
+        if (this.$store.get('editor/mode') === 'create') {
+          await this.save()
+        } else {
+          await this.save({ rethrow: true })
+          await this.exit()
+        }
       } catch (err) {
       } catch (err) {
         // Error is already handled
         // Error is already handled
       }
       }

+ 1 - 1
client/components/login.vue

@@ -170,7 +170,7 @@
                     v-spacer
                     v-spacer
 
 
     loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
     loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
-    nav-footer(color='grey darken-5')
+    nav-footer(color='grey darken-5', dark-color='grey darken-5')
     notify
     notify
 </template>
 </template>
 
 

+ 278 - 48
client/components/profile/profile.vue

@@ -8,9 +8,9 @@
             .headline.primary--text.animated.fadeInLeft {{$t('profile:title')}}
             .headline.primary--text.animated.fadeInLeft {{$t('profile:title')}}
             .subheading.grey--text.animated.fadeInLeft {{$t('profile:subtitle')}}
             .subheading.grey--text.animated.fadeInLeft {{$t('profile:subtitle')}}
           v-spacer
           v-spacer
-          v-btn.animated.fadeInDown(outlined, color='primary', disabled).mr-0
-            v-icon(left) mdi-earth
-            span {{$t('profile:viewPublicProfile')}}
+          //- v-btn.animated.fadeInDown(outlined, color='primary', disabled).mr-0
+          //-   v-icon(left) mdi-earth
+          //-   span {{$t('profile:viewPublicProfile')}}
       v-flex(lg6 xs12)
       v-flex(lg6 xs12)
         v-card
         v-card
           v-toolbar(color='primary', dark, dense, flat)
           v-toolbar(color='primary', dark, dense, flat)
@@ -30,8 +30,9 @@
                   left
                   left
                   )
                   )
                   template(v-slot:activator='{ on }')
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptDisplayName`)')
-                      v-icon mdi-pencil
+                    v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptDisplayName`)')
+                      v-icon(left) mdi-pencil
+                      span {{ $t('common:actions:edit') }}
                   v-card
                   v-card
                     v-text-field(
                     v-text-field(
                       ref='iptDisplayName'
                       ref='iptDisplayName'
@@ -59,8 +60,9 @@
                   left
                   left
                   )
                   )
                   template(v-slot:activator='{ on }')
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptLocation`)')
-                      v-icon mdi-pencil
+                    v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptLocation`)')
+                      v-icon(left) mdi-pencil
+                      span {{ $t('common:actions:edit') }}
                   v-card
                   v-card
                     v-text-field(
                     v-text-field(
                       ref='iptLocation'
                       ref='iptLocation'
@@ -88,8 +90,9 @@
                   left
                   left
                   )
                   )
                   template(v-slot:activator='{ on }')
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptJobTitle`)')
-                      v-icon mdi-pencil
+                    v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptJobTitle`)')
+                      v-icon(left) mdi-pencil
+                      span {{ $t('common:actions:edit') }}
                   v-card
                   v-card
                     v-text-field(
                     v-text-field(
                       ref='iptJobTitle'
                       ref='iptJobTitle'
@@ -117,8 +120,9 @@
                   left
                   left
                   )
                   )
                   template(v-slot:activator='{ on }')
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, color='grey', x-small, v-on='on', @click='focusField(`iptTimezone`)')
-                      v-icon mdi-pencil
+                    v-btn(text, color='grey', small, v-on='on', @click='focusField(`iptTimezone`)')
+                      v-icon(left) mdi-pencil
+                      span {{ $t('common:actions:edit') }}
                   v-card
                   v-card
                     v-select(
                     v-select(
                       ref='iptTimezone'
                       ref='iptTimezone'
@@ -135,15 +139,15 @@
                     )
                     )
           v-card-chin
           v-card-chin
             v-spacer
             v-spacer
-            v-btn.px-4(color='success')
+            v-btn.px-4(color='success', depressed, @click='saveProfile', :loading='saveLoading')
               v-icon(left) mdi-content-save
               v-icon(left) mdi-content-save
               span {{$t('common:actions.save')}}
               span {{$t('common:actions.save')}}
         v-card.mt-3
         v-card.mt-3
           v-toolbar(color='primary', dark, dense, flat)
           v-toolbar(color='primary', dark, dense, flat)
             v-toolbar-title
             v-toolbar-title
-              .subtitle-1 Authentication
-          v-card-text
-            v-subheader.pl-0 Provider
+              .subtitle-1 {{$t('profile:auth.title')}}
+          v-card-text.pt-0
+            v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.provider')}}
             v-toolbar(
             v-toolbar(
               flat
               flat
               :color='$vuetify.theme.dark ? "grey darken-2" : "purple lighten-5"'
               :color='$vuetify.theme.dark ? "grey darken-2" : "purple lighten-5"'
@@ -151,36 +155,64 @@
               :class='$vuetify.theme.dark ? "grey--text text--lighten-1" : "purple--text text--darken-4"'
               :class='$vuetify.theme.dark ? "grey--text text--lighten-1" : "purple--text text--darken-4"'
               )
               )
               v-icon(:color='$vuetify.theme.dark ? "grey lighten-1" : "purple darken-4"') mdi-shield-lock
               v-icon(:color='$vuetify.theme.dark ? "grey lighten-1" : "purple darken-4"') mdi-shield-lock
-              .subheading.ml-3 Local
-            v-divider.mt-3
-            v-subheader.pl-0 Two-Factor Authentication (2FA)
-            .caption.mb-2 2FA adds an extra layer of security by requiring a unique code generated on your smartphone when signing in.
-            v-btn(color='purple darken-4', disabled).ml-0 Enable 2FA
-            v-btn(color='purple darken-4', dark, depressed, disabled).ml-0 Disable 2FA
-            v-divider.mt-3
-            v-subheader.pl-0 Change Password
-            v-text-field(label='Current Password', type='password', prepend-icon='mdi-textbox-password')
-            v-text-field(label='New Password', type='password', prepend-icon='mdi-textbox-password')
-            v-text-field(label='Confirm New Password', type='password', prepend-icon='mdi-textbox-password')
+              .subheading.ml-3 {{ user.providerName }}
+            //- v-divider.mt-3
+            //- v-subheader.pl-0: span.subtitle-2 Two-Factor Authentication (2FA)
+            //- .caption.mb-2 2FA adds an extra layer of security by requiring a unique code generated on your smartphone when signing in.
+            //- v-btn(color='purple darken-4', disabled).ml-0 Enable 2FA
+            //- v-btn(color='purple darken-4', dark, depressed, disabled).ml-0 Disable 2FA
+            template(v-if='user.providerKey === `local`')
+              v-divider.mt-3
+              v-subheader.pl-0: span.subtitle-2 {{$t('profile:auth.changePassword')}}
+              v-text-field(
+                ref='iptCurrentPass'
+                v-model='currentPass'
+                outlined
+                :label='$t(`profile:auth.currentPassword`)'
+                type='password'
+                prepend-inner-icon='mdi-textbox-password'
+                )
+              v-text-field(
+                ref='iptNewPass'
+                v-model='newPass'
+                outlined
+                :label='$t(`profile:auth.newPassword`)'
+                type='password'
+                prepend-inner-icon='mdi-textbox-password'
+                counter='255'
+                loading
+                )
+                password-strength(slot='progress', v-model='newPass')
+              v-text-field(
+                ref='iptVerifyPass'
+                v-model='verifyPass'
+                outlined
+                :label='$t(`profile:auth.verifyPassword`)'
+                type='password'
+                prepend-inner-icon='mdi-textbox-password'
+                hide-details
+                )
           v-card-chin
           v-card-chin
             v-spacer
             v-spacer
-            v-btn.px-4(color='purple darken-4', dark)
+            v-btn.px-4(color='purple darken-4', dark, depressed, @click='changePassword', :loading='changePassLoading')
               v-icon(left) mdi-progress-check
               v-icon(left) mdi-progress-check
-              span Change Password
+              span {{$t('profile:auth.changePassword')}}
       v-flex(lg6 xs12)
       v-flex(lg6 xs12)
+        //- v-card
+        //-   v-toolbar(color='primary', dark, dense, flat)
+        //-     v-toolbar-title
+        //-       .subtitle-1 Picture
+        //-   v-card-title
+        //-     v-avatar.blue(v-if='picture.kind === `initials`', :size='40')
+        //-       span.white--text.subheading {{picture.initials}}
+        //-     v-avatar(v-else-if='picture.kind === `image`', :size='40')
+        //-       v-img(:src='picture.url')
+        //-     v-btn(outlined).mx-4 Upload Picture
+        //-     v-btn(outlined, disabled) Remove Picture
         v-card
         v-card
           v-toolbar(color='primary', dark, dense, flat)
           v-toolbar(color='primary', dark, dense, flat)
             v-toolbar-title
             v-toolbar-title
-              .subtitle-1 Picture
-          v-card-title
-            v-avatar(size='64', color='grey')
-              v-icon(size='64', color='grey lighten-2') mdi-account-circle
-            v-btn(depressed).mx-4.elevation-1 Upload Picture
-            v-btn(depressed, disabled).elevation-1 Remove Picture
-        v-card.mt-3
-          v-toolbar(color='primary', dark, dense, flat)
-            v-toolbar-title
-              .subtitle-1 Groups
+              .subtitle-1 {{$t('profile:groups.title')}}
           v-list(dense)
           v-list(dense)
             template(v-for='(grp, idx) of user.groups')
             template(v-for='(grp, idx) of user.groups')
               v-list-item(:key='`grp-id-` + grp')
               v-list-item(:key='`grp-id-` + grp')
@@ -192,34 +224,51 @@
         v-card.mt-3
         v-card.mt-3
           v-toolbar(color='teal', dark, dense, flat)
           v-toolbar(color='teal', dark, dense, flat)
             v-toolbar-title
             v-toolbar-title
-              .subtitle-1 Activity
+              .subtitle-1 {{$t('profile:activity.title')}}
           v-card-text.grey--text.text--darken-2
           v-card-text.grey--text.text--darken-2
-            .caption.grey--text Joined on
+            .caption.grey--text {{$t('profile:activity.joinedOn')}}
             .body-2: strong {{ user.createdAt | moment('LLLL') }}
             .body-2: strong {{ user.createdAt | moment('LLLL') }}
-            .caption.grey--text.mt-3 Profile last updated on
+            .caption.grey--text.mt-3 {{$t('profile:activity.lastUpdatedOn')}}
             .body-2: strong {{ user.updatedAt | moment('LLLL') }}
             .body-2: strong {{ user.updatedAt | moment('LLLL') }}
-            .caption.grey--text.mt-3 Last login on
-            .body-2: strong {{ user.lastLoginOn | moment('LLLL') }}
+            .caption.grey--text.mt-3 {{$t('profile:activity.lastLoginOn')}}
+            .body-2: strong {{ user.lastLoginAt | moment('LLLL') }}
             v-divider.mt-3
             v-divider.mt-3
-            .caption.grey--text.mt-3 Pages created
-            .body-2: strong 0
-            .caption.grey--text.mt-3 Comments posted
+            .caption.grey--text.mt-3 {{$t('profile:activity.pagesCreated')}}
+            .body-2: strong {{ user.pagesTotal }}
+            .caption.grey--text.mt-3 {{$t('profile:activity.commentsPosted')}}
             .body-2: strong 0
             .body-2: strong 0
 </template>
 </template>
 
 
 <script>
 <script>
+import { get } from 'vuex-pathify'
 import gql from 'graphql-tag'
 import gql from 'graphql-tag'
 import _ from 'lodash'
 import _ from 'lodash'
+import Cookies from 'js-cookie'
+import validate from 'validate.js'
+
+import PasswordStrength from '../common/password-strength.vue'
 
 
 export default {
 export default {
+  components: {
+    PasswordStrength
+  },
   data() {
   data() {
     return {
     return {
+      saveLoading: false,
+      changePassLoading: false,
       user: {
       user: {
+        name: 'unknown',
+        location: '',
+        jobTitle: '',
+        timezone: '',
         createdAt: '1970-01-01',
         createdAt: '1970-01-01',
         updatedAt: '1970-01-01',
         updatedAt: '1970-01-01',
-        lastLoginOn: '1970-01-01',
+        lastLoginAt: '1970-01-01',
         groups: []
         groups: []
       },
       },
+      currentPass: '',
+      newPass: '',
+      verifyPass: '',
       editPop: {
       editPop: {
         name: false,
         name: false,
         location: false,
         location: false,
@@ -480,6 +529,27 @@ export default {
       ]
       ]
     }
     }
   },
   },
+  computed: {
+    pictureUrl: get('user/pictureUrl'),
+    picture () {
+      if (this.pictureUrl && this.pictureUrl.length > 1) {
+        return {
+          kind: 'image',
+          url: this.pictureUrl
+        }
+      } else {
+        const nameParts = this.user.name.toUpperCase().split(' ')
+        let initials = _.head(nameParts).charAt(0)
+        if (nameParts.length > 1) {
+          initials += _.last(nameParts).charAt(0)
+        }
+        return {
+          kind: 'initials',
+          initials
+        }
+      }
+    }
+  },
   methods: {
   methods: {
     /**
     /**
      * Focus an input after delay
      * Focus an input after delay
@@ -490,6 +560,164 @@ export default {
           this.$refs[ipt].focus()
           this.$refs[ipt].focus()
         }, 200)
         }, 200)
       })
       })
+    },
+    /**
+     * Save User Profile
+     */
+    async saveProfile () {
+      this.saveLoading = true
+      this.$store.commit(`loadingStart`, 'profile-save')
+
+      try {
+        const respRaw = await this.$apollo.mutate({
+          mutation: gql`
+            mutation ($name: String!, $location: String!, $jobTitle: String!, $timezone: String!) {
+              users {
+                updateProfile(name: $name, location: $location, jobTitle: $jobTitle, timezone: $timezone) {
+                  responseResult {
+                    succeeded
+                    errorCode
+                    slug
+                    message
+                  }
+                  jwt
+                }
+              }
+            }
+          `,
+          variables: {
+            name: this.user.name,
+            location: this.user.location,
+            jobTitle: this.user.jobTitle,
+            timezone: this.user.timezone
+          }
+        })
+        const resp = _.get(respRaw, 'data.users.updateProfile.responseResult', {})
+        if (resp.succeeded) {
+          Cookies.set('jwt', _.get(respRaw, 'data.users.updateProfile.jwt', ''), { expires: 365 })
+          this.$store.set('user/name', this.user.name)
+          this.$store.commit('showNotification', {
+            message: this.$t('profile:save.success'),
+            style: 'success',
+            icon: 'check'
+          })
+        } else {
+          throw new Error(resp.message)
+        }
+      } catch (err) {
+        this.$store.commit('pushGraphError', err)
+      }
+
+      this.$store.commit(`loadingStop`, 'profile-save')
+      this.saveLoading = false
+    },
+    /**
+     * Change Password
+     */
+    async changePassword () {
+      const validation = validate({
+        current: this.currentPass,
+        password: this.newPass,
+        verifyPassword: this.verifyPass
+      }, {
+        current: {
+          presence: {
+            message: this.$t('auth:missingPassword'),
+            allowEmpty: false
+          },
+          length: {
+            minimum: 6,
+            tooShort: this.$t('auth:passwordTooShort')
+          }
+        },
+        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')
+          }
+        }
+      }, { fullMessages: false })
+
+      if (validation) {
+        if (validation.current) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.current[0],
+            icon: 'warning'
+          })
+          this.$refs.iptCurrentPass.focus()
+        } else if (validation.password) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.password[0],
+            icon: 'warning'
+          })
+          this.$refs.iptNewPass.focus()
+        } else if (validation.verifyPassword) {
+          this.$store.commit('showNotification', {
+            style: 'red',
+            message: validation.verifyPassword[0],
+            icon: 'warning'
+          })
+          this.$refs.iptVerifyPass.focus()
+        }
+      } else {
+        this.changePassLoading = true
+        this.$store.commit(`loadingStart`, 'profile-changepassword')
+
+        try {
+          const respRaw = await this.$apollo.mutate({
+            mutation: gql`
+              mutation ($current: String!, $new: String!) {
+                users {
+                  changePassword(current: $current, new: $new) {
+                    responseResult {
+                      succeeded
+                      errorCode
+                      slug
+                      message
+                    }
+                    jwt
+                  }
+                }
+              }
+            `,
+            variables: {
+              current: this.currentPass,
+              new: this.newPass
+            }
+          })
+          const resp = _.get(respRaw, 'data.users.changePassword.responseResult', {})
+          if (resp.succeeded) {
+            this.currentPass = ''
+            this.newPass = ''
+            this.verifyPass = ''
+            Cookies.set('jwt', _.get(respRaw, 'data.users.changePassword.jwt', ''), { expires: 365 })
+            this.$store.commit('showNotification', {
+              message: this.$t('profile:auth.changePassSuccess'),
+              style: 'success',
+              icon: 'check'
+            })
+          } else {
+            throw new Error(resp.message)
+          }
+        } catch (err) {
+          this.$store.commit('pushGraphError', err)
+        }
+
+        this.$store.commit(`loadingStop`, 'profile-changepassword')
+        this.changePassLoading = false
+      }
     }
     }
   },
   },
   apollo: {
   apollo: {
@@ -501,6 +729,7 @@ export default {
               id
               id
               name
               name
               email
               email
+              providerKey
               providerName
               providerName
               isSystem
               isSystem
               isVerified
               isVerified
@@ -509,8 +738,9 @@ export default {
               timezone
               timezone
               createdAt
               createdAt
               updatedAt
               updatedAt
-              lastLoginOn
+              lastLoginAt
               groups
               groups
+              pagesTotal
             }
             }
           }
           }
         }
         }

+ 6 - 6
client/components/register.vue

@@ -23,7 +23,7 @@
                     solo
                     solo
                     flat
                     flat
                     prepend-icon='mdi-email'
                     prepend-icon='mdi-email'
-                    background-color='grey lighten-4'
+                    :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     hide-details
                     hide-details
                     ref='iptEmail'
                     ref='iptEmail'
                     v-model='email'
                     v-model='email'
@@ -34,7 +34,7 @@
                     solo
                     solo
                     flat
                     flat
                     prepend-icon='mdi-textbox-password'
                     prepend-icon='mdi-textbox-password'
-                    background-color='grey lighten-4'
+                    :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     ref='iptPassword'
                     ref='iptPassword'
                     v-model='password'
                     v-model='password'
                     :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
                     :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
@@ -50,7 +50,7 @@
                     solo
                     solo
                     flat
                     flat
                     prepend-icon='mdi-textbox-password'
                     prepend-icon='mdi-textbox-password'
-                    background-color='grey lighten-4'
+                    :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     hide-details
                     hide-details
                     ref='iptVerifyPassword'
                     ref='iptVerifyPassword'
                     v-model='verifyPassword'
                     v-model='verifyPassword'
@@ -63,7 +63,7 @@
                     solo
                     solo
                     flat
                     flat
                     prepend-icon='mdi-account'
                     prepend-icon='mdi-account'
-                    background-color='grey lighten-4'
+                    :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     ref='iptName'
                     ref='iptName'
                     v-model='name'
                     v-model='name'
                     :placeholder='$t("auth:fields.name")'
                     :placeholder='$t("auth:fields.name")'
@@ -85,14 +85,14 @@
                     ) {{ $t('auth:actions.register') }}
                     ) {{ $t('auth:actions.register') }}
                   v-spacer
                   v-spacer
                 v-divider
                 v-divider
-                v-card-actions.py-3.grey.lighten-4
+                v-card-actions.py-3.grey(:class='$vuetify.theme.dark ? `darken-4-l1` : `lighten-4`')
                   v-spacer
                   v-spacer
                   i18next.caption(path='auth:switchToLogin.text', tag='div')
                   i18next.caption(path='auth:switchToLogin.text', tag='div')
                     a.caption(href='/login', place='link') {{ $t('auth:switchToLogin.link') }}
                     a.caption(href='/login', place='link') {{ $t('auth:switchToLogin.link') }}
                   v-spacer
                   v-spacer
 
 
     loader(v-model='isLoading', :mode='loaderMode', :icon='loaderIcon', :color='loaderColor', :title='loaderTitle', :subtitle='loaderSubtitle')
     loader(v-model='isLoading', :mode='loaderMode', :icon='loaderIcon', :color='loaderColor', :title='loaderTitle', :subtitle='loaderSubtitle')
-    nav-footer(color='grey darken-4', dark-color='grey darken-4')
+    nav-footer(color='grey darken-5', dark-color='grey darken-5')
     notify
     notify
 </template>
 </template>
 
 

+ 1 - 2
client/themes/default/components/nav-footer.vue

@@ -29,9 +29,8 @@ export default {
   computed: {
   computed: {
     company: get('site/company'),
     company: get('site/company'),
     contentLicense: get('site/contentLicense'),
     contentLicense: get('site/contentLicense'),
-    darkMode: get('site/dark'),
     bgColor() {
     bgColor() {
-      if (!this.darkMode) {
+      if (!this.$vuetify.theme.dark) {
         return this.color
         return this.color
       } else {
       } else {
         return this.darkColor
         return this.darkColor

+ 36 - 36
package.json

@@ -48,13 +48,13 @@
     "apollo-server": "2.11.0",
     "apollo-server": "2.11.0",
     "apollo-server-express": "2.11.0",
     "apollo-server-express": "2.11.0",
     "auto-load": "3.0.4",
     "auto-load": "3.0.4",
-    "aws-sdk": "2.639.0",
+    "aws-sdk": "2.653.0",
     "azure-search-client": "3.1.5",
     "azure-search-client": "3.1.5",
     "bcryptjs-then": "1.0.1",
     "bcryptjs-then": "1.0.1",
     "bluebird": "3.7.2",
     "bluebird": "3.7.2",
     "body-parser": "1.19.0",
     "body-parser": "1.19.0",
     "brute-knex": "4.0.0",
     "brute-knex": "4.0.0",
-    "chalk": "3.0.0",
+    "chalk": "4.0.0",
     "cheerio": "1.0.0-rc.3",
     "cheerio": "1.0.0-rc.3",
     "chokidar": "3.3.1",
     "chokidar": "3.3.1",
     "clean-css": "4.2.3",
     "clean-css": "4.2.3",
@@ -75,7 +75,7 @@
     "express-session": "1.17.0",
     "express-session": "1.17.0",
     "file-type": "14.1.4",
     "file-type": "14.1.4",
     "filesize": "6.1.0",
     "filesize": "6.1.0",
-    "fs-extra": "8.1.0",
+    "fs-extra": "9.0.0",
     "getos": "3.1.5",
     "getos": "3.1.5",
     "graphql": "14.6.0",
     "graphql": "14.6.0",
     "graphql-list-fields": "2.0.2",
     "graphql-list-fields": "2.0.2",
@@ -84,7 +84,7 @@
     "graphql-tools": "4.0.7",
     "graphql-tools": "4.0.7",
     "he": "1.2.0",
     "he": "1.2.0",
     "highlight.js": "9.18.1",
     "highlight.js": "9.18.1",
-    "i18next": "19.3.2",
+    "i18next": "19.3.4",
     "i18next-express-middleware": "1.9.1",
     "i18next-express-middleware": "1.9.1",
     "i18next-node-fs-backend": "2.1.3",
     "i18next-node-fs-backend": "2.1.3",
     "image-size": "0.8.3",
     "image-size": "0.8.3",
@@ -94,7 +94,7 @@
     "jsonwebtoken": "8.5.1",
     "jsonwebtoken": "8.5.1",
     "katex": "0.11.1",
     "katex": "0.11.1",
     "klaw": "3.0.0",
     "klaw": "3.0.0",
-    "knex": "0.20.11",
+    "knex": "0.20.13",
     "lodash": "4.17.15",
     "lodash": "4.17.15",
     "markdown-it": "10.0.0",
     "markdown-it": "10.0.0",
     "markdown-it-abbr": "1.0.4",
     "markdown-it-abbr": "1.0.4",
@@ -118,10 +118,10 @@
     "mssql": "6.2.0",
     "mssql": "6.2.0",
     "multer": "1.4.2",
     "multer": "1.4.2",
     "mysql2": "2.1.0",
     "mysql2": "2.1.0",
-    "nanoid": "2.1.11",
+    "nanoid": "3.0.2",
     "node-2fa": "1.1.2",
     "node-2fa": "1.1.2",
     "node-cache": "5.1.0",
     "node-cache": "5.1.0",
-    "nodemailer": "6.4.5",
+    "nodemailer": "6.4.6",
     "objection": "2.1.3",
     "objection": "2.1.3",
     "passport": "0.4.1",
     "passport": "0.4.1",
     "passport-auth0": "1.3.2",
     "passport-auth0": "1.3.2",
@@ -130,7 +130,7 @@
     "passport-discord": "0.1.3",
     "passport-discord": "0.1.3",
     "passport-dropbox-oauth2": "1.1.0",
     "passport-dropbox-oauth2": "1.1.0",
     "passport-facebook": "3.0.0",
     "passport-facebook": "3.0.0",
-    "passport-github2": "0.1.11",
+    "passport-github2": "0.1.12",
     "passport-gitlab2": "5.0.0",
     "passport-gitlab2": "5.0.0",
     "passport-google-oauth20": "2.0.0",
     "passport-google-oauth20": "2.0.0",
     "passport-jwt": "4.0.0",
     "passport-jwt": "4.0.0",
@@ -143,9 +143,9 @@
     "passport-saml": "1.3.3",
     "passport-saml": "1.3.3",
     "passport-twitch-oauth": "1.0.0",
     "passport-twitch-oauth": "1.0.0",
     "pem-jwk": "2.0.0",
     "pem-jwk": "2.0.0",
-    "pg": "7.18.2",
+    "pg": "8.0.0",
     "pg-hstore": "2.3.3",
     "pg-hstore": "2.3.3",
-    "pg-query-stream": "3.0.3",
+    "pg-query-stream": "3.0.4",
     "pg-tsquery": "8.1.0",
     "pg-tsquery": "8.1.0",
     "pug": "2.0.4",
     "pug": "2.0.4",
     "punycode": "2.1.1",
     "punycode": "2.1.1",
@@ -162,22 +162,22 @@
     "simple-git": "1.132.0",
     "simple-git": "1.132.0",
     "solr-node": "1.2.1",
     "solr-node": "1.2.1",
     "sqlite3": "4.1.1",
     "sqlite3": "4.1.1",
-    "ssh2": "0.8.8",
+    "ssh2": "0.8.9",
     "ssh2-promise": "0.1.6",
     "ssh2-promise": "0.1.6",
     "striptags": "3.1.1",
     "striptags": "3.1.1",
     "subscriptions-transport-ws": "0.9.16",
     "subscriptions-transport-ws": "0.9.16",
-    "tar-fs": "2.0.0",
+    "tar-fs": "2.0.1",
     "twemoji": "12.1.5",
     "twemoji": "12.1.5",
     "uslug": "1.0.4",
     "uslug": "1.0.4",
-    "uuid": "7.0.2",
+    "uuid": "7.0.3",
     "validate.js": "0.13.1",
     "validate.js": "0.13.1",
     "winston": "3.2.1",
     "winston": "3.2.1",
     "xss": "1.0.6",
     "xss": "1.0.6",
-    "yargs": "15.3.0"
+    "yargs": "15.3.1"
   },
   },
   "devDependencies": {
   "devDependencies": {
     "@babel/cli": "^7.8.4",
     "@babel/cli": "^7.8.4",
-    "@babel/core": "^7.8.7",
+    "@babel/core": "^7.9.0",
     "@babel/plugin-proposal-class-properties": "^7.8.3",
     "@babel/plugin-proposal-class-properties": "^7.8.3",
     "@babel/plugin-proposal-decorators": "^7.8.3",
     "@babel/plugin-proposal-decorators": "^7.8.3",
     "@babel/plugin-proposal-export-namespace-from": "^7.8.3",
     "@babel/plugin-proposal-export-namespace-from": "^7.8.3",
@@ -188,11 +188,11 @@
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/plugin-syntax-import-meta": "^7.8.3",
     "@babel/plugin-syntax-import-meta": "^7.8.3",
     "@babel/polyfill": "^7.8.7",
     "@babel/polyfill": "^7.8.7",
-    "@babel/preset-env": "^7.8.7",
+    "@babel/preset-env": "^7.9.0",
     "@mdi/font": "5.0.45",
     "@mdi/font": "5.0.45",
     "@panter/vue-i18next": "0.15.2",
     "@panter/vue-i18next": "0.15.2",
     "@requarks/ckeditor5": "12.4.0-wiki.14",
     "@requarks/ckeditor5": "12.4.0-wiki.14",
-    "@vue/babel-preset-app": "4.2.3",
+    "@vue/babel-preset-app": "4.3.0",
     "animate-sass": "0.8.2",
     "animate-sass": "0.8.2",
     "animated-number-vue": "1.0.0",
     "animated-number-vue": "1.0.0",
     "apollo-cache-inmemory": "1.6.5",
     "apollo-cache-inmemory": "1.6.5",
@@ -204,10 +204,10 @@
     "apollo-link-persisted-queries": "0.2.2",
     "apollo-link-persisted-queries": "0.2.2",
     "apollo-link-ws": "1.0.19",
     "apollo-link-ws": "1.0.19",
     "apollo-utilities": "1.3.3",
     "apollo-utilities": "1.3.3",
-    "autoprefixer": "9.7.4",
+    "autoprefixer": "9.7.5",
     "babel-eslint": "10.1.0",
     "babel-eslint": "10.1.0",
-    "babel-jest": "25.1.0",
-    "babel-loader": "^8.0.6",
+    "babel-jest": "25.2.6",
+    "babel-loader": "^8.1.0",
     "babel-plugin-graphql-tag": "2.5.0",
     "babel-plugin-graphql-tag": "2.5.0",
     "babel-plugin-lodash": "3.3.4",
     "babel-plugin-lodash": "3.3.4",
     "babel-plugin-prismjs": "2.0.1",
     "babel-plugin-prismjs": "2.0.1",
@@ -216,37 +216,37 @@
     "chart.js": "2.9.3",
     "chart.js": "2.9.3",
     "clean-webpack-plugin": "3.0.0",
     "clean-webpack-plugin": "3.0.0",
     "clipboard": "2.0.6",
     "clipboard": "2.0.6",
-    "codemirror": "5.52.0",
+    "codemirror": "5.52.2",
     "copy-webpack-plugin": "5.1.1",
     "copy-webpack-plugin": "5.1.1",
     "core-js": "3.6.4",
     "core-js": "3.6.4",
     "css-loader": "3.4.2",
     "css-loader": "3.4.2",
     "cssnano": "4.1.10",
     "cssnano": "4.1.10",
-    "d3": "5.15.0",
+    "d3": "5.15.1",
     "duplicate-package-checker-webpack-plugin": "3.0.0",
     "duplicate-package-checker-webpack-plugin": "3.0.0",
     "epic-spinners": "1.1.0",
     "epic-spinners": "1.1.0",
     "eslint": "6.8.0",
     "eslint": "6.8.0",
     "eslint-config-requarks": "1.0.7",
     "eslint-config-requarks": "1.0.7",
-    "eslint-config-standard": "14.1.0",
-    "eslint-plugin-import": "2.20.1",
-    "eslint-plugin-node": "11.0.0",
+    "eslint-config-standard": "14.1.1",
+    "eslint-plugin-import": "2.20.2",
+    "eslint-plugin-node": "11.1.0",
     "eslint-plugin-promise": "4.2.1",
     "eslint-plugin-promise": "4.2.1",
     "eslint-plugin-standard": "4.0.1",
     "eslint-plugin-standard": "4.0.1",
     "eslint-plugin-vue": "6.2.2",
     "eslint-plugin-vue": "6.2.2",
     "fibers": "4.0.2",
     "fibers": "4.0.2",
-    "file-loader": "5.1.0",
+    "file-loader": "6.0.0",
     "filepond": "4.13.0",
     "filepond": "4.13.0",
-    "filepond-plugin-file-validate-type": "1.2.4",
+    "filepond-plugin-file-validate-type": "1.2.5",
     "filesize.js": "2.0.0",
     "filesize.js": "2.0.0",
     "graphql-persisted-document-loader": "2.0.0",
     "graphql-persisted-document-loader": "2.0.0",
     "graphql-tag": "^2.10.3",
     "graphql-tag": "^2.10.3",
     "hammerjs": "2.0.8",
     "hammerjs": "2.0.8",
-    "html-webpack-plugin": "4.0.0-beta.8",
+    "html-webpack-plugin": "4.0.4",
     "html-webpack-pug-plugin": "2.0.0",
     "html-webpack-pug-plugin": "2.0.0",
     "i18next-chained-backend": "2.0.1",
     "i18next-chained-backend": "2.0.1",
     "i18next-localstorage-backend": "3.1.1",
     "i18next-localstorage-backend": "3.1.1",
     "i18next-xhr-backend": "3.2.2",
     "i18next-xhr-backend": "3.2.2",
     "ignore-loader": "0.1.2",
     "ignore-loader": "0.1.2",
-    "jest": "25.1.0",
+    "jest": "25.2.7",
     "js-cookie": "2.2.1",
     "js-cookie": "2.2.1",
     "mermaid": "8.4.8",
     "mermaid": "8.4.8",
     "mini-css-extract-plugin": "0.9.0",
     "mini-css-extract-plugin": "0.9.0",
@@ -260,7 +260,7 @@
     "postcss-loader": "3.0.0",
     "postcss-loader": "3.0.0",
     "postcss-preset-env": "6.7.0",
     "postcss-preset-env": "6.7.0",
     "postcss-selector-parser": "6.0.2",
     "postcss-selector-parser": "6.0.2",
-    "prismjs": "1.19.0",
+    "prismjs": "1.20.0",
     "pug-lint": "2.6.0",
     "pug-lint": "2.6.0",
     "pug-loader": "2.4.0",
     "pug-loader": "2.4.0",
     "pug-plain-loader": "1.0.0",
     "pug-plain-loader": "1.0.0",
@@ -272,9 +272,9 @@
     "script-ext-html-webpack-plugin": "2.1.4",
     "script-ext-html-webpack-plugin": "2.1.4",
     "simple-progress-webpack-plugin": "1.1.2",
     "simple-progress-webpack-plugin": "1.1.2",
     "style-loader": "1.1.3",
     "style-loader": "1.1.3",
-    "terser": "4.6.6",
+    "terser": "4.6.10",
     "twemoji-awesome": "1.0.6",
     "twemoji-awesome": "1.0.6",
-    "url-loader": "3.0.0",
+    "url-loader": "4.0.0",
     "velocity-animate": "1.5.2",
     "velocity-animate": "1.5.2",
     "viz.js": "2.1.2",
     "viz.js": "2.1.2",
     "vue": "2.6.11",
     "vue": "2.6.11",
@@ -283,7 +283,7 @@
     "vue-clipboards": "1.3.0",
     "vue-clipboards": "1.3.0",
     "vue-filepond": "6.0.2",
     "vue-filepond": "6.0.2",
     "vue-hot-reload-api": "2.3.4",
     "vue-hot-reload-api": "2.3.4",
-    "vue-loader": "15.9.0",
+    "vue-loader": "15.9.1",
     "vue-moment": "4.1.0",
     "vue-moment": "4.1.0",
     "vue-router": "3.1.6",
     "vue-router": "3.1.6",
     "vue-status-indicator": "1.2.1",
     "vue-status-indicator": "1.2.1",
@@ -291,12 +291,12 @@
     "vue2-animate": "2.1.3",
     "vue2-animate": "2.1.3",
     "vuedraggable": "2.23.2",
     "vuedraggable": "2.23.2",
     "vuescroll": "4.15.0",
     "vuescroll": "4.15.0",
-    "vuetify": "2.2.17",
+    "vuetify": "2.2.20",
     "vuetify-loader": "1.4.3",
     "vuetify-loader": "1.4.3",
     "vuex": "3.1.3",
     "vuex": "3.1.3",
     "vuex-pathify": "1.4.1",
     "vuex-pathify": "1.4.1",
-    "vuex-persistedstate": "2.7.1",
-    "webpack": "4.42.0",
+    "vuex-persistedstate": "3.0.1",
+    "webpack": "4.42.1",
     "webpack-bundle-analyzer": "3.6.1",
     "webpack-bundle-analyzer": "3.6.1",
     "webpack-cli": "3.3.11",
     "webpack-cli": "3.3.11",
     "webpack-dev-middleware": "3.7.2",
     "webpack-dev-middleware": "3.7.2",

+ 4 - 0
server/controllers/common.js

@@ -278,6 +278,10 @@ router.get(['/i', '/i/:id'], async (req, res, next) => {
  * Profile
  * Profile
  */
  */
 router.get(['/p', '/p/*'], (req, res, next) => {
 router.get(['/p', '/p/*'], (req, res, next) => {
+  if (!req.user || req.user.id < 1 || req.user.id === 2) {
+    return res.render('unauthorized', { action: 'view' })
+  }
+
   _.set(res.locals, 'pageMeta.title', 'User Profile')
   _.set(res.locals, 'pageMeta.title', 'User Profile')
   res.render('profile')
   res.render('profile')
 })
 })

+ 8 - 0
server/db/migrations-sqlite/2.3.10.js

@@ -0,0 +1,8 @@
+exports.up = knex => {
+  return knex.schema
+    .alterTable('users', table => {
+      table.string('lastLoginAt')
+    })
+}
+
+exports.down = knex => { }

+ 8 - 0
server/db/migrations/2.3.10.js

@@ -0,0 +1,8 @@
+exports.up = knex => {
+  return knex.schema
+    .alterTable('users', table => {
+      table.string('lastLoginAt')
+    })
+}
+
+exports.down = knex => { }

+ 2 - 2
server/graph/resolvers/system.js

@@ -9,7 +9,7 @@ const moment = require('moment')
 const graphHelper = require('../../helpers/graph')
 const graphHelper = require('../../helpers/graph')
 const request = require('request-promise')
 const request = require('request-promise')
 const crypto = require('crypto')
 const crypto = require('crypto')
-const nanoid = require('nanoid/non-secure/generate')
+const nanoid = require('nanoid/non-secure').customAlphabet('1234567890abcdef', 10)
 
 
 /* global WIKI */
 /* global WIKI */
 
 
@@ -150,7 +150,7 @@ module.exports = {
                       roles = _.concat(roles, ['write:pages', 'manage:pages', 'read:source', 'read:history', 'write:assets', 'manage:assets'])
                       roles = _.concat(roles, ['write:pages', 'manage:pages', 'read:source', 'read:history', 'write:assets', 'manage:assets'])
                     }
                     }
                     return {
                     return {
-                      id: nanoid('1234567890abcdef', 10),
+                      id: nanoid(),
                       roles: roles,
                       roles: roles,
                       match: r.exact ? 'EXACT' : 'START',
                       match: r.exact ? 'EXACT' : 'START',
                       deny: r.deny,
                       deny: r.deny,

+ 89 - 10
server/graph/resolvers/user.js

@@ -1,4 +1,5 @@
 const graphHelper = require('../../helpers/graph')
 const graphHelper = require('../../helpers/graph')
+const _ = require('lodash')
 
 
 /* global WIKI */
 /* global WIKI */
 
 
@@ -35,15 +36,16 @@ module.exports = {
       if (!usr.isActive) {
       if (!usr.isActive) {
         throw new WIKI.Error.AuthAccountBanned()
         throw new WIKI.Error.AuthAccountBanned()
       }
       }
-      const usrGroups = await usr.$relatedQuery('groups')
-      return {
-        ...usr,
-        password: '',
-        providerKey: '',
-        tfaSecret: '',
-        lastLoginOn: '1970-01-01',
-        groups: usrGroups.map(g => g.name)
-      }
+
+      const providerInfo = _.find(WIKI.data.authentication, ['key', usr.providerKey])
+
+      usr.providerName = _.get(providerInfo, 'title', 'Unknown')
+      usr.lastLoginAt = usr.lastLoginAt || usr.updatedAt
+      usr.password = ''
+      usr.providerId = ''
+      usr.tfaSecret = ''
+
+      return usr
     }
     }
   },
   },
   UserMutation: {
   UserMutation: {
@@ -124,11 +126,88 @@ module.exports = {
     },
     },
     resetPassword (obj, args) {
     resetPassword (obj, args) {
       return false
       return false
+    },
+    async updateProfile (obj, args, context) {
+      try {
+        if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
+          throw new WIKI.Error.AuthRequired()
+        }
+        const usr = await WIKI.models.users.query().findById(context.req.user.id)
+        if (!usr.isActive) {
+          throw new WIKI.Error.AuthAccountBanned()
+        }
+        if (!usr.isVerified) {
+          throw new WIKI.Error.AuthAccountNotVerified()
+        }
+
+        await WIKI.models.users.updateUser({
+          id: usr.id,
+          name: _.trim(args.name),
+          jobTitle: _.trim(args.jobTitle),
+          location: _.trim(args.location),
+          timezone: args.timezone
+        })
+
+        const newToken = await WIKI.models.users.refreshToken(usr.id)
+
+        return {
+          responseResult: graphHelper.generateSuccess('User profile updated successfully'),
+          jwt: newToken.token
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
+    async changePassword (obj, args, context) {
+      try {
+        if (!context.req.user || context.req.user.id < 1 || context.req.user.id === 2) {
+          throw new WIKI.Error.AuthRequired()
+        }
+        const usr = await WIKI.models.users.query().findById(context.req.user.id)
+        if (!usr.isActive) {
+          throw new WIKI.Error.AuthAccountBanned()
+        }
+        if (!usr.isVerified) {
+          throw new WIKI.Error.AuthAccountNotVerified()
+        }
+        if (usr.providerKey !== 'local') {
+          throw new WIKI.Error.AuthProviderInvalid()
+        }
+        try {
+          await usr.verifyPassword(args.current)
+        } catch (err) {
+          throw new WIKI.Error.AuthPasswordInvalid()
+        }
+
+        await WIKI.models.users.updateUser({
+          id: usr.id,
+          newPassword: args.new
+        })
+
+        const newToken = await WIKI.models.users.refreshToken(usr)
+
+        return {
+          responseResult: graphHelper.generateSuccess('Password changed successfully'),
+          jwt: newToken.token
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
     }
     }
   },
   },
   User: {
   User: {
-    groups(usr) {
+    groups (usr) {
       return usr.$relatedQuery('groups')
       return usr.$relatedQuery('groups')
     }
     }
+  },
+  UserProfile: {
+    async groups (usr) {
+      const usrGroups = await usr.$relatedQuery('groups')
+      return usrGroups.map(g => g.name)
+    },
+    async pagesTotal (usr) {
+      const result = await WIKI.models.pages.query().count('* as total').where('creatorId', usr.id).first()
+      return _.toSafeInteger(result.total)
+    }
   }
   }
 }
 }

+ 20 - 1
server/graph/schemas/user.graphql

@@ -76,6 +76,18 @@ type UserMutation {
   resetPassword(
   resetPassword(
     id: Int!
     id: Int!
   ): DefaultResponse
   ): DefaultResponse
+
+  updateProfile(
+    name: String!
+    location: String!
+    jobTitle: String!
+    timezone: String!
+  ): UserTokenResponse
+
+  changePassword(
+    current: String!
+    new: String!
+  ): UserTokenResponse
 }
 }
 
 
 # -----------------------------------------------
 # -----------------------------------------------
@@ -117,6 +129,7 @@ type UserProfile {
   id: Int!
   id: Int!
   name: String!
   name: String!
   email: String!
   email: String!
+  providerKey: String
   providerName: String
   providerName: String
   isSystem: Boolean!
   isSystem: Boolean!
   isVerified: Boolean!
   isVerified: Boolean!
@@ -125,6 +138,12 @@ type UserProfile {
   timezone: String!
   timezone: String!
   createdAt: Date!
   createdAt: Date!
   updatedAt: Date!
   updatedAt: Date!
-  lastLoginOn: Date!
+  lastLoginAt: Date
   groups: [String]!
   groups: [String]!
+  pagesTotal: Int!
+}
+
+type UserTokenResponse {
+  responseResult: ResponseStatus!
+  jwt: String
 }
 }

+ 4 - 0
server/helpers/error.js

@@ -57,6 +57,10 @@ module.exports = {
     message: 'Invalid email / username or password.',
     message: 'Invalid email / username or password.',
     code: 1002
     code: 1002
   }),
   }),
+  AuthPasswordInvalid: CustomError('AuthPasswordInvalid', {
+    message: 'Password is incorrect.',
+    code: 1020
+  }),
   AuthProviderInvalid: CustomError('AuthProviderInvalid', {
   AuthProviderInvalid: CustomError('AuthProviderInvalid', {
     message: 'Invalid authentication provider.',
     message: 'Invalid authentication provider.',
     code: 1003
     code: 1003

+ 1 - 1
server/models/userKeys.js

@@ -2,7 +2,7 @@
 
 
 const Model = require('objection').Model
 const Model = require('objection').Model
 const moment = require('moment')
 const moment = require('moment')
-const nanoid = require('nanoid')
+const nanoid = require('nanoid').nanoid
 
 
 /**
 /**
  * Users model
  * Users model

+ 3 - 0
server/models/users.js

@@ -341,6 +341,9 @@ module.exports = class User extends Model {
       user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions')
       user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions')
     }
     }
 
 
+    // Update Last Login Date
+    await WIKI.models.users.query().findById(user.id).patch({ lastLoginAt: new Date().toISOString() })
+
     return {
     return {
       token: jwt.sign({
       token: jwt.sign({
         id: user.id,
         id: user.id,

File diff suppressed because it is too large
+ 287 - 350
yarn.lock


Some files were not shown because too many files changed in this diff