Переглянути джерело

feat: mandatory password change on login + UI fixes

Nick 5 роки тому
батько
коміт
d3e693ab46
40 змінених файлів з 975 додано та 575 видалено
  1. 1 1
      client/components/admin/admin-contribute.vue
  2. 1 1
      client/components/admin/admin-dev-flags.vue
  3. 108 26
      client/components/admin/admin-general.vue
  4. 17 22
      client/components/admin/admin-groups-edit-users.vue
  5. 1 1
      client/components/admin/admin-groups.vue
  6. 3 3
      client/components/admin/admin-pages-edit.vue
  7. 1 1
      client/components/admin/admin-pages.vue
  8. 2 2
      client/components/admin/admin-search.vue
  9. 10 4
      client/components/admin/admin-storage.vue
  10. 10 10
      client/components/admin/admin-system.vue
  11. 2 1
      client/components/admin/admin-users-create.vue
  12. 59 38
      client/components/admin/admin-users-edit.vue
  13. 1 1
      client/components/common/page-delete.vue
  14. 3 3
      client/components/common/user-search.vue
  15. 126 46
      client/components/login.vue
  16. 13 1
      client/graph/admin/site/site-mutation-save-config.gql
  17. 6 0
      client/graph/admin/site/site-query-config.gql
  18. 2 0
      client/graph/admin/users/users-query-single.gql
  19. 13 0
      client/graph/login/login-mutation-changepassword.gql
  20. 3 2
      client/graph/login/login-mutation-login.gql
  21. 3 2
      client/graph/login/login-mutation-tfa.gql
  22. 16 3
      client/scss/pages/_error.scss
  23. 20 19
      client/themes/default/components/page.vue
  24. 1 1
      dev/templates/legacy.pug
  25. 1 1
      dev/templates/master.pug
  26. 56 53
      package.json
  27. 7 0
      server/app/data.yml
  28. 23 9
      server/graph/resolvers/authentication.js
  29. 11 2
      server/graph/resolvers/site.js
  30. 10 4
      server/graph/schemas/authentication.graphql
  31. 12 0
      server/graph/schemas/site.graphql
  32. 2 0
      server/graph/schemas/user.graphql
  33. 12 3
      server/middlewares/security.js
  34. 1 1
      server/models/userKeys.js
  35. 61 16
      server/models/users.js
  36. 1 1
      server/modules/authentication/local/definition.yml
  37. 7 21
      server/views/error.pug
  38. 1 1
      server/views/legacy/master.pug
  39. 1 1
      server/views/master.pug
  40. 347 274
      yarn.lock

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

@@ -66,7 +66,7 @@
               v-tab-item(:transition='false', :reverse-transition='false')
                 .body-1.pa-3 {{ $t('admin:contribute.tshirts') }}
                 v-card-actions.ml-2
-                  v-btn(outline, :color='darkMode ? `blue lighten-1` : `primary`', href='https://wikijs.threadless.com', large)
+                  v-btn(outlined, :color='darkMode ? `blue lighten-1` : `primary`', href='https://wikijs.threadless.com', large)
                     v-icon(left) mdi-tshirt-crew
                     span {{ $t('admin:contribute.shop') }}
             v-divider.mt-3

+ 1 - 1
client/components/admin/admin-dev-flags.vue

@@ -13,7 +13,7 @@
             span {{$t('common:actions.apply')}}
 
         v-card.mt-3.white.grey--text.text--darken-3
-          v-alert(color='red', value='true', icon='mdi-alert', dark, prominent)
+          v-alert(color='red', :value='true', icon='mdi-alert', dark, prominent)
             span Do NOT enable these flags unless you know what you're doing!
             .caption Doing so may result in data loss or broken installation!
           v-card-text

+ 108 - 26
client/components/admin/admin-general.vue

@@ -92,14 +92,14 @@
 
             v-flex(lg6 xs12)
               v-card.animated.fadeInUp.wait-p4s
-                v-toolbar(color='primary', dark, dense, flat)
+                v-toolbar(color='indigo', dark, dense, flat)
                   v-toolbar-title.subtitle-1 Features
                   v-spacer
-                  v-chip(label, color='white', small).primary--text coming soon
+                  v-chip(label, color='white', small).indigo--text coming soon
                 v-card-text
                   v-switch(
                     label='Asset Image Optimization'
-                    color='primary'
+                    color='indigo'
                     v-model='config.featureTinyPNG'
                     persistent-hint
                     hint='Image optimization tool to reduce filesize and bandwidth costs.'
@@ -119,7 +119,7 @@
                   v-divider.mt-3
                   v-switch(
                     label='Page Ratings'
-                    color='primary'
+                    color='indigo'
                     v-model='config.featurePageRatings'
                     persistent-hint
                     hint='Allow users to rate pages.'
@@ -129,7 +129,7 @@
                   v-divider.mt-3
                   v-switch(
                     label='Page Comments'
-                    color='primary'
+                    color='indigo'
                     v-model='config.featurePageComments'
                     persistent-hint
                     hint='Allow users to leave comments on pages.'
@@ -139,13 +139,75 @@
                   v-divider.mt-3
                   v-switch(
                     label='Personal Wikis'
-                    color='primary'
+                    color='indigo'
                     v-model='config.featurePersonalWikis'
                     persistent-hint
                     hint='Allow users to have their own personal wiki.'
                     disabled
                     )
 
+              v-card.mt-5.animated.fadeInUp.wait-p5s
+                v-toolbar(color='red darken-2', dark, dense, flat)
+                  v-toolbar-title.subtitle-1 Security
+                v-card-text
+                  v-alert(outlined, color='red darken-2', icon='mdi-information-outline').body-2 Make sure to understand the implications before turning on / off a security feature.
+                  v-switch.mt-3(
+                    label='Block IFrame Embedding'
+                    color='red darken-2'
+                    v-model='config.securityIframe'
+                    persistent-hint
+                    hint='Prevents other websites from embedding your wiki in an iframe. This provides clickjacking protection.'
+                    )
+                  v-divider.mt-3
+                  v-switch(
+                    label='Same Origin Referrer Policy'
+                    color='red darken-2'
+                    v-model='config.securityReferrerPolicy'
+                    persistent-hint
+                    hint='Limits the referrer header to same origin.'
+                    )
+
+                  v-divider.mt-3
+                  v-switch(
+                    label='Enforce HSTS'
+                    color='red darken-2'
+                    v-model='config.securityHSTS'
+                    persistent-hint
+                    hint='This ensures the connection cannot be established through an insecure HTTP connection.'
+                    )
+                  v-select.mt-5(
+                    outlined
+                    label='HSTS Max Age'
+                    :items='hstsDurations'
+                    v-model='config.securityHSTSDuration'
+                    prepend-icon='mdi-subdirectory-arrow-right'
+                    :disabled='!config.securityHSTS'
+                    hide-details
+                    style='max-width: 450px;'
+                    )
+                  .pl-11.mt-3
+                    .caption Defines the duration for which the server should only deliver content through HTTPS.
+                    .caption It's a good idea to start with small values and make sure that nothing breaks on your wiki before moving to longer values.
+
+                  v-divider.mt-3
+                  v-switch(
+                    label='Enforce CSP'
+                    color='red darken-2'
+                    v-model='config.securityCSP'
+                    persistent-hint
+                    hint='Restricts scripts to pre-approved content sources.'
+                    disabled
+                    )
+                  v-textarea.mt-5(
+                    label='CSP Directives'
+                    outlined
+                    v-model='config.securityCSPDirectives'
+                    prepend-icon='mdi-subdirectory-arrow-right'
+                    persistent-hint
+                    hint='One directive per line.'
+                    disabled
+                  )
+
 </template>
 
 <script>
@@ -163,12 +225,6 @@ export default {
         { text: 'Google Analytics', value: 'ga' },
         { text: 'Google Tag Manager', value: 'gtm' }
       ],
-      metaRobots: [
-        { text: 'Index', value: 'index' },
-        { text: 'Follow', value: 'follow' },
-        { text: 'No Index', value: 'noindex' },
-        { text: 'No Follow', value: 'nofollow' }
-      ],
       config: {
         host: '',
         title: '',
@@ -183,8 +239,28 @@ export default {
         featurePageRatings: false,
         featurePageComments: false,
         featurePersonalWikis: false,
-        featureTinyPNG: false
-      }
+        featureTinyPNG: false,
+        securityIframe: true,
+        securityReferrerPolicy: true,
+        securityHSTS: false,
+        securityHSTSDuration: 0,
+        securityCSP: false,
+        securityCSPDirectives: ''
+      },
+      hstsDurations: [
+        { value: 300, text: '5 minutes' },
+        { value: 86400, text: '1 day' },
+        { value: 604800, text: '1 week' },
+        { value: 2592000, text: '1 month' },
+        { value: 31536000, text: '1 year' },
+        { value: 63072000, text: '2 years' }
+      ],
+      metaRobots: [
+        { text: 'Index', value: 'index' },
+        { text: 'Follow', value: 'follow' },
+        { text: 'No Index', value: 'noindex' },
+        { text: 'No Follow', value: 'nofollow' }
+      ]
     }
   },
   computed: {
@@ -198,18 +274,24 @@ export default {
         await this.$apollo.mutate({
           mutation: siteUpdateConfigMutation,
           variables: {
-            host: this.config.host || '',
-            title: this.config.title || '',
-            description: this.config.description || '',
-            robots: this.config.robots || [],
-            analyticsService: this.config.analyticsService || '',
-            analyticsId: this.config.analyticsId || '',
-            company: this.config.company || '',
-            hasLogo: this.config.hasLogo || false,
-            logoIsSquare: this.config.logoIsSquare || false,
-            featurePageRatings: this.config.featurePageRatings || false,
-            featurePageComments: this.config.featurePageComments || false,
-            featurePersonalWikis: this.config.featurePersonalWikis || false
+            host: _.get(this.config, 'host', ''),
+            title: _.get(this.config, 'title', ''),
+            description: _.get(this.config, 'description', ''),
+            robots: _.get(this.config, 'robots', []),
+            analyticsService: _.get(this.config, 'analyticsService', ''),
+            analyticsId: _.get(this.config, 'analyticsId', ''),
+            company: _.get(this.config, 'company', ''),
+            hasLogo: _.get(this.config, 'hasLogo', false),
+            logoIsSquare: _.get(this.config, 'logoIsSquare', false),
+            featurePageRatings: _.get(this.config, 'featurePageRatings', false),
+            featurePageComments: _.get(this.config, 'featurePageComments', false),
+            featurePersonalWikis: _.get(this.config, 'featurePersonalWikis', false),
+            securityIframe: _.get(this.config, 'securityIframe', false),
+            securityReferrerPolicy: _.get(this.config, 'securityReferrerPolicy', false),
+            securityHSTS: _.get(this.config, 'securityHSTS', false),
+            securityHSTSDuration: _.get(this.config, 'securityHSTSDuration', 0),
+            securityCSP: _.get(this.config, 'securityCSP', false),
+            securityCSPDirectives: _.get(this.config, 'securityCSPDirectives', '')
           },
           watchLoading (isLoading) {
             this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-site-update')

+ 17 - 22
client/components/admin/admin-groups-edit-users.vue

@@ -23,26 +23,21 @@
       must-sort,
       hide-default-footer
     )
-      template(slot='item', slot-scope='props')
-        tr(:active='props.selected')
-          td.text-xs-right {{ props.item.id }}
-          td {{ props.item.name }}
-          td {{ props.item.email }}
-          td
-            v-menu(bottom, right, min-width='200')
-              template(v-slot:activator='{ on }')
-                v-btn(icon, v-on='on', small)
-                  v-icon.grey--text.text--darken-1 mdi-dots-horizontal
-              v-list(dense, nav)
-                v-list-item(:to='`/users/` + props.item.id')
-                  v-list-item-action: v-icon(color='primary') mdi-account-outline
-                  v-list-item-content
-                    v-list-item-title View User Profile
-                template(v-if='props.item.id !== 2')
-                  v-list-item(@click='unassignUser(props.item.id)')
-                    v-list-item-action: v-icon(color='orange') mdi-account-remove-outline
-                    v-list-item-content
-                      v-list-item-title Unassign
+      template(v-slot:item.actions='{ item }')
+        v-menu(bottom, right, min-width='200')
+          template(v-slot:activator='{ on }')
+            v-btn(icon, v-on='on', small)
+              v-icon.grey--text.text--darken-1 mdi-dots-horizontal
+          v-list(dense, nav)
+            v-list-item(:to='`/users/` + item.id')
+              v-list-item-action: v-icon(color='primary') mdi-account-outline
+              v-list-item-content
+                v-list-item-title View User Profile
+            template(v-if='item.id !== 2')
+              v-list-item(@click='unassignUser(item.id)')
+                v-list-item-action: v-icon(color='orange') mdi-account-remove-outline
+                v-list-item-content
+                  v-list-item-title Unassign
       template(slot='no-data')
         v-alert.ma-3(icon='warning', outlined) No users to display.
     .text-center.py-2(v-if='group.users.length > 15')
@@ -70,10 +65,10 @@ export default {
   data() {
     return {
       headers: [
-        { text: 'ID', value: 'id', width: 50, align: 'right' },
+        { text: 'ID', value: 'id', width: 50 },
         { text: 'Name', value: 'name' },
         { text: 'Email', value: 'email' },
-        { text: '', value: 'actions', sortable: false, width: 50 }
+        { text: 'Actions', value: 'actions', sortable: false, width: 50 }
       ],
       searchUserDialog: false,
       pagination: 1,

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

@@ -17,7 +17,7 @@
                 span New Group
             v-card
               .dialog-header.is-short New Group
-              v-card-text
+              v-card-text.pt-5
                 v-text-field.md2(
                   outlined
                   prepend-icon='mdi-account-group'

+ 3 - 3
client/components/admin/admin-pages-edit.vue

@@ -30,11 +30,11 @@
             template(v-slot:activator='{ on }')
               v-btn.mx-1.animated.fadeInDown.wait-p1s(color='red', large, outlined, v-on='on')
                 v-icon(color='red') mdi-trash-can-outline
-            v-card.wiki-form
+            v-card
               .dialog-header.is-short.is-red
                 v-icon.mr-2(color='white') mdi-file-document-box-remove-outline
                 span {{$t('common:page.delete')}}
-              v-card-text
+              v-card-text.pt-5
                 i18next.body-2(path='common:page.deleteTitle', tag='div')
                   span.red--text.text--darken-2(place='title') {{page.title}}
                 .caption {{$t('common:page.deleteSubtitle')}}
@@ -44,7 +44,7 @@
                   span.red--text.text--darken-2 /{{page.path}}
               v-card-chin
                 v-spacer
-                v-btn(flat, @click='deletePageDialog = false', :disabled='loading') {{$t('common:actions.cancel')}}
+                v-btn(text, @click='deletePageDialog = false', :disabled='loading') {{$t('common:actions.cancel')}}
                 v-btn(color='red darken-2', @click='deletePage', :loading='loading').white--text {{$t('common:actions.delete')}}
           v-btn.ml-1.animated.fadeInDown(color='teal', large, outlined, @click='rerenderPage')
             v-icon(left) mdi-cube-scan

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

@@ -64,7 +64,7 @@
                 td {{ props.item.createdAt | moment('calendar') }}
                 td {{ props.item.updatedAt | moment('calendar') }}
             template(slot='no-data')
-              v-alert.ma-3(icon='warning', :value='true', outline) No pages to display.
+              v-alert.ma-3(icon='mdi-alert', :value='true', outlined) No pages to display.
           .text-xs-center.py-2.animated.fadeInDown(v-if='this.pageTotal > 1')
             v-pagination(v-model='pagination', :length='pageTotal')
 </template>

+ 2 - 2
client/components/admin/admin-search.vue

@@ -26,8 +26,8 @@
               v-list-item(:key='eng.key', @click='selectedEngine = eng.key', :disabled='!eng.isAvailable')
                 v-list-item-avatar(size='24')
                   v-icon(color='grey', v-if='!eng.isAvailable') mdi-minus-box-outline
-                  v-icon(color='primary', v-else-if='eng.key === selectedEngine') mdi-checkbox-marked-outline
-                  v-icon(color='grey', v-else) mdi-checkbox-blank-outline
+                  v-icon(color='primary', v-else-if='eng.key === selectedEngine') mdi-checkbox-marked-circle-outline
+                  v-icon(color='grey', v-else) mdi-checkbox-blank-circle-outline
                 v-list-item-content
                   v-list-item-title.body-2(:class='!eng.isAvailable ? `grey--text` : (selectedEngine === eng.key ? `primary--text` : ``)') {{ eng.title }}
                   v-list-item-subtitle: .caption(:class='!eng.isAvailable ? `grey--text text--lighten-1` : (selectedEngine === eng.key ? `blue--text ` : ``)') {{ eng.description }}

+ 10 - 4
client/components/admin/admin-storage.vue

@@ -49,7 +49,7 @@
                     v-icon(color='white') mdi-clock-outline
                   v-list-item-content
                     v-list-item-title.body-2 {{tgt.title}}
-                    v-list-item-sub-title.purple--text.caption {{tgt.status}}
+                    v-list-item-subtitle.purple--text.caption {{tgt.status}}
                   v-list-item-action
                     v-progress-circular(indeterminate, :size='20', :width='2', color='purple')
                 template(v-else-if='tgt.status === `operational`')
@@ -57,13 +57,13 @@
                     v-icon(color='white') mdi-check-circle
                   v-list-item-content
                     v-list-item-title.body-2 {{tgt.title}}
-                    v-list-item-sub-title.green--text.caption {{$t('admin:storage.lastSync', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
+                    v-list-item-subtitle.green--text.caption {{$t('admin:storage.lastSync', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
                 template(v-else)
                   v-list-item-avatar(color='red')
                     v-icon(color='white') mdi-close-circle-outline
                   v-list-item-content
                     v-list-item-title.body-2 {{tgt.title}}
-                    v-list-item-sub-title.red--text.caption {{$t('admin:storage.lastSyncAttempt', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
+                    v-list-item-subtitle.red--text.caption {{$t('admin:storage.lastSyncAttempt', { time: $options.filters.moment(tgt.lastAttempt, 'from') })}}
                   v-list-item-action
                     v-menu
                       v-btn(slot='activator', icon)
@@ -86,6 +86,10 @@
                 img(:src='target.logo', :alt='target.title')
               .body-2.pt-3 {{target.description}}
               .body-2.pt-3.pb-5: a(:href='target.website') {{target.website}}
+              i18next.body-2(path='admin:storage.targetState', tag='div', v-if='target.isEnabled')
+                v-chip(color='green', small, dark, label, place='state') {{$t('admin:storage.targetStateActive')}}
+              i18next.body-2(path='admin:storage.targetState', tag='div', v-else)
+                v-chip(color='red', small, dark, label, place='state') {{$t('admin:storage.targetStateInactive')}}
               v-divider.mt-3
               .overline.my-5 {{$t('admin:storage.targetConfig')}}
               .body-2.ml-3(v-if='!target.config || target.config.length < 1'): em {{$t('admin:storage.noConfigOption')}}
@@ -179,6 +183,8 @@
               template(v-if='target.actions && target.actions.length > 0')
                 v-divider.mt-3
                 .overline.my-5 {{$t('admin:storage.actions')}}
+                v-alert(outlined, :value='!target.isEnabled', color='red', icon='mdi-alert')
+                  .body-2 {{$t('admin:storage.actionsInactiveWarn')}}
                 v-container.pt-0(grid-list-xl, fluid)
                   v-layout(row, wrap, fill-height)
                     v-flex(xs12, lg6, xl4, v-for='act of target.actions', :key='act.handler')
@@ -190,7 +196,7 @@
                             @click='executeAction(target.key, act.handler)'
                             outlined
                             :color='$vuetify.theme.dark ? `blue` : `primary`'
-                            :disabled='runningAction'
+                            :disabled='runningAction || !target.isEnabled'
                             :loading='runningActionHandler === act.handler'
                             ) {{$t('admin:storage.actionRun')}}
 

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

@@ -13,13 +13,13 @@
               v-btn.animated.fadeInLeft.wait-p2s.btn-animate-rotate(fab, absolute, :right='!$vuetify.rtl', :left='$vuetify.rtl', top, small, light, @click='refresh'): v-icon(color='grey') mdi-refresh
               v-subheader Wiki.js
               v-list(two-line, dense)
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue.white--text mdi-application-export
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.currentVersion') }}
                     v-list-item-subtitle {{ info.currentVersion }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue.white--text mdi-inbox-arrow-up
                   v-list-item-content
@@ -31,38 +31,38 @@
               v-divider.mt-3
               v-subheader {{ $t('admin:system.hostInfo') }}
               v-list(two-line, dense)
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-avatar.blue-grey(size='40')
                       v-icon(color='white') {{platformLogo}}
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.os') }}
                     v-list-item-subtitle {{ (info.platform === 'docker') ? 'Docker Container (Linux)' : info.operatingSystem }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue-grey.white--text mdi-desktop-classic
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.hostname') }}
                     v-list-item-subtitle {{ info.hostname }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue-grey.white--text mdi-cpu-64-bit
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.cpuCores') }}
                     v-list-item-subtitle {{ info.cpuCores }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue-grey.white--text mdi-memory
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.totalRAM') }}
                     v-list-item-subtitle {{ info.ramTotal }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue-grey.white--text mdi-iframe-outline
                   v-list-item-content
                     v-list-item-title {{ $t('admin:system.workingDirectory') }}
                     v-list-item-subtitle {{ info.workingDirectory }}
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-icon.blue-grey.white--text mdi-card-bulleted-settings-outline
                   v-list-item-content
@@ -73,7 +73,7 @@
             v-card.pb-3.animated.fadeInUp.wait-p4s
               v-subheader Node.js
               v-list(dense)
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-avatar.light-green(size='40')
                       v-icon(color='white') mdi-nodejs
@@ -83,7 +83,7 @@
               v-divider.mt-3
               v-subheader {{ info.dbType }}
               v-list(dense)
-                v-list-item(avatar)
+                v-list-item
                   v-list-item-avatar
                     v-avatar.indigo.darken-1(size='40')
                       v-icon(color='white') mdi-database

+ 2 - 1
client/components/admin/admin-users-create.vue

@@ -8,7 +8,7 @@
         v-btn.mx-0(color='white', outlined, disabled, dark)
           v-icon(left) mdi-database-import
           span Bulk Import
-      v-card-text
+      v-card-text.pt-5
         v-select(
           :items='providers'
           item-text='title'
@@ -89,6 +89,7 @@
           label='Send a welcome email'
           hide-details
           v-model='sendWelcomeEmail'
+          disabled
         )
       v-card-chin
         v-spacer

+ 59 - 38
client/components/admin/admin-users-edit.vue

@@ -3,12 +3,26 @@
     v-layout(row, wrap)
       v-flex(xs12)
         .admin-header
-          img.animated.fadeInUp(src='/svg/icon-male-user.svg', alt='Edit User', style='width: 80px;')
+          img.animated.fadeInUp(src='/svg/icon-male-user.svg', :alt='$t(`admin:users.edit`)', style='width: 80px;')
           .admin-header-title
-            .headline.blue--text.text--darken-2.animated.fadeInLeft Edit User
+            .headline.blue--text.text--darken-2.animated.fadeInLeft {{$t('admin:users.edit')}}
             .subtitle-1.grey--text.animated.fadeInLeft.wait-p2s {{user.name}}
           v-spacer
-          .caption.grey--text.animated.fadeInRight.wait-p5s ID #[strong {{user.id}}]
+          template(v-if='user.isActive')
+            status-indicator.mr-3(positive, pulse)
+            .caption.green--text {{$t('admin:users.active')}}
+          template(v-else)
+            status-indicator.mr-3(negative, pulse)
+            .caption.red--text {{$t('admin:users.inactive')}}
+          template(v-if='user.isVerified')
+            status-indicator.mr-3.ml-4(active, pulse)
+            .caption.blue--text {{$t('admin:users.verified')}}
+          template(v-else)
+            status-indicator.mr-3.ml-4(intermediary, pulse)
+            .caption.deep-orange--text {{$t('admin:users.unverified')}}
+          v-spacer
+          i18next.caption.grey--text.animated.fadeInRight.wait-p5s(path='admin:users.id', tag='div')
+            strong(place='id') {{user.id}}
           v-divider.animated.fadeInRight.wait-p3s.ml-3(vertical)
           v-btn.ml-3.animated.fadeInDown.wait-p2s(color='grey', large, outlined, to='/users')
             v-icon mdi-arrow-left
@@ -30,15 +44,15 @@
         v-card.animated.fadeInUp
           v-toolbar(color='primary', dense, dark, flat)
             v-icon.mr-2 mdi-information-variant
-            span Basic Info
+            span {{$t('admin:users.basicInfo')}}
           v-list.py-0(two-line, dense)
             v-list-item
               v-list-item-avatar(size='32')
                 v-icon mdi-email-variant
               v-list-item-content
-                v-list-item-title Email
+                v-list-item-title {{$t('admin:users.email')}}
                 v-list-item-subtitle {{ user.email }}
-              v-list-item-action(v-if='!user.isSystem')
+              v-list-item-action(v-if='!user.isSystem && user.providerKey === `local`')
                 v-menu(
                   v-model='editPop.email'
                   :close-on-content-click='false'
@@ -52,7 +66,7 @@
                     v-text-field(
                       ref='iptEmail'
                       v-model='user.email'
-                      label='Email'
+                      :label='$t(`admin:users.email`)'
                       solo
                       hide-details
                       append-icon='mdi-check'
@@ -66,7 +80,7 @@
               v-list-item-avatar(size='32')
                 v-icon mdi-account
               v-list-item-content
-                v-list-item-title Display Name
+                v-list-item-title {{$t('admin:users.displayName')}}
                 v-list-item-subtitle {{ user.name }}
               v-list-item-action
                 v-menu(
@@ -82,7 +96,7 @@
                     v-text-field(
                       ref='iptDisplayName'
                       v-model='user.name'
-                      label='Display Name'
+                      :label='$t(`admin:users.displayName`)'
                       solo
                       hide-details
                       append-icon='mdi-check'
@@ -94,13 +108,13 @@
         v-card.mt-3.animated.fadeInUp.wait-p2s(v-if='!user.isSystem')
           v-toolbar(color='primary', dense, dark, flat)
             v-icon.mr-2 mdi-lock-outline
-            span Authentication
+            span {{$t('admin:users.authentication')}}
           v-list.py-0(two-line, dense)
             v-list-item
               v-list-item-avatar(size='32')
                 v-icon mdi-domain
               v-list-item-content
-                v-list-item-title Provider
+                v-list-item-title {{$t('admin:users.authProvider')}}
                 v-list-item-subtitle {{ user.providerKey }}
               //- v-list-item-action
               //-   v-img(src='https://static.requarks.io/logo/wikijs.svg', alt='', contain, max-height='32', position='center right')
@@ -110,7 +124,7 @@
                 v-list-item-avatar(size='32')
                   v-icon mdi-textbox-password
                 v-list-item-content
-                  v-list-item-title Password
+                  v-list-item-title {{$t('admin:users.password')}}
                   v-list-item-subtitle &bull;&bull;&bull;&bull;&bull;&bull;&bull;&bull;
                 v-list-item-action
                   v-menu(
@@ -124,12 +138,12 @@
                         template(v-slot:activator='{ on: tooltip }')
                           v-btn(icon, color='grey', x-small, v-on='{ ...menu, ...tooltip }', @click='focusField(`iptNewPassword`)')
                             v-icon mdi-cached
-                        span Change Password
+                        span {{$t('admin:users.changePassword')}}
                     v-card
                       v-text-field(
                         ref='iptNewPassword'
                         v-model='newPassword'
-                        label='New Password'
+                        :label='$t(`admin:users.newPassword`)'
                         solo
                         hide-details
                         append-icon='mdi-check'
@@ -149,26 +163,26 @@
                 v-list-item-avatar(size='32')
                   v-icon mdi-two-factor-authentication
                 v-list-item-content
-                  v-list-item-title Two Factor Authentication (2FA)
+                  v-list-item-title {{$t('admin:users.tfa')}}
                   v-list-item-subtitle.red--text Inactive
                 v-list-item-action
                   v-tooltip(top)
                     template(v-slot:activator='{ on }')
                       v-btn(icon, color='grey', x-small, v-on='on', disabled)
                         v-icon mdi-power
-                    span Toggle 2FA
-              template(v-if='user.providerId')
-                v-divider
-                v-list-item
-                  v-list-item-avatar(size='32')
-                    v-icon mdi-account
-                  v-list-item-content
-                    v-list-item-title Provider Id
-                    v-list-item-subtitle {{ user.providerId }}
+                    span {{$t('admin:users.toggle2FA')}}
+            template(v-if='user.providerId')
+              v-divider
+              v-list-item
+                v-list-item-avatar(size='32')
+                  v-icon mdi-music-accidental-sharp
+                v-list-item-content
+                  v-list-item-title {{$t('admin:users.authProviderId')}}
+                  v-list-item-subtitle {{ user.providerId }}
         v-card.mt-3.animated.fadeInUp.wait-p4s
           v-toolbar(color='primary', dense, dark, flat)
             v-icon.mr-2 mdi-account-group
-            span User Groups
+            span {{$t('admin:users.groups')}}
           v-list(dense)
             template(v-for='(group, idx) in user.groups')
               v-list-item(:key='`group-` + group.id')
@@ -181,14 +195,14 @@
                     v-icon mdi-close
               v-divider(v-if='idx < user.groups.length - 1')
           v-alert.mx-3(v-if='user.groups.length < 1', outlined, color='grey darken-1', icon='mdi-alert')
-            .caption This user is not assigned to any group yet. You must assign at least 1 group to a user.
+            .caption {{$t('admin:users.noGroupAssigned')}}
           v-card-chin(v-if='!user.isSystem')
             v-spacer
             v-select(
               ref='iptAssignGroup'
               :items='groups'
               v-model='newGroup'
-              label='Select Group...'
+              :label='$t(`admin:users.selectGroup`)'
               item-value='id'
               item-text='name'
               item-disabled='isSystem'
@@ -201,18 +215,18 @@
             )
             v-btn.ml-2.px-4(depressed, color='primary', height='48', @click='assignGroup', :disabled='newGroup === 0')
               v-icon(left) mdi-clipboard-account-outline
-              span Assign
+              span {{$t('admin:users.groupAssign')}}
       v-flex(xs6)
         v-card.animated.fadeInUp.wait-p2s
           v-toolbar(color='primary', dense, dark, flat)
             v-icon.mr-2 mdi-account-badge-outline
-            span Extended Metadata
+            span {{$t('admin:users.extendedMetadata')}}
           v-list.py-0(two-line, dense)
             v-list-item
               v-list-item-avatar(size='32')
                 v-icon mdi-map-marker
               v-list-item-content
-                v-list-item-title Location
+                v-list-item-title {{$t('admin:users.location')}}
                 v-list-item-subtitle {{ user.location }}
               v-list-item-action
                 v-menu(
@@ -228,7 +242,7 @@
                     v-text-field(
                       ref='iptLocation'
                       v-model='user.location'
-                      label='Location'
+                      :label='$t(`admin:users.location`)'
                       solo
                       hide-details
                       append-icon='mdi-check'
@@ -241,7 +255,7 @@
               v-list-item-avatar(size='32')
                 v-icon mdi-account-badge-horizontal-outline
               v-list-item-content
-                v-list-item-title Job Title
+                v-list-item-title {{$t('admin:users.jobTitle')}}
                 v-list-item-subtitle {{ user.jobTitle }}
               v-list-item-action
                 v-menu(
@@ -257,7 +271,7 @@
                     v-text-field(
                       ref='iptJobTitle'
                       v-model='user.jobTitle'
-                      label='Job Title'
+                      :label='$t(`admin:users.jobTitle`)'
                       solo
                       hide-details
                       append-icon='mdi-check'
@@ -270,7 +284,7 @@
               v-list-item-avatar(size='32')
                 v-icon mdi-map-clock-outline
               v-list-item-content
-                v-list-item-title Timezone
+                v-list-item-title {{$t('admin:users.timezone')}}
                 v-list-item-subtitle {{ user.timezone }}
               v-list-item-action
                 v-menu(
@@ -287,7 +301,7 @@
                       ref='iptTimezone'
                       :items='timezones'
                       v-model='user.timezone'
-                      label='Timezone'
+                      :label='$t(`admin:users.timezone`)'
                       solo
                       dense
                       hide-details
@@ -308,11 +322,16 @@
 import _ from 'lodash'
 import { get } from 'vuex-pathify'
 
+import { StatusIndicator } from 'vue-status-indicator'
+
 import userQuery from 'gql/admin/users/users-query-single.gql'
 import groupsQuery from 'gql/admin/users/users-query-groups.gql'
 import updateUserMutation from 'gql/admin/users/users-mutation-update.gql'
 
 export default {
+  components: {
+    StatusIndicator
+  },
   data() {
     return {
       deleteUserDialog: false,
@@ -334,7 +353,9 @@ export default {
         location: '',
         jobTitle: '',
         timezone: '',
-        groups: []
+        groups: [],
+        isActive: false,
+        isVerified: false
       },
       timezones: [
         { text: '(GMT-11:00) Niue', value: 'Pacific/Niue' },
@@ -613,7 +634,7 @@ export default {
       if (_.get(resp, 'data.users.update.responseResult.succeeded', false)) {
         this.$store.commit('showNotification', {
           style: 'success',
-          message: 'User updated successfully.',
+          message: this.$t('admin:users.userUpdateSuccess'),
           icon: 'check'
         })
         this.$router.push('/users')
@@ -636,7 +657,7 @@ export default {
     assignGroup() {
       if (_.some(this.user.groups, ['id', this.newGroup])) {
         this.$store.commit('showNotification', {
-          message: 'User is already assigned to this group!',
+          message: this.$t('admin:users.userAlreadyAssignedToGroup'),
           style: 'error',
           icon: 'alert'
         })

+ 1 - 1
client/components/common/page-delete.vue

@@ -4,7 +4,7 @@
       .dialog-header.is-short.is-red
         v-icon.mr-2(color='white') mdi-file-document-box-remove-outline
         span {{$t('common:page.delete')}}
-      v-card-text
+      v-card-text.pt-5
         i18next.body-1(path='common:page.deleteTitle', tag='div')
           span.red--text.text--darken-2(place='title') {{pageTitle}}
         .caption {{$t('common:page.deleteSubtitle')}}

+ 3 - 3
client/components/common/user-search.vue

@@ -3,7 +3,7 @@
     v-model='dialogOpen'
     max-width='650'
     )
-    v-card.wiki-form
+    v-card
       .dialog-header
         span {{$t('common:user.search')}}
         v-spacer
@@ -14,7 +14,7 @@
           :width='2'
           v-show='searchLoading'
           )
-      v-card-text
+      v-card-text.pt-5
         v-text-field(
           outlined
           :label='$t(`common:user.searchPlaceholder`)'
@@ -56,7 +56,7 @@ import searchUsersQuery from 'gql/common/common-users-query-search.gql'
 export default {
   filters: {
     initials(val) {
-      return val.split(' ').map(v => v.substring(0, 1)).join()
+      return val.split(' ').map(v => v.substring(0, 1)).join('')
     }
   },
   props: {

+ 126 - 46
client/components/login.vue

@@ -11,17 +11,18 @@
             offset-xl4, xl4
             )
             transition(name='fadeUp')
-              v-card.elevation-5.md2(v-show='isShown')
+              v-card.elevation-5(v-show='isShown')
                 v-toolbar(color='primary', flat, dense, dark)
                   v-spacer
                   .subheading(v-if='screen === "tfa"') {{ $t('auth:tfa.subtitle') }}
+                  .subheading(v-if='screen === "changePwd"') {{ $t('auth:changePwd.subtitle') }}
                   .subheading(v-else-if='selectedStrategy.key !== "local"') {{ $t('auth:loginUsingStrategy', { strategy: selectedStrategy.title, interpolation: { escapeValue: false } }) }}
                   .subheading(v-else) {{ $t('auth:loginRequired') }}
                   v-spacer
                 v-card-text.text-center
                   h1.display-1.primary--text.py-2 {{ siteTitle }}
                   template(v-if='screen === "login"')
-                    v-text-field.md2.mt-3(
+                    v-text-field.mt-3(
                       solo
                       flat
                       prepend-icon='mdi-clipboard-account'
@@ -31,7 +32,7 @@
                       v-model='username'
                       :placeholder='$t("auth:fields.emailUser")'
                       )
-                    v-text-field.md2.mt-2(
+                    v-text-field.mt-2(
                       solo
                       flat
                       prepend-icon='mdi-textbox-password'
@@ -47,7 +48,7 @@
                     )
                   template(v-else-if='screen === "tfa"')
                     .body-2 Enter the security code generated from your trusted device:
-                    v-text-field.md2.centered.mt-2(
+                    v-text-field.centered.mt-2(
                       solo
                       flat
                       background-color='grey lighten-4'
@@ -57,12 +58,34 @@
                       :placeholder='$t("auth:tfa.placeholder")'
                       @keyup.enter='verifySecurityCode'
                     )
+                  template(v-else-if='screen === "changePwd"')
+                    .body-2 {{$t('auth:changePwd.instructions')}}
+                    v-text-field.mt-2(
+                      type='password'
+                      solo
+                      flat
+                      background-color='grey lighten-4'
+                      hide-details
+                      ref='iptNewPassword'
+                      v-model='newPassword'
+                      :placeholder='$t(`auth:changePwd.newPasswordPlaceholder`)'
+                    )
+                    v-text-field.mt-2(
+                      type='password'
+                      solo
+                      flat
+                      background-color='grey lighten-4'
+                      hide-details
+                      v-model='newPasswordVerify'
+                      :placeholder='$t(`auth:changePwd.newPasswordVerifyPlaceholder`)'
+                      @keyup.enter='changePassword'
+                    )
                   template(v-else-if='screen === "forgot"')
                     .body-2 {{ $t('auth:forgotPasswordSubtitle') }}
-                    v-text-field.md2.mt-3(
+                    v-text-field.mt-3(
                       solo
                       flat
-                      prepend-icon='email'
+                      prepend-icon='mdi-email'
                       background-color='grey lighten-4'
                       hide-details
                       ref='iptEmailForgot'
@@ -71,31 +94,48 @@
                       )
                 v-card-actions.pb-4
                   v-spacer
-                  v-btn.md2(
+                  v-btn(
+                    width='100%'
+                    max-width='250px'
                     v-if='screen === "login"'
-                    block
                     large
-                    color='primary'
+                    color='teal'
+                    dark
                     @click='login'
-                    round
+                    rounded
                     :loading='isLoading'
                     ) {{ $t('auth:actions.login') }}
-                  v-btn.md2(
+                  v-btn(
+                    width='100%'
+                    max-width='250px'
                     v-else-if='screen === "tfa"'
-                    block
                     large
-                    color='primary'
+                    color='teal'
+                    dark
                     @click='verifySecurityCode'
-                    round
+                    rounded
                     :loading='isLoading'
                     ) {{ $t('auth:tfa.verifyToken') }}
-                  v-btn.md2(
+                  v-btn(
+                    width='100%'
+                    max-width='250px'
+                    v-else-if='screen === "changePwd"'
+                    large
+                    color='teal'
+                    dark
+                    @click='changePassword'
+                    rounded
+                    :loading='isLoading'
+                    ) {{ $t('auth:changePwd.proceed') }}
+                  v-btn(
+                    width='100%'
+                    max-width='250px'
                     v-else-if='screen === "forgot"'
-                    block
                     large
-                    color='primary'
+                    color='teal'
+                    dark
                     @click='forgotPasswordSubmit'
-                    round
+                    rounded
                     :loading='isLoading'
                     ) {{ $t('auth:sendResetPassword') }}
                   v-spacer
@@ -111,15 +151,16 @@
                   v-divider
                   v-card-text.grey.lighten-4.text-center
                     .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'
-                        v-ripple
-                        v-html='strategy.icon'
-                        :class='strategy.color + " elevation-" + (strategy.key === selectedStrategy.key ? "0" : "4")'
-                        @click='selectStrategy(strategy)'
-                        )
-                      span {{ strategy.title }}
+                    v-btn.mx-1.social-login-btn(
+                      v-for='strategy in strategies', :key='strategy.key'
+                      large
+                      @click='selectStrategy(strategy)'
+                      dark
+                      :color='strategy.color'
+                      :depressed='strategy.key === selectedStrategy.key'
+                      )
+                      v-avatar.mr-3(tile, :class='strategy.color', size='24', v-html='strategy.icon')
+                      span(style='text-transform: none;') {{ strategy.title }}
                 template(v-if='screen === "login" && selectedStrategy.selfRegistration')
                   v-divider
                   v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
@@ -142,6 +183,7 @@ import Cookies from 'js-cookie'
 import strategiesQuery from 'gql/login/login-query-strategies.gql'
 import loginMutation from 'gql/login/login-mutation-login.gql'
 import tfaMutation from 'gql/login/login-mutation-tfa.gql'
+import changePasswordMutation from 'gql/login/login-mutation-changepassword.gql'
 
 export default {
   i18nOptions: { namespaces: 'auth' },
@@ -155,11 +197,13 @@ export default {
       password: '',
       hidePassword: true,
       securityCode: '',
-      loginToken: '',
+      continuationToken: '',
       isLoading: false,
       loaderColor: 'grey darken-4',
       loaderTitle: 'Working...',
-      isShown: false
+      isShown: false,
+      newPassword: '',
+      newPasswordVerify: ''
     }
   },
   computed: {
@@ -205,14 +249,14 @@ export default {
         this.$store.commit('showNotification', {
           style: 'red',
           message: this.$t('auth:invalidEmailUsername'),
-          icon: 'warning'
+          icon: 'alert'
         })
         this.$refs.iptEmail.focus()
       } else if (this.password.length < 2) {
         this.$store.commit('showNotification', {
           style: 'red',
           message: this.$t('auth:invalidPassword'),
-          icon: 'warning'
+          icon: 'alert'
         })
         this.$refs.iptPassword.focus()
       } else {
@@ -231,10 +275,16 @@ export default {
           if (_.has(resp, 'data.authentication.login')) {
             let respObj = _.get(resp, 'data.authentication.login', {})
             if (respObj.responseResult.succeeded === true) {
-              if (respObj.tfaRequired === true) {
+              this.continuationToken = respObj.continuationToken
+              if (respObj.mustChangePwd === true) {
+                this.screen = 'changePwd'
+                this.$nextTick(() => {
+                  this.$refs.iptNewPassword.focus()
+                })
+                this.isLoading = false
+              } else if (respObj.mustProvideTFA === true) {
                 this.screen = 'tfa'
                 this.securityCode = ''
-                this.loginToken = respObj.tfaLoginToken
                 this.$nextTick(() => {
                   this.$refs.iptTFA.focus()
                 })
@@ -258,7 +308,7 @@ export default {
           this.$store.commit('showNotification', {
             style: 'red',
             message: err.message,
-            icon: 'warning'
+            icon: 'alert'
           })
           this.isLoading = false
         }
@@ -280,7 +330,7 @@ export default {
         this.$apollo.mutate({
           mutation: tfaMutation,
           variables: {
-            loginToken: this.loginToken,
+            continuationToken: this.continuationToken,
             securityCode: this.securityCode
           }
         }).then(resp => {
@@ -307,23 +357,59 @@ export default {
           this.$store.commit('showNotification', {
             style: 'red',
             message: err.message,
-            icon: 'warning'
+            icon: 'alert'
           })
           this.isLoading = false
         })
       }
     },
-    forgotPassword() {
+    /**
+     * CHANGE PASSWORD
+     */
+    async changePassword () {
+      this.loaderColor = 'grey darken-4'
+      this.loaderTitle = this.$t('auth:changePwd.loading')
+      this.isLoading = true
+      const resp = await this.$apollo.mutate({
+        mutation: changePasswordMutation,
+        variables: {
+          continuationToken: this.continuationToken,
+          newPassword: this.newPassword
+        }
+      })
+      if (_.get(resp, 'data.authentication.loginChangePassword.responseResult.succeeded', false) === true) {
+        this.loaderColor = 'green darken-1'
+        this.loaderTitle = this.$t('auth:loginSuccess')
+        Cookies.set('jwt', _.get(resp, 'data.authentication.loginChangePassword.jwt', ''), { expires: 365 })
+        _.delay(() => {
+          window.location.replace('/') // TEMPORARY - USE RETURNURL
+        }, 1000)
+      } else {
+        this.$store.commit('showNotification', {
+          style: 'red',
+          message: _.get(resp, 'data.authentication.loginChangePassword.responseResult.message', false),
+          icon: 'alert'
+        })
+        this.isLoading = false
+      }
+    },
+    /**
+     * SWITCH TO FORGOT PASSWORD SCREEN
+     */
+    forgotPassword () {
       this.screen = 'forgot'
       this.$nextTick(() => {
         this.$refs.iptEmailForgot.focus()
       })
     },
-    async forgotPasswordSubmit() {
+    /**
+     * FORGOT PASSWORD SUBMIT
+     */
+    async forgotPasswordSubmit () {
       this.$store.commit('showNotification', {
         style: 'pink',
         message: 'Coming soon!',
-        icon: 'free_breakfast'
+        icon: 'ferry'
       })
     }
   },
@@ -378,18 +464,12 @@ export default {
     }
 
     .social-login-btn {
-      display: inline-flex;
-      justify-content: center;
-      align-items: center;
-      border-radius: 50%;
-      width: 54px;
-      height: 54px;
       cursor: pointer;
       transition: opacity .2s ease;
       &:hover {
         opacity: .8;
       }
-      margin: .5rem 0;
+      margin: .25rem 0;
       svg {
         width: 24px;
         height: 24px;

+ 13 - 1
client/graph/admin/site/site-mutation-save-config.gql

@@ -11,6 +11,12 @@ mutation (
   $featurePageRatings: Boolean!
   $featurePageComments: Boolean!
   $featurePersonalWikis: Boolean!
+  $securityIframe: Boolean!
+  $securityReferrerPolicy: Boolean!
+  $securityHSTS: Boolean!
+  $securityHSTSDuration: Int!
+  $securityCSP: Boolean!
+  $securityCSPDirectives: String!
 ) {
   site {
     updateConfig(
@@ -25,7 +31,13 @@ mutation (
       logoIsSquare: $logoIsSquare,
       featurePageRatings: $featurePageRatings,
       featurePageComments: $featurePageComments,
-      featurePersonalWikis: $featurePersonalWikis
+      featurePersonalWikis: $featurePersonalWikis,
+      securityIframe: $securityIframe,
+      securityReferrerPolicy: $securityReferrerPolicy,
+      securityHSTS: $securityHSTS,
+      securityHSTSDuration: $securityHSTSDuration,
+      securityCSP: $securityCSP,
+      securityCSPDirectives: $securityCSPDirectives
     ) {
       responseResult {
         succeeded

+ 6 - 0
client/graph/admin/site/site-query-config.gql

@@ -13,6 +13,12 @@
       featurePageRatings
       featurePageComments
       featurePersonalWikis
+      securityIframe
+      securityReferrerPolicy
+      securityHSTS
+      securityHSTSDuration
+      securityCSP
+      securityCSPDirectives
     }
   }
 }

+ 2 - 0
client/graph/admin/users/users-query-single.gql

@@ -10,6 +10,8 @@ query ($id: Int!) {
       jobTitle
       timezone
       isSystem
+      isActive
+      isVerified
       createdAt
       updatedAt
       groups {

+ 13 - 0
client/graph/login/login-mutation-changepassword.gql

@@ -0,0 +1,13 @@
+mutation($continuationToken: String!, $newPassword: String!) {
+  authentication {
+    loginChangePassword(continuationToken: $continuationToken, newPassword: $newPassword) {
+      responseResult {
+        succeeded
+        errorCode
+        slug
+        message
+      }
+      jwt
+    }
+  }
+}

+ 3 - 2
client/graph/login/login-mutation-login.gql

@@ -8,8 +8,9 @@ mutation($username: String!, $password: String!, $strategy: String!) {
         message
       }
       jwt
-      tfaRequired
-      tfaLoginToken
+      mustChangePwd
+      mustProvideTFA
+      continuationToken
     }
   }
 }

+ 3 - 2
client/graph/login/login-mutation-tfa.gql

@@ -1,12 +1,13 @@
-mutation($loginToken: String!, $securityCode: String!) {
+mutation($continuationToken: String!, $securityCode: String!) {
   authentication {
-    loginTFA(loginToken: $loginToken, securityCode: $securityCode) {
+    loginTFA(continuationToken: $continuationToken, securityCode: $securityCode) {
       responseResult {
         succeeded
         errorCode
         slug
         message
       }
+      jwt
     }
   }
 }

+ 16 - 3
client/scss/pages/_error.scss

@@ -6,6 +6,7 @@
   justify-content: center;
   align-items: center;
   color: mc('grey', '50');
+  font-family: Roboto, Arial, sans-serif;
 
   img {
     width: 250px;
@@ -57,8 +58,20 @@
     }
   }
 
-  code {
-    color: mc('grey', '500');
-    font-size: .8rem;
+  > strong {
+    font-size: 1.5rem;
+  }
+
+  > span {
+    margin-top: 1rem;
+  }
+
+  > pre {
+    margin-top: 2rem;
+
+    code {
+      color: mc('grey', '500');
+      font-size: .8rem;
+    }
   }
 }

+ 20 - 19
client/themes/default/components/page.vue

@@ -70,20 +70,7 @@
                       v-list-item-title.px-3.caption.grey--text(:class='darkMode ? `text--lighten-1` : `text--darken-1`') {{tocSubItem.title}}
                     //- v-divider(inset, v-if='tocIdx < toc.length - 1')
 
-            v-card.mt-5
-              .pa-5.pt-3
-                .overline.indigo--text.d-flex.align-center(:class='$vuetify.theme.dark ? `text--lighten-3` : ``')
-                  span {{$t('common:page.lastEditedBy')}}
-                  v-spacer
-                  v-tooltip(top, v-if='isAuthenticated')
-                    template(v-slot:activator='{ on }')
-                      v-btn.btn-animate-edit(icon, :href='"/h/" + locale + "/" + path', v-on='on', x-small)
-                        v-icon(color='grey', dense) mdi-history
-                    span History
-                .body-2.grey--text(:class='darkMode ? `` : `text--darken-3`') {{ authorName }}
-                .caption.grey--text.text--darken-1 {{ updatedAt | moment('calendar') }}
-
-            v-card.mt-5(v-if='tags.length > 0 || true')
+            v-card.mt-5(v-if='tags.length > 0')
               .pa-5
                 .overline.teal--text.pb-2(:class='$vuetify.theme.dark ? `text--lighten-3` : ``') Tags
                 v-chip.mr-1(
@@ -96,6 +83,19 @@
                   v-icon(color='teal', left, small) mdi-label
                   span.teal--text.text--darken-2 {{tag.text}}
 
+            v-card.mt-5
+              .pa-5
+                .overline.indigo--text.d-flex.align-center(:class='$vuetify.theme.dark ? `text--lighten-3` : ``')
+                  span {{$t('common:page.lastEditedBy')}}
+                  v-spacer
+                  v-tooltip(top, v-if='isAuthenticated')
+                    template(v-slot:activator='{ on }')
+                      v-btn.btn-animate-edit(icon, :href='"/h/" + locale + "/" + path', v-on='on', x-small)
+                        v-icon(color='grey', dense) mdi-history
+                    span History
+                .body-2.grey--text(:class='darkMode ? `` : `text--darken-3`') {{ authorName }}
+                .caption.grey--text.text--darken-1 {{ updatedAt | moment('calendar') }}
+
             v-card.mt-5
               .pa-5
                 .overline.pb-2.yellow--text(:class='$vuetify.theme.dark ? `text--darken-3` : `text--darken-4`') Rating
@@ -108,20 +108,21 @@
                     hover
                   )
                   .caption.grey--text 5 votes
-              v-divider
-              v-toolbar(:color='darkMode ? `grey darken-3` : `grey lighten-4`', flat, dense)
+
+            v-card.mt-5(flat)
+              v-toolbar(:color='darkMode ? `grey darken-3` : `grey lighten-3`', flat, dense)
                 v-spacer
                 v-tooltip(bottom)
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-bookmark
+                    v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-bookmark
                   span {{$t('common:page.bookmark')}}
                 v-tooltip(bottom)
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-share-variant
+                    v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-share-variant
                   span {{$t('common:page.share')}}
                 v-tooltip(bottom)
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, tile, small, v-on='on'): v-icon(color='grey') mdi-printer
+                    v-btn(icon, tile, v-on='on'): v-icon(color='grey') mdi-printer
                   span {{$t('common:page.printFormat')}}
                 v-spacer
 

+ 1 - 1
dev/templates/legacy.pug

@@ -32,7 +32,7 @@ html
       link(
         type='text/css'
         rel='stylesheet'
-        href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
+        href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
         )
     else if config.theming.iconset === 'fa4'
       link(

+ 1 - 1
dev/templates/master.pug

@@ -36,7 +36,7 @@ html(lang=siteConfig.lang)
       link(
         type='text/css'
         rel='stylesheet'
-        href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
+        href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
         )
     else if config.theming.iconset === 'fa4'
       link(

+ 56 - 53
package.json

@@ -35,13 +35,13 @@
   },
   "dependencies": {
     "@aoberoi/passport-slack": "1.0.5",
-    "@bugsnag/js": "6.3.2",
+    "@bugsnag/js": "6.4.0",
     "algoliasearch": "3.33.0",
     "apollo-fetch": "0.7.0",
-    "apollo-server": "2.8.1",
-    "apollo-server-express": "2.8.1",
+    "apollo-server": "2.9.0",
+    "apollo-server-express": "2.9.0",
     "auto-load": "3.0.4",
-    "aws-sdk": "2.503.0",
+    "aws-sdk": "2.517.0",
     "axios": "0.19.0",
     "azure-search-client": "3.1.5",
     "bcryptjs-then": "1.0.1",
@@ -67,18 +67,18 @@
     "express": "4.17.1",
     "express-brute": "1.0.1",
     "express-session": "1.16.2",
-    "file-type": "12.1.0",
+    "file-type": "12.2.0",
     "filesize": "4.1.2",
     "fs-extra": "8.1.0",
     "getos": "3.1.1",
-    "graphql": "14.4.2",
+    "graphql": "14.5.3",
     "graphql-list-fields": "2.0.2",
     "graphql-rate-limit-directive": "1.1.0",
     "graphql-subscriptions": "1.1.0",
     "graphql-tools": "4.0.5",
-    "highlight.js": "9.15.9",
-    "i18next": "17.0.8",
-    "i18next-express-middleware": "1.8.0",
+    "highlight.js": "9.15.10",
+    "i18next": "17.0.12",
+    "i18next-express-middleware": "1.8.1",
     "i18next-node-fs-backend": "2.1.3",
     "image-size": "0.7.4",
     "js-base64": "2.5.1",
@@ -86,12 +86,12 @@
     "js-yaml": "3.13.1",
     "jsonwebtoken": "8.5.1",
     "klaw": "3.0.0",
-    "knex": "0.19.1",
+    "knex": "0.19.2",
     "lodash": "4.17.15",
-    "markdown-it": "9.0.1",
+    "markdown-it": "9.1.0",
     "markdown-it-abbr": "1.0.4",
     "markdown-it-anchor": "5.2.4",
-    "markdown-it-attrs": "3.0.0",
+    "markdown-it-attrs": "3.0.1",
     "markdown-it-emoji": "1.4.0",
     "markdown-it-expand-tabs": "1.0.13",
     "markdown-it-external-links": "0.0.6",
@@ -106,7 +106,7 @@
     "mime-types": "2.1.24",
     "moment": "2.24.0",
     "moment-timezone": "0.5.26",
-    "mongodb": "3.2.7",
+    "mongodb": "3.3.1",
     "mssql": "5.1.0",
     "multer": "1.4.2",
     "mysql2": "1.6.5",
@@ -116,7 +116,7 @@
     "nodemailer": "6.3.0",
     "objection": "1.6.9",
     "passport": "0.4.0",
-    "passport-auth0": "1.2.0",
+    "passport-auth0": "1.2.1",
     "passport-azure-ad": "4.1.0",
     "passport-cas": "0.1.1",
     "passport-discord": "0.1.3",
@@ -135,7 +135,7 @@
     "passport-saml": "1.1.0",
     "passport-twitch": "1.0.3",
     "pem-jwk": "2.0.0",
-    "pg": "7.12.0",
+    "pg": "7.12.1",
     "pg-hstore": "2.3.3",
     "pg-query-stream": "2.0.0",
     "pg-tsquery": "8.0.5",
@@ -152,18 +152,18 @@
     "serve-favicon": "2.5.0",
     "simple-git": "1.124.0",
     "solr-node": "1.2.1",
-    "sqlite3": "4.0.9",
+    "sqlite3": "4.1.0",
     "striptags": "3.1.1",
     "subscriptions-transport-ws": "0.9.16",
     "tar-fs": "2.0.0",
     "twemoji": "12.1.2",
     "uslug": "1.0.4",
-    "uuid": "3.3.2",
+    "uuid": "3.3.3",
     "validate.js": "0.13.1",
     "validator": "11.1.0",
     "validator-as-promised": "1.0.2",
     "winston": "3.2.1",
-    "yargs": "13.3.0"
+    "yargs": "14.0.0"
   },
   "devDependencies": {
     "@babel/cli": "^7.5.0",
@@ -179,13 +179,13 @@
     "@babel/plugin-syntax-import-meta": "^7.2.0",
     "@babel/polyfill": "^7.4.4",
     "@babel/preset-env": "^7.5.4",
-    "@mdi/font": "3.8.95",
+    "@mdi/font": "4.1.95",
     "@panter/vue-i18next": "0.15.1",
-    "@vue/babel-preset-app": "3.10.0",
+    "@vue/babel-preset-app": "3.11.0",
     "animate-sass": "0.8.2",
     "animated-number-vue": "1.0.0",
-    "apollo-cache-inmemory": "1.6.2",
-    "apollo-client": "2.6.3",
+    "apollo-cache-inmemory": "1.6.3",
+    "apollo-client": "2.6.4",
     "apollo-link": "1.2.12",
     "apollo-link-batch-http": "1.2.12",
     "apollo-link-error": "1.1.11",
@@ -195,9 +195,9 @@
     "apollo-utilities": "1.3.2",
     "autoprefixer": "9.6.1",
     "babel-eslint": "10.0.2",
-    "babel-jest": "24.8.0",
+    "babel-jest": "24.9.0",
     "babel-loader": "^8.0.6",
-    "babel-plugin-graphql-tag": "2.4.0",
+    "babel-plugin-graphql-tag": "2.5.0",
     "babel-plugin-lodash": "3.3.4",
     "babel-plugin-prismjs": "1.1.1",
     "babel-plugin-transform-imports": "2.0.0",
@@ -206,26 +206,26 @@
     "chart.js": "2.8.0",
     "clean-webpack-plugin": "3.0.0",
     "copy-webpack-plugin": "5.0.4",
-    "core-js": "3.1.4",
-    "css-loader": "3.1.0",
+    "core-js": "3.2.1",
+    "css-loader": "3.2.0",
     "cssnano": "4.1.10",
     "duplicate-package-checker-webpack-plugin": "3.0.0",
     "epic-spinners": "1.1.0",
-    "eslint": "6.1.0",
+    "eslint": "6.2.2",
     "eslint-config-requarks": "1.0.7",
-    "eslint-config-standard": "13.0.1",
+    "eslint-config-standard": "14.0.1",
     "eslint-plugin-import": "2.18.2",
     "eslint-plugin-node": "9.1.0",
     "eslint-plugin-promise": "4.2.1",
-    "eslint-plugin-standard": "4.0.0",
+    "eslint-plugin-standard": "4.0.1",
     "eslint-plugin-vue": "5.2.3",
     "fibers": "4.0.1",
-    "file-loader": "4.1.0",
-    "filepond": "4.4.12",
+    "file-loader": "4.2.0",
+    "filepond": "4.5.0",
     "filepond-plugin-file-validate-type": "1.2.4",
     "filesize.js": "1.0.2",
-    "grapesjs": "0.14.62",
-    "graphiql": "0.13.2",
+    "grapesjs": "0.15.3",
+    "graphiql": "0.14.2",
     "graphql-persisted-document-loader": "1.0.1",
     "graphql-tag": "^2.10.1",
     "graphql-voyager": "1.0.0-rc.27",
@@ -234,10 +234,10 @@
     "html-webpack-pug-plugin": "2.0.0",
     "i18next-chained-backend": "2.0.0",
     "i18next-localstorage-backend": "3.0.0",
-    "i18next-xhr-backend": "3.0.1",
+    "i18next-xhr-backend": "3.1.2",
     "ignore-loader": "0.1.2",
-    "jest": "24.8.0",
-    "js-cookie": "2.2.0",
+    "jest": "24.9.0",
+    "js-cookie": "2.2.1",
     "mini-css-extract-plugin": "0.8.0",
     "moment-duration-format": "2.3.2",
     "offline-plugin": "5.0.7",
@@ -254,19 +254,19 @@
     "pug-loader": "2.4.0",
     "pug-plain-loader": "1.0.0",
     "raw-loader": "3.1.0",
-    "react": "16.8.6",
-    "react-dom": "16.8.6",
+    "react": "16.9.0",
+    "react-dom": "16.9.0",
     "resolve-url-loader": "3.1.0",
-    "sass": "1.22.9",
-    "sass-loader": "7.1.0",
+    "sass": "1.22.10",
+    "sass-loader": "7.3.1",
     "sass-resources-loader": "2.0.1",
     "script-ext-html-webpack-plugin": "2.1.4",
     "simple-progress-webpack-plugin": "1.1.2",
-    "style-loader": "0.23.1",
-    "terser": "4.1.3",
+    "style-loader": "1.0.0",
+    "terser": "4.2.1",
     "twemoji-awesome": "1.0.6",
     "url-loader": "2.1.0",
-    "vee-validate": "2.2.13",
+    "vee-validate": "2.2.15",
     "velocity-animate": "1.5.2",
     "viz.js": "2.1.2",
     "vue": "2.6.10",
@@ -274,32 +274,32 @@
     "vue-chartjs": "3.4.2",
     "vue-clipboards": "1.3.0",
     "vue-codemirror": "4.0.6",
-    "vue-filepond": "5.1.2",
+    "vue-filepond": "5.1.3",
     "vue-hot-reload-api": "2.3.3",
     "vue-loader": "15.7.1",
-    "vue-material-design-icons": "3.3.1",
+    "vue-material-design-icons": "3.4.0",
     "vue-moment": "4.0.0",
-    "vue-router": "3.0.7",
+    "vue-router": "3.1.2",
     "vue-simple-breakpoints": "1.0.3",
     "vue-status-indicator": "1.2.1",
     "vue-template-compiler": "2.6.10",
     "vue-tour": "1.1.0",
-    "vue2-animate": "2.1.0",
+    "vue2-animate": "2.1.2",
     "vuedraggable": "2.23.0",
-    "vuescroll": "4.13.1",
-    "vuetify": "2.0.4",
+    "vuescroll": "4.14.0",
+    "vuetify": "2.0.10",
     "vuetify-loader": "1.3.0",
     "vuex": "3.1.1",
     "vuex-pathify": "1.2.4",
     "vuex-persistedstate": "2.5.4",
-    "webpack": "4.39.1",
+    "webpack": "4.39.2",
     "webpack-bundle-analyzer": "3.4.1",
-    "webpack-cli": "3.3.6",
+    "webpack-cli": "3.3.7",
     "webpack-dev-middleware": "3.7.0",
     "webpack-hot-middleware": "2.25.0",
     "webpack-merge": "4.2.1",
     "webpack-subresource-integrity": "1.3.2",
-    "webpackbar": "3.2.0",
+    "webpackbar": "4.0.0",
     "whatwg-fetch": "3.0.0",
     "write-file-webpack-plugin": "4.5.1",
     "xterm": "3.14.5",
@@ -344,7 +344,10 @@
     "requireSpaceAfterCodeOperator": true,
     "requireStrictEqualityOperators": true,
     "validateAttributeQuoteMarks": "'",
-    "validateAttributeSeparator": { "separator": ", ", "multiLineSeparator": "\n  " },
+    "validateAttributeSeparator": {
+      "separator": ", ",
+      "multiLineSeparator": "\n  "
+    },
     "validateDivTags": true,
     "validateIndentation": 2,
     "excludeFiles": [

+ 7 - 0
server/app/data.yml

@@ -42,6 +42,13 @@ defaults:
       theme: 'default'
       iconset: 'md'
       darkMode: false
+    security:
+      securityIframe: true
+      securityReferrerPolicy: true
+      securityHSTS: false
+      securityHSTSDuration: 300
+      securityCSP: false
+      securityCSPDirectives: ''
     flags:
       ldapdebug: false
       sqllog: false

+ 23 - 9
server/graph/resolvers/authentication.js

@@ -7,16 +7,16 @@ const graphHelper = require('../../helpers/graph')
 
 module.exports = {
   Query: {
-    async authentication() { return {} }
+    async authentication () { return {} }
   },
   Mutation: {
-    async authentication() { return {} }
+    async authentication () { return {} }
   },
   AuthenticationQuery: {
     /**
      * Fetch active authentication strategies
      */
-    async strategies(obj, args, context, info) {
+    async strategies (obj, args, context, info) {
       let strategies = await WIKI.models.authentication.getStrategies(args.isEnabled)
       strategies = strategies.map(stg => {
         const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
@@ -44,7 +44,7 @@ module.exports = {
     /**
      * Perform Login
      */
-    async login(obj, args, context) {
+    async login (obj, args, context) {
       try {
         const authResult = await WIKI.models.users.login(args, context)
         return {
@@ -63,7 +63,7 @@ module.exports = {
     /**
      * Perform 2FA Login
      */
-    async loginTFA(obj, args, context) {
+    async loginTFA (obj, args, context) {
       try {
         const authResult = await WIKI.models.users.loginTFA(args, context)
         return {
@@ -74,10 +74,24 @@ module.exports = {
         return graphHelper.generateError(err)
       }
     },
+    /**
+     * Perform Mandatory Password Change after Login
+     */
+    async loginChangePassword (obj, args, context) {
+      try {
+        const authResult = await WIKI.models.users.loginChangePassword(args, context)
+        return {
+          ...authResult,
+          responseResult: graphHelper.generateSuccess('Password changed successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
     /**
      * Register a new account
      */
-    async register(obj, args, context) {
+    async register (obj, args, context) {
       try {
         await WIKI.models.users.register({ ...args, verify: true }, context)
         return {
@@ -90,7 +104,7 @@ module.exports = {
     /**
      * Update Authentication Strategies
      */
-    async updateStrategies(obj, args, context) {
+    async updateStrategies (obj, args, context) {
       try {
         WIKI.config.auth = {
           audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
@@ -122,7 +136,7 @@ module.exports = {
     /**
      * Generate New Authentication Public / Private Key Certificates
      */
-    async regenerateCertificates(obj, args, context) {
+    async regenerateCertificates (obj, args, context) {
       try {
         await WIKI.auth.regenerateCertificates()
         return {
@@ -135,7 +149,7 @@ module.exports = {
     /**
      * Reset Guest User
      */
-    async resetGuestUser(obj, args, context) {
+    async resetGuestUser (obj, args, context) {
       try {
         await WIKI.auth.resetGuestUser()
         return {

+ 11 - 2
server/graph/resolvers/site.js

@@ -17,7 +17,8 @@ module.exports = {
         company: WIKI.config.company,
         ...WIKI.config.seo,
         ...WIKI.config.logo,
-        ...WIKI.config.features
+        ...WIKI.config.features,
+        ...WIKI.config.security
       }
     }
   },
@@ -42,7 +43,15 @@ module.exports = {
           featurePageComments: args.featurePageComments,
           featurePersonalWikis: args.featurePersonalWikis
         }
-        await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features'])
+        WIKI.config.security = {
+          securityIframe: args.securityIframe,
+          securityReferrerPolicy: args.securityReferrerPolicy,
+          securityHSTS: args.securityHSTS,
+          securityHSTSDuration: args.securityHSTSDuration,
+          securityCSP: args.securityCSP,
+          securityCSPDirectives: args.securityCSPDirectives
+        }
+        await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'seo', 'logo', 'features', 'security'])
 
         return {
           responseResult: graphHelper.generateSuccess('Site configuration updated successfully')

+ 10 - 4
server/graph/schemas/authentication.graphql

@@ -32,9 +32,14 @@ type AuthenticationMutation {
   ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
 
   loginTFA(
-    loginToken: String!
+    continuationToken: String!
     securityCode: String!
-  ): DefaultResponse @rateLimit(limit: 5, duration: 60)
+  ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
+
+  loginChangePassword(
+    continuationToken: String!
+    newPassword: String!
+  ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
 
   register(
     email: String!
@@ -76,8 +81,9 @@ type AuthenticationStrategy {
 type AuthenticationLoginResponse {
   responseResult: ResponseStatus
   jwt: String
-  tfaRequired: Boolean
-  tfaLoginToken: String
+  mustChangePwd: Boolean
+  mustProvideTFA: Boolean
+  continuationToken: String
 }
 
 type AuthenticationRegisterResponse {

+ 12 - 0
server/graph/schemas/site.graphql

@@ -36,6 +36,12 @@ type SiteMutation {
     featurePageRatings: Boolean!
     featurePageComments: Boolean!
     featurePersonalWikis: Boolean!
+    securityIframe: Boolean!
+    securityReferrerPolicy: Boolean!
+    securityHSTS: Boolean!
+    securityHSTSDuration: Int!
+    securityCSP: Boolean!
+    securityCSPDirectives: String!
   ): DefaultResponse @auth(requires: ["manage:system"])
 }
 
@@ -56,4 +62,10 @@ type SiteConfig {
   featurePageRatings: Boolean!
   featurePageComments: Boolean!
   featurePersonalWikis: Boolean!
+  securityIframe: Boolean!
+  securityReferrerPolicy: Boolean!
+  securityHSTS: Boolean!
+  securityHSTSDuration: Int!
+  securityCSP: Boolean!
+  securityCSPDirectives: String!
 }

+ 2 - 0
server/graph/schemas/user.graphql

@@ -89,6 +89,8 @@ type User {
   providerKey: String!
   providerId: String
   isSystem: Boolean!
+  isActive: Boolean!
+  isVerified: Boolean!
   location: String!
   jobTitle: String!
   timezone: String!

+ 12 - 3
server/middlewares/security.js

@@ -1,4 +1,4 @@
-'use strict'
+/* global WIKI */
 
 /**
  * Security Middleware
@@ -13,7 +13,9 @@ module.exports = function (req, res, next) {
   req.app.disable('x-powered-by')
 
   // -> Disable Frame Embedding
-  res.set('X-Frame-Options', 'deny')
+  if (WIKI.config.securityIframe) {
+    res.set('X-Frame-Options', 'deny')
+  }
 
   // -> Re-enable XSS Fitler if disabled
   res.set('X-XSS-Protection', '1; mode=block')
@@ -25,7 +27,14 @@ module.exports = function (req, res, next) {
   res.set('X-UA-Compatible', 'IE=edge')
 
   // -> Disables referrer header when navigating to a different origin
-  res.set('Referrer-Policy', 'same-origin')
+  if (WIKI.config.securityReferrerPolicy) {
+    res.set('Referrer-Policy', 'same-origin')
+  }
+
+  // -> Enforce HSTS
+  if (WIKI.config.securityHSTS) {
+    res.set('Strict-Transport-Security', `max-age=${WIKI.config.securityHSTSDuration}; includeSubDomains`)
+  }
 
   return next()
 }

+ 1 - 1
server/models/userKeys.js

@@ -45,7 +45,7 @@ module.exports = class UserKey extends Model {
   }
 
   static async generateToken ({ userId, kind }, context) {
-    const token = await nanoid()
+    const token = nanoid()
     await WIKI.models.userKeys.query().insert({
       kind,
       token,

+ 61 - 16
server/models/users.js

@@ -3,7 +3,6 @@
 const bcrypt = require('bcryptjs-then')
 const _ = require('lodash')
 const tfa = require('node-2fa')
-const securityHelper = require('../helpers/security')
 const jwt = require('jsonwebtoken')
 const Model = require('objection').Model
 const validate = require('validate.js')
@@ -280,30 +279,46 @@ module.exports = class User extends Model {
           if (err) { return reject(err) }
           if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) }
 
+          // Must Change Password?
+          if (user.mustChangePwd) {
+            try {
+              const pwdChangeToken = await WIKI.models.userKeys.generateToken({
+                kind: 'changePwd',
+                userId: user.id
+              })
+
+              return resolve({
+                mustChangePwd: true,
+                continuationToken: pwdChangeToken
+              })
+            } catch (err) {
+              WIKI.logger.warn(err)
+              return reject(new WIKI.Error.AuthGenericError())
+            }
+          }
+
           // Is 2FA required?
           if (user.tfaIsActive) {
             try {
-              let loginToken = await securityHelper.generateToken(32)
-              await WIKI.redis.set(`tfa:${loginToken}`, user.id, 'EX', 600)
+              const tfaToken = await WIKI.models.userKeys.generateToken({
+                kind: 'tfa',
+                userId: user.id
+              })
               return resolve({
                 tfaRequired: true,
-                tfaLoginToken: loginToken
+                continuationToken: tfaToken
               })
             } catch (err) {
               WIKI.logger.warn(err)
               return reject(new WIKI.Error.AuthGenericError())
             }
-          } else {
-            // No 2FA, log in user
-            return context.req.logIn(user, { session: !strInfo.useForm }, async err => {
-              if (err) { return reject(err) }
-              const jwtToken = await WIKI.models.users.refreshToken(user)
-              resolve({
-                jwt: jwtToken.token,
-                tfaRequired: false
-              })
-            })
           }
+
+          context.req.logIn(user, { session: !strInfo.useForm }, async err => {
+            if (err) { return reject(err) }
+            const jwtToken = await WIKI.models.users.refreshToken(user)
+            resolve({ jwt: jwtToken.token })
+          })
         })(context.req, context.res, () => {})
       })
     } else {
@@ -348,7 +363,7 @@ module.exports = class User extends Model {
     }
   }
 
-  static async loginTFA(opts, context) {
+  static async loginTFA (opts, context) {
     if (opts.securityCode.length === 6 && opts.loginToken.length === 64) {
       let result = await WIKI.redis.get(`tfa:${opts.loginToken}`)
       if (result) {
@@ -374,6 +389,36 @@ module.exports = class User extends Model {
     throw new WIKI.Error.AuthTFAInvalid()
   }
 
+  /**
+   * Change Password from a Mandatory Password Change after Login
+   */
+  static async loginChangePassword ({ continuationToken, newPassword }, context) {
+    if (!newPassword || newPassword.length < 6) {
+      throw new WIKI.Error.InputInvalid('Password must be at least 6 characters!')
+    }
+    const usr = await WIKI.models.userKeys.validateToken({
+      kind: 'changePwd',
+      token: continuationToken
+    })
+
+    if (usr) {
+      await WIKI.models.users.query().patch({
+        password: newPassword,
+        mustChangePwd: false
+      }).findById(usr.id)
+
+      return new Promise((resolve, reject) => {
+        context.req.logIn(usr, { session: false }, async err => {
+          if (err) { return reject(err) }
+          const jwtToken = await WIKI.models.users.refreshToken(usr)
+          resolve({ jwt: jwtToken.token })
+        })
+      })
+    } else {
+      throw new WIKI.Error.UserNotFound()
+    }
+  }
+
   /**
    * Create a new user
    *
@@ -520,7 +565,7 @@ module.exports = class User extends Model {
         }
         usrData.password = newPassword
       }
-      if (!_.isEmpty(groups)) {
+      if (_.isArray(groups)) {
         const usrGroupsRaw = await usr.$relatedQuery('groups')
         const usrGroups = _.map(usrGroupsRaw, 'id')
         // Relate added groups

+ 1 - 1
server/modules/authentication/local/definition.yml

@@ -3,7 +3,7 @@ title: Local
 description: Built-in authentication for Wiki.js
 author: requarks.io
 logo: https://static.requarks.io/logo/wikijs.svg
-color: yellow darken-3
+color: primary
 website: https://wiki.js.org
 isAvailable: true
 useForm: true

+ 7 - 21
server/views/error.pug

@@ -2,25 +2,11 @@ extends master.pug
 
 block body
   #root.is-fullscreen
-    v-app(dark)
-      .app-error
-        v-container
-          .pt-5
-            v-layout(row)
-              v-flex(xs10)
-                a(href='/'): img(src='/svg/logo-wikijs.svg')
-              v-flex.text-right(xs2)
-                v-btn(href='/', depressed, color='red darken-3')
-                  v-icon(left) home
-                  span Home
-            v-alert(color='grey', outline, :value='true', icon='error')
-              strong.red--text.text--lighten-3 Oops, something went wrong...
-              .body-1.red--text.text--lighten-2= message
+    .app-error
+      a(href='/')
+        img(src='/svg/logo-wikijs.svg')
+      strong Oops, something went wrong...
+      span= message
             
-            if error.stack
-              v-expansion-panel.mt-5
-                v-expansion-panel-content.red.darken-3(:value='true')
-                  div(slot='header') View Debug Trace
-                  v-card(color='grey darken-4')
-                    v-card-text
-                      pre: code #{error.stack}
+      if error.stack
+        pre: code #{error.stack}

+ 1 - 1
server/views/legacy/master.pug

@@ -32,7 +32,7 @@ html
       link(
         type='text/css'
         rel='stylesheet'
-        href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
+        href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
         )
     else if config.theming.iconset === 'fa4'
       link(

+ 1 - 1
server/views/master.pug

@@ -36,7 +36,7 @@ html(lang=siteConfig.lang)
       link(
         type='text/css'
         rel='stylesheet'
-        href='https://use.fontawesome.com/releases/v5.9.0/css/all.css'
+        href='https://use.fontawesome.com/releases/v5.10.0/css/all.css'
         )
     else if config.theming.iconset === 'fa4'
       link(

Різницю між файлами не показано, бо вона завелика
+ 347 - 274
yarn.lock


Деякі файли не було показано, через те що забагато файлів було змінено