Sfoglia il codice sorgente

refactor: deps update + 2FA setup + verify

NGPixel 4 anni fa
parent
commit
f72530bf84
58 ha cambiato i file con 755 aggiunte e 778 eliminazioni
  1. 1 1
      client/client-app.js
  2. 15 0
      client/components/admin.vue
  3. 8 24
      client/components/admin/admin-analytics.vue
  4. 1 1
      client/components/admin/admin-api-create.vue
  5. 2 2
      client/components/admin/admin-api.vue
  6. 1 20
      client/components/admin/admin-auth.vue
  7. 7 23
      client/components/admin/admin-comments.vue
  8. 1 1
      client/components/admin/admin-mail.vue
  9. 13 11
      client/components/admin/admin-navigation.vue
  10. 6 12
      client/components/admin/admin-rendering.vue
  11. 8 6
      client/components/admin/admin-search.vue
  12. 7 4
      client/components/admin/admin-storage.vue
  13. 2 2
      client/components/admin/admin-tags.vue
  14. 1 1
      client/components/admin/admin-users-create.vue
  15. 8 6
      client/components/admin/admin-users-edit.vue
  16. 3 3
      client/components/admin/admin-utilities-importv1.vue
  17. 3 3
      client/components/comments.vue
  18. 13 3
      client/components/common/nav-header.vue
  19. 1 1
      client/components/history.vue
  20. 157 82
      client/components/login.vue
  21. 2 2
      client/components/register.vue
  22. 2 2
      client/components/setup.vue
  23. 0 0
      client/fonts/arabic/BalooBhaijaan-Regular.woff
  24. 0 0
      client/fonts/arabic/BalooBhaijaan-Regular.woff2
  25. 0 0
      client/fonts/arabic/Tajawal-Bold.woff
  26. 0 0
      client/fonts/arabic/Tajawal-Bold.woff2
  27. 0 0
      client/fonts/arabic/Tajawal-Medium.woff
  28. 0 0
      client/fonts/arabic/Tajawal-Medium.woff2
  29. 0 0
      client/fonts/arabic/Tajawal-Regular.woff
  30. 0 0
      client/fonts/arabic/Tajawal-Regular.woff2
  31. 0 0
      client/fonts/default/Roboto-Bold.woff
  32. 0 0
      client/fonts/default/Roboto-Bold.woff2
  33. 0 0
      client/fonts/default/Roboto-BoldItalic.woff
  34. 0 0
      client/fonts/default/Roboto-BoldItalic.woff2
  35. 0 0
      client/fonts/default/Roboto-Italic.woff
  36. 0 0
      client/fonts/default/Roboto-Italic.woff2
  37. 0 0
      client/fonts/default/Roboto-Medium.woff
  38. 0 0
      client/fonts/default/Roboto-Medium.woff2
  39. 0 0
      client/fonts/default/Roboto-MediumItalic.woff
  40. 0 0
      client/fonts/default/Roboto-MediumItalic.woff2
  41. 0 0
      client/fonts/default/Roboto-Regular.woff
  42. 0 0
      client/fonts/default/Roboto-Regular.woff2
  43. 0 0
      client/fonts/default/RobotoMono-Regular.woff
  44. 0 0
      client/fonts/default/RobotoMono-Regular.woff2
  45. 1 0
      client/index-legacy.js
  46. 10 10
      client/scss/fonts/arabic.scss
  47. 14 14
      client/scss/fonts/default.scss
  48. 0 1
      client/scss/legacy.scss
  49. 1 4
      dev/webpack/webpack.dev.js
  50. 1 4
      dev/webpack/webpack.prod.js
  51. 66 66
      package.json
  52. 3 0
      server/graph/schemas/authentication.graphql
  53. 1 0
      server/graph/schemas/user.graphql
  54. 1 1
      server/models/assets.js
  55. 14 8
      server/models/userKeys.js
  56. 106 72
      server/models/users.js
  57. 1 1
      server/setup.js
  58. 274 387
      yarn.lock

+ 1 - 1
client/client-app.js

@@ -64,7 +64,7 @@ const graphQLLink = ApolloLink.from([
       })
       store.commit('showNotification', {
         style: 'red',
-        message: isAuthError ? `You are not authorized to access this resource.` : `An unexpected error occured.`,
+        message: isAuthError ? `You are not authorized to access this resource.` : `An unexpected error occurred.`,
         icon: 'alert'
       })
     }

+ 15 - 0
client/components/admin.vue

@@ -309,6 +309,21 @@ export default {
   }
 }
 
+.admin-providerlogo {
+  width: 250px;
+  height: 50px;
+  float: right;
+  display: flex;
+  justify-content: flex-end;
+  align-items: center;
+  margin-left: 16px;
+
+  img {
+    max-width: 100%;
+    max-height: 50px;
+  }
+}
+
 .v-application.admin {
   code {
     box-shadow: none;

+ 8 - 24
client/components/admin/admin-analytics.vue

@@ -46,14 +46,16 @@
               hide-details
               inset
               )
+          v-card-info(color='blue')
+            div
+              div {{provider.description}}
+              span.caption: a(:href='provider.website') {{provider.website}}
+            v-spacer
+            .admin-providerlogo
+              img(:src='provider.logo', :alt='provider.title')
           v-card-text
             v-form
-              .analytic-provider-logo
-                img(:src='provider.logo', :alt='provider.title')
-              .body-2.pt-3 {{provider.description}}
-              .body-2.pt-3: a(:href='provider.website') {{provider.website}}
-              v-divider.mt-5
-              .overline.py-5 {{$t('admin:analytics.providerConfiguration')}}
+              .overline.pb-5 {{$t('admin:analytics.providerConfiguration')}}
               .body-1.ml-3(v-if='!provider.config || provider.config.length < 1'): em {{$t('admin:analytics.providerNoConfiguration')}}
               template(v-else, v-for='cfg in provider.config')
                 v-select(
@@ -177,21 +179,3 @@ export default {
   }
 }
 </script>
-
-<style lang='scss' scoped>
-
-.analytic-provider-logo {
-  width: 250px;
-  height: 85px;
-  float:right;
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-
-  img {
-    max-width: 100%;
-    max-height: 50px;
-  }
-}
-
-</style>

+ 1 - 1
client/components/admin/admin-api-create.vue

@@ -211,7 +211,7 @@ export default {
         } else {
           this.$store.commit('showNotification', {
             style: 'red',
-            message: _.get(resp, 'data.authentication.createApiKey.responseResult.message', 'An unexpected error occured.'),
+            message: _.get(resp, 'data.authentication.createApiKey.responseResult.message', 'An unexpected error occurred.'),
             icon: 'alert'
           })
         }

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

@@ -131,7 +131,7 @@ export default {
         } else {
           this.$store.commit('showNotification', {
             style: 'red',
-            message: _.get(resp, 'data.authentication.setApiState.responseResult.message', 'An unexpected error occured.'),
+            message: _.get(resp, 'data.authentication.setApiState.responseResult.message', 'An unexpected error occurred.'),
             icon: 'alert'
           })
         }
@@ -182,7 +182,7 @@ export default {
         } else {
           this.$store.commit('showNotification', {
             style: 'red',
-            message: _.get(resp, 'data.authentication.revokeApiKey.responseResult.message', 'An unexpected error occured.'),
+            message: _.get(resp, 'data.authentication.revokeApiKey.responseResult.message', 'An unexpected error occurred.'),
             icon: 'alert'
           })
         }

+ 1 - 20
client/components/admin/admin-auth.vue

@@ -74,7 +74,7 @@
               span {{strategy.strategy.description}}
               .caption: a(:href='strategy.strategy.website') {{strategy.strategy.website}}
             v-spacer
-            .authlogo
+            .admin-providerlogo
               img(:src='strategy.strategy.logo', :alt='strategy.strategy.title')
           v-card-text
             .overline.mb-5 {{$t('admin:auth.strategyConfiguration')}}
@@ -423,22 +423,3 @@ export default {
   }
 }
 </script>
-
-<style lang='scss' scoped>
-
-.authlogo {
-  width: 250px;
-  height: 60px;
-  float:right;
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-  margin-left: 16px;
-
-  img {
-    max-width: 100%;
-    max-height: 50px;
-  }
-}
-
-</style>

+ 7 - 23
client/components/admin/admin-comments.vue

@@ -38,12 +38,14 @@
         v-card.animated.fadeInUp.wait-p2s
           v-toolbar(color='primary', dense, flat, dark)
             .subtitle-1 {{provider.title}}
-          v-card-text
-            .providerlogo
+          v-card-info(color='blue')
+            div
+              div {{provider.description}}
+              span.caption: a(:href='provider.website') {{provider.website}}
+            v-spacer
+            .admin-providerlogo
               img(:src='provider.logo', :alt='provider.title')
-            .caption.pt-3 {{provider.description}}
-            .caption.pb-3: a(:href='provider.website') {{provider.website}}
-            v-divider.mt-3
+          v-card-text
             .overline.my-5 {{$t('admin:comments.providerConfig')}}
             .body-2.ml-3(v-if='!provider.config || provider.config.length < 1'): em {{$t('admin:comments.providerNoConfig')}}
             template(v-else, v-for='cfg in provider.config')
@@ -202,21 +204,3 @@ export default {
   }
 }
 </script>
-
-<style lang='scss' scoped>
-
-.providerlogo {
-  width: 250px;
-  height: 85px;
-  float:right;
-  display: flex;
-  justify-content: flex-end;
-  align-items: center;
-
-  img {
-    max-width: 100%;
-    max-height: 50px;
-  }
-}
-
-</style>

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

@@ -226,7 +226,7 @@ export default {
           }
         })
         if (!_.get(resp, 'data.mail.sendTest.responseResult.succeeded', false)) {
-          throw new Error(_.get(resp, 'data.mail.sendTest.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.mail.sendTest.responseResult.message', 'An unexpected error occurred.'))
         }
 
         this.testEmail = ''

+ 13 - 11
client/components/admin/admin-navigation.vue

@@ -8,7 +8,9 @@
             .headline.primary--text.animated.fadeInLeft {{$t('navigation.title')}}
             .subtitle-1.grey--text.animated.fadeInLeft.wait-p4s {{$t('navigation.subtitle')}}
           v-spacer
-          v-btn.animated.fadeInDown.wait-p2s.mr-3(icon, outlined, color='grey', @click='refresh')
+          v-btn.animated.fadeInDown.wait-p3s(icon, outlined, color='grey', href='https://docs.requarks.io/navigation', target='_blank')
+            v-icon mdi-help-circle
+          v-btn.mx-3.animated.fadeInDown.wait-p2s.mr-3(icon, outlined, color='grey', @click='refresh')
             v-icon mdi-refresh
           v-btn.animated.fadeInDown(color='success', depressed, @click='save', large)
             v-icon(left) mdi-check
@@ -30,15 +32,6 @@
                       v-list-item-avatar
                         v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `TREE` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle
                         v-icon(v-else, :color='config.mode === `TREE` ? `teal` : `grey lighten-3`') mdi-check-circle
-                    v-list-item(value='MIXED')
-                      v-list-item-avatar
-                        img(src='/_assets/svg/icon-user-menu-male-dotted.svg', alt='Custom Navigation')
-                      v-list-item-content
-                        v-list-item-title {{$t('admin:navigation.modeCustom.title')}}
-                        v-list-item-subtitle {{$t('admin:navigation.modeCustom.description')}}
-                      v-list-item-avatar
-                        v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `MIXED` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle
-                        v-icon(v-else, :color='config.mode === `MIXED` ? `teal` : `grey lighten-3`') mdi-check-circle
                     v-list-item(value='STATIC')
                       v-list-item-avatar
                         img(src='/_assets/svg/icon-features-list.svg', alt='Static Navigation')
@@ -48,6 +41,15 @@
                       v-list-item-avatar
                         v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `STATIC` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle
                         v-icon(v-else, :color='config.mode === `STATIC` ? `teal` : `grey lighten-3`') mdi-check-circle
+                    v-list-item(value='MIXED')
+                      v-list-item-avatar
+                        img(src='/_assets/svg/icon-user-menu-male-dotted.svg', alt='Custom Navigation')
+                      v-list-item-content
+                        v-list-item-title {{$t('admin:navigation.modeCustom.title')}}
+                        v-list-item-subtitle {{$t('admin:navigation.modeCustom.description')}}
+                      v-list-item-avatar
+                        v-icon(v-if='$vuetify.theme.dark', :color='config.mode === `MIXED` ? `teal lighten-3` : `grey darken-2`') mdi-check-circle
+                        v-icon(v-else, :color='config.mode === `MIXED` ? `teal` : `grey lighten-3`') mdi-check-circle
                     v-list-item(value='NONE')
                       v-list-item-avatar
                         img(src='/_assets/svg/icon-cancel-dotted.svg', alt='None')
@@ -421,7 +423,7 @@ export default {
             icon: 'check'
           })
         } else {
-          throw new Error(_.get(resp, 'data.navigation.updateTree.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.navigation.updateTree.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         this.$store.commit('pushGraphError', err)

+ 6 - 12
client/components/admin/admin-rendering.vue

@@ -85,18 +85,12 @@
               hide-details
               inset
               )
-          v-card-text.py-2.pl-4
-            .body-2.pt-3 {{currentRenderer.description}}
-            .body-2.pt-1.pb-5: a(href='https://docs.requarks.io/en/rendering', target='_blank') Documentation
-            i18next.body-2(path='admin:auth.strategyState', tag='div', v-if='currentRenderer.isEnabled')
-              v-chip(color='green', small, dark, label, place='state') {{$t('admin:auth.strategyStateActive')}}
-              span(v-if='selectedCore === `local`', place='locked') {{$t('admin:auth.strategyStateLocked')}}
-              span(v-else, place='locked', v-text='')
-            i18next.body-2(path='admin:auth.strategyState', tag='div', v-else)
-              v-chip(color='red', small, dark, label, place='state') {{$t('admin:auth.strategyStateInactive')}}
-          v-divider.mt-3
-          v-card-text.pb-4.pt-2.pl-4
-            .overline.my-5 Rendering Module Configuration
+          v-card-info(color='blue')
+            div
+              div {{currentRenderer.description}}
+              span.caption: a(href='https://docs.requarks.io/en/rendering', target='_blank') Documentation
+          v-card-text.pb-4.pl-4
+            .overline.mb-5 Rendering Module Configuration
             .body-2.ml-3(v-if='!currentRenderer.config || currentRenderer.config.length < 1'): em This rendering module has no configuration options you can modify.
             template(v-else, v-for='(cfg, idx) in currentRenderer.config')
               v-select(

+ 8 - 6
client/components/admin/admin-search.vue

@@ -41,13 +41,15 @@
         v-card.animated.fadeInUp.wait-p2s
           v-toolbar(color='primary', dense, flat, dark)
             .subtitle-1 {{engine.title}}
-          v-card-text
-            .enginelogo
+          v-card-info(color='blue')
+            div
+              div {{engine.description}}
+              span.caption: a(:href='engine.website') {{engine.website}}
+            v-spacer
+            .admin-providerlogo
               img(:src='engine.logo', :alt='engine.title')
-            .caption.pt-3 {{engine.description}}
-            .caption.pb-3: a(:href='engine.website') {{engine.website}}
-            v-divider.mt-3
-            .overline.my-5 {{$t('admin:search.engineConfig')}}
+          v-card-text
+            .overline.mb-5 {{$t('admin:search.engineConfig')}}
             .body-2.ml-3(v-if='!engine.config || engine.config.length < 1'): em {{$t('admin:search.engineNoConfig')}}
             template(v-else, v-for='cfg in engine.config')
               v-select(

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

@@ -92,12 +92,15 @@
               hide-details
               inset
               )
+          v-card-info(color='blue')
+            div
+              div {{target.description}}
+              span.caption: a(:href='target.website') {{target.website}}
+            v-spacer
+            .admin-providerlogo
+              img(:src='target.logo', :alt='target.title')
           v-card-text
             v-form
-              .targetlogo
-                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)

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

@@ -153,7 +153,7 @@ export default {
           })
           this.refresh()
         } else {
-          throw new Error(_.get(resp, 'data.pages.deleteTag.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.pages.deleteTag.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         this.$store.commit('pushGraphError', err)
@@ -193,7 +193,7 @@ export default {
           })
           this.current.updatedAt = new Date()
         } else {
-          throw new Error(_.get(resp, 'data.pages.updateTag.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.pages.updateTag.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         this.$store.commit('pushGraphError', err)

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

@@ -212,7 +212,7 @@ export default {
         } else {
           this.$store.commit('showNotification', {
             style: 'red',
-            message: _.get(resp, 'data.users.create.responseResult.message', 'An unexpected error occured.'),
+            message: _.get(resp, 'data.users.create.responseResult.message', 'An unexpected error occurred.'),
             icon: 'alert'
           })
         }

+ 8 - 6
client/components/admin/admin-users-edit.vue

@@ -174,7 +174,8 @@
                   v-icon mdi-two-factor-authentication
                 v-list-item-content
                   v-list-item-title {{$t('admin:users.tfa')}}
-                  v-list-item-subtitle.red--text Inactive
+                  v-list-item-subtitle.green--text(v-if='user.tfaIsActive') Active
+                  v-list-item-subtitle.red--text(v-else) Inactive
                 v-list-item-action
                   v-tooltip(top)
                     template(v-slot:activator='{ on }')
@@ -709,7 +710,7 @@ export default {
       } else {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: _.get(resp, 'data.users.activate.responseResult.message', 'An unexpected error occured.'),
+          message: _.get(resp, 'data.users.activate.responseResult.message', 'An unexpected error occurred.'),
           icon: 'warning'
         })
       }
@@ -749,7 +750,7 @@ export default {
       } else {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: _.get(resp, 'data.users.deactivate.responseResult.message', 'An unexpected error occured.'),
+          message: _.get(resp, 'data.users.deactivate.responseResult.message', 'An unexpected error occurred.'),
           icon: 'warning'
         })
       }
@@ -798,7 +799,7 @@ export default {
       } else {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: _.get(resp, 'data.users.delete.responseResult.message', 'An unexpected error occured.'),
+          message: _.get(resp, 'data.users.delete.responseResult.message', 'An unexpected error occurred.'),
           icon: 'warning'
         })
       }
@@ -864,7 +865,7 @@ export default {
       } else {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: _.get(resp, 'data.users.update.responseResult.message', 'An unexpected error occured.'),
+          message: _.get(resp, 'data.users.update.responseResult.message', 'An unexpected error occurred.'),
           icon: 'warning'
         })
       }
@@ -935,7 +936,7 @@ export default {
       } else {
         this.$store.commit('showNotification', {
           style: 'red',
-          message: _.get(resp, 'data.users.verify.responseResult.message', 'An unexpected error occured.'),
+          message: _.get(resp, 'data.users.verify.responseResult.message', 'An unexpected error occurred.'),
           icon: 'warning'
         })
       }
@@ -962,6 +963,7 @@ export default {
               createdAt
               updatedAt
               lastLoginAt
+              tfaIsActive
               groups {
                 id
                 name

+ 3 - 3
client/components/admin/admin-utilities-importv1.vue

@@ -345,7 +345,7 @@ export default {
             })
             const respObj = _.get(resp, 'data.system.importUsersFromV1', {})
             if (!_.get(respObj, 'responseResult.succeeded', false)) {
-              throw new Error(_.get(respObj, 'responseResult.message', 'An unexpected error occured'))
+              throw new Error(_.get(respObj, 'responseResult.message', 'An unexpected error occurred'))
             }
             this.successUsers = _.get(respObj, 'usersCount', 0)
             this.successGroups = _.get(respObj, 'groupsCount', 0)
@@ -429,7 +429,7 @@ export default {
               })
               const respObj = _.get(respSv, 'data.storage.updateTargets', {})
               if (!_.get(respObj, 'responseResult.succeeded', false)) {
-                throw new Error(_.get(respObj, 'responseResult.message', 'An unexpected error occured'))
+                throw new Error(_.get(respObj, 'responseResult.message', 'An unexpected error occurred'))
               }
 
               this.progress += 10
@@ -480,7 +480,7 @@ export default {
 
               const respImportObj = _.get(respImport, 'data.storage.executeAction', {})
               if (!_.get(respImportObj, 'responseResult.succeeded', false)) {
-                throw new Error(_.get(respImportObj, 'responseResult.message', 'An unexpected error occured'))
+                throw new Error(_.get(respImportObj, 'responseResult.message', 'An unexpected error occurred'))
               }
 
               this.progress += 15

+ 3 - 3
client/components/comments.vue

@@ -314,7 +314,7 @@ export default {
             this.$vuetify.goTo(`#comment-post-id-${_.get(resp, 'data.comments.create.id', 0)}`, this.scrollOpts)
           })
         } else {
-          throw new Error(_.get(resp, 'data.comments.create.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.comments.create.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         this.$store.commit('showNotification', {
@@ -420,7 +420,7 @@ export default {
 
           this.editCommentCancel()
         } else {
-          throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         console.warn(err)
@@ -482,7 +482,7 @@ export default {
 
           this.comments = _.reject(this.comments, ['id', this.commentToDelete.id])
         } else {
-          throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occured.'))
+          throw new Error(_.get(resp, 'data.comments.delete.responseResult.message', 'An unexpected error occurred.'))
         }
       } catch (err) {
         this.$store.commit('showNotification', {

+ 13 - 3
client/components/common/nav-header.vue

@@ -172,6 +172,19 @@
               span {{$t('common:header.newPage')}}
             v-divider(vertical)
 
+          //- ADMIN
+
+          template(v-if='isAuthenticated && isAdmin')
+            v-tooltip(bottom, v-if='mode !== `admin`')
+              template(v-slot:activator='{ on }')
+                v-btn(icon, tile, height='64', v-on='on', href='/a', :aria-label='$t(`common:header.admin`)')
+                  v-icon(color='grey') mdi-cog
+              span {{$t('common:header.admin')}}
+            v-btn(v-else, text, tile, height='64', href='/', :aria-label='$t(`common:actions.exit`)')
+              v-icon(left, color='grey') mdi-exit-to-app
+              span {{$t('common:actions.exit')}}
+            v-divider(vertical)
+
           //- ACCOUNT
 
           v-menu(v-if='isAuthenticated', offset-y, bottom, min-width='300', transition='slide-y-transition', left)
@@ -210,9 +223,6 @@
                 v-list-item-action: v-icon(color='blue-grey') mdi-face-profile
                 v-list-item-content
                   v-list-item-title(:class='$vuetify.theme.dark ? `blue-grey--text text--lighten-3` : `blue-grey--text`') {{$t('common:header.profile')}}
-              v-list-item(href='/a', v-if='isAuthenticated && isAdmin')
-                v-list-item-action.btn-animate-rotate: v-icon(:color='$vuetify.theme.dark ? `blue-grey lighten-3` : `blue-grey`') mdi-cog
-                v-list-item-title(:class='$vuetify.theme.dark ? `blue-grey--text text--lighten-3` : `blue-grey--text`') {{$t('common:header.admin')}}
               v-list-item(@click='logout')
                 v-list-item-action: v-icon(color='red') mdi-logout
                 v-list-item-title.red--text {{$t('common:header.logout')}}

+ 1 - 1
client/components/history.vue

@@ -418,7 +418,7 @@ export default {
             window.location.assign(`/${this.locale}/${this.path}`)
           }, 1000)
         } else {
-          throw new Error(_.get(resp, 'data.pages.restore.responseResult.message', 'An unexpected error occured'))
+          throw new Error(_.get(resp, 'data.pages.restore.responseResult.message', 'An unexpected error occurred'))
         }
       } catch (err) {
         this.$store.commit('showNotification', {

+ 157 - 82
client/components/login.vue

@@ -2,17 +2,27 @@
   v-app
     .login(:style='`background-image: url(` + bgUrl + `);`')
       .login-sd
-        .d-flex
+        .d-flex.mb-5
           .login-logo
             v-avatar(tile, size='34')
               v-img(:src='logoUrl')
           .login-title
             .text-h6 {{ siteTitle }}
+        v-alert.mb-0(
+          v-model='errorShown'
+          transition='slide-y-reverse-transition'
+          color='red darken-2'
+          tile
+          dark
+          dense
+          icon='mdi-alert'
+          )
+          .body-2 {{errorMessage}}
         //-------------------------------------------------
         //- PROVIDERS LIST
         //-------------------------------------------------
         template(v-if='screen === `login` && strategies.length > 1')
-          .login-subtitle.mt-5
+          .login-subtitle
             .text-subtitle-1 Select Authentication Provider
           .login-list
             v-list.elevation-1.radius-7(nav)
@@ -176,19 +186,51 @@
             v-model='securityCode'
             :placeholder='$t("auth:tfa.placeholder")'
             autocomplete='one-time-code'
-            @keyup.enter='verifySecurityCode'
+            @keyup.enter='verifySecurityCode(false)'
+          )
+          v-btn.mt-2.text-none(
+            width='100%'
+            large
+            color='primary'
+            dark
+            @click='verifySecurityCode(false)'
+            :loading='isLoading'
+            ) {{ $t('auth:tfa.verifyToken') }}
+
+    //-------------------------------------------------
+    //- SETUP TFA FORM
+    //-------------------------------------------------
+    v-dialog(v-model='isTFASetupShown', max-width='600', persistent)
+      v-card
+        .login-tfa.text-center.pa-5
+          .subtitle-1.primary--text Your administrator has required Two-Factor Authentication (2FA) to be enabled on your account.
+          v-divider.my-5
+          .subtitle-2 1) Scan the QR code below from your mobile 2FA application:
+          .caption (e.g. #[a(href='https://authy.com/', target='_blank', noopener) Authy], #[a(href='https://support.google.com/accounts/answer/1066447', target='_blank', noopener) Google Authenticator], #[a(href='https://www.microsoft.com/en-us/account/authenticator', target='_blank', noopener) Microsoft Authenticator], etc.)
+          .login-tfa-qr.mt-5(v-if='isTFASetupShown', v-html='tfaQRImage')
+          .subtitle-2.mt-5 2) Enter the security code generated from your trusted device:
+          v-text-field.login-tfa-field.mt-2(
+            solo
+            flat
+            background-color='white'
+            hide-details
+            ref='iptTFASetup'
+            v-model='securityCode'
+            :placeholder='$t("auth:tfa.placeholder")'
+            autocomplete='one-time-code'
+            @keyup.enter='verifySecurityCode(true)'
           )
           v-btn.mt-2.text-none(
             width='100%'
             large
             color='primary'
             dark
-            @click='verifySecurityCode'
+            @click='verifySecurityCode(true)'
             :loading='isLoading'
             ) {{ $t('auth:tfa.verifyToken') }}
 
     loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
-    notify
+    notify(style='padding-top: 64px;')
 </template>
 
 <script>
@@ -231,7 +273,11 @@ export default {
       isShown: false,
       newPassword: '',
       newPasswordVerify: '',
-      isTFAShown: false
+      isTFAShown: false,
+      isTFASetupShown: false,
+      tfaQRImage: '',
+      errorShown: false,
+      errorMessage: ''
     }
   },
   computed: {
@@ -282,26 +328,21 @@ export default {
      * LOGIN
      */
     async login () {
+      this.errorShown = false
       if (this.username.length < 2) {
-        this.$store.commit('showNotification', {
-          style: 'red',
-          message: this.$t('auth:invalidEmailUsername'),
-          icon: 'alert'
-        })
+        this.errorMessage = this.$t('auth:invalidEmailUsername')
+        this.errorShown = true
         this.$refs.iptEmail.focus()
       } else if (this.password.length < 2) {
-        this.$store.commit('showNotification', {
-          style: 'red',
-          message: this.$t('auth:invalidPassword'),
-          icon: 'alert'
-        })
+        this.errorMessage = this.$t('auth:invalidPassword')
+        this.errorShown = true
         this.$refs.iptPassword.focus()
       } else {
         this.loaderColor = 'grey darken-4'
         this.loaderTitle = this.$t('auth:signingIn')
         this.isLoading = true
         try {
-          let resp = await this.$apollo.mutate({
+          const resp = await this.$apollo.mutate({
             mutation: gql`
               mutation($username: String!, $password: String!, $strategy: String!) {
                 authentication {
@@ -315,8 +356,10 @@ export default {
                     jwt
                     mustChangePwd
                     mustProvideTFA
+                    mustSetupTFA
                     continuationToken
                     redirect
+                    tfaQRImage
                   }
                 }
               }
@@ -328,38 +371,9 @@ export default {
             }
           })
           if (_.has(resp, 'data.authentication.login')) {
-            let respObj = _.get(resp, 'data.authentication.login', {})
+            const respObj = _.get(resp, 'data.authentication.login', {})
             if (respObj.responseResult.succeeded === 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.$nextTick(() => {
-                  this.$refs.iptTFA.focus()
-                })
-                this.isLoading = false
-              } else {
-                this.loaderColor = 'green darken-1'
-                this.loaderTitle = this.$t('auth:loginSuccess')
-                Cookies.set('jwt', respObj.jwt, { expires: 365 })
-                _.delay(() => {
-                  const loginRedirect = Cookies.get('loginRedirect')
-                  if (loginRedirect) {
-                    Cookies.remove('loginRedirect')
-                    window.location.replace(loginRedirect)
-                  } else if (respObj.redirect) {
-                    window.location.replace(respObj.redirect)
-                  } else {
-                    window.location.replace('/')
-                  }
-                }, 1000)
-              }
+              this.handleLoginResponse(respObj)
             } else {
               throw new Error(respObj.responseResult.message)
             }
@@ -380,58 +394,70 @@ export default {
     /**
      * VERIFY TFA CODE
      */
-    verifySecurityCode () {
+    async verifySecurityCode (setup = false) {
       if (this.securityCode.length !== 6) {
         this.$store.commit('showNotification', {
           style: 'red',
           message: 'Enter a valid security code.',
-          icon: 'warning'
+          icon: 'alert'
         })
-        this.$refs.iptTFA.focus()
+        if (setup) {
+          this.$refs.iptTFASetup.focus()
+        } else {
+          this.$refs.iptTFA.focus()
+        }
       } else {
+        this.loaderColor = 'grey darken-4'
+        this.loaderTitle = this.$t('auth:signingIn')
         this.isLoading = true
-        this.$apollo.mutate({
-          mutation: gql`
-            {
-              authentication {
-                activeStrategies {
-                  key
+        try {
+          const resp = await this.$apollo.mutate({
+            mutation: gql`
+              mutation(
+                $continuationToken: String!
+                $securityCode: String!
+                $setup: Boolean
+                ) {
+                authentication {
+                  loginTFA(
+                    continuationToken: $continuationToken
+                    securityCode: $securityCode
+                    setup: $setup
+                    ) {
+                    responseResult {
+                      succeeded
+                      errorCode
+                      slug
+                      message
+                    }
+                    jwt
+                    mustChangePwd
+                    continuationToken
+                    redirect
+                  }
                 }
               }
+            `,
+            variables: {
+              continuationToken: this.continuationToken,
+              securityCode: this.securityCode,
+              setup
             }
-          `,
-          variables: {
-            continuationToken: this.continuationToken,
-            securityCode: this.securityCode
-          }
-        }).then(resp => {
+          })
           if (_.has(resp, 'data.authentication.loginTFA')) {
             let respObj = _.get(resp, 'data.authentication.loginTFA', {})
             if (respObj.responseResult.succeeded === true) {
-              this.$store.commit('showNotification', {
-                message: 'Login successful!',
-                style: 'success',
-                icon: 'check'
-              })
-              _.delay(() => {
-                const loginRedirect = Cookies.get('loginRedirect')
-                if (loginRedirect) {
-                  Cookies.remove('loginRedirect')
-                  window.location.replace(loginRedirect)
-                } else if (respObj.redirect) {
-                  window.location.replace(respObj.redirect)
-                } else {
-                  window.location.replace('/')
-                }
-              }, 1000)
-              this.isLoading = false
+              this.handleLoginResponse(respObj)
             } else {
+              if (!setup) {
+                this.isTFAShown = false
+              }
               throw new Error(respObj.responseResult.message)
             }
           } else {
             throw new Error(this.$t('auth:genericError'))
           }
-        }).catch(err => {
+        } catch (err) {
           console.error(err)
           this.$store.commit('showNotification', {
             style: 'red',
@@ -439,7 +465,7 @@ export default {
             icon: 'alert'
           })
           this.isLoading = false
-        })
+        }
       }
     },
     /**
@@ -498,6 +524,46 @@ export default {
         message: 'Coming soon!',
         icon: 'ferry'
       })
+    },
+    handleLoginResponse (respObj) {
+      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.securityCode = ''
+        this.isTFAShown = true
+        setTimeout(() => {
+          this.$refs.iptTFA.focus()
+        }, 500)
+        this.isLoading = false
+      } else if (respObj.mustSetupTFA === true) {
+        this.securityCode = ''
+        this.isTFASetupShown = true
+        this.tfaQRImage = respObj.tfaQRImage
+        setTimeout(() => {
+          this.$refs.iptTFASetup.focus()
+        }, 500)
+        this.isLoading = false
+      } else {
+        this.loaderColor = 'green darken-1'
+        this.loaderTitle = this.$t('auth:loginSuccess')
+        Cookies.set('jwt', respObj.jwt, { expires: 365 })
+        _.delay(() => {
+          const loginRedirect = Cookies.get('loginRedirect')
+          if (loginRedirect) {
+            Cookies.remove('loginRedirect')
+            window.location.replace(loginRedirect)
+          } else if (respObj.redirect) {
+            window.location.replace(respObj.redirect)
+          } else {
+            window.location.replace('/')
+          }
+        }, 1000)
+      }
     }
   },
   apollo: {
@@ -619,6 +685,15 @@ export default {
       &-field input {
         text-align: center;
       }
+
+      &-qr {
+        background-color: #FFF;
+        padding: 5px;
+        border-radius: 5px;
+        width: 200px;
+        height: 200px;
+        margin: 0 auto;
+      }
     }
   }
 </style>

+ 2 - 2
client/components/register.vue

@@ -33,7 +33,7 @@
                   v-text-field.md2.mt-2(
                     solo
                     flat
-                    prepend-icon='mdi-textbox-password'
+                    prepend-icon='mdi-form-textbox-password'
                     :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     ref='iptPassword'
                     v-model='password'
@@ -49,7 +49,7 @@
                   v-text-field.md2.mt-2(
                     solo
                     flat
-                    prepend-icon='mdi-textbox-password'
+                    prepend-icon='mdi-form-textbox-password'
                     :background-color='$vuetify.theme.dark ? `grey darken-3` : `grey lighten-4`'
                     hide-details
                     ref='iptVerifyPassword'

+ 2 - 2
client/components/setup.vue

@@ -7,7 +7,7 @@
             v-card.elevation-20.radius-7.animated.fadeInUp
               v-alert(v-if='isDevMode', tile, dark, color='red darken-3', icon='mdi-alert', prominent)
                 .body-2 You are running an unstable, unreleased development version. This code base is #[strong NOT] for production use!
-                .body-2.mt-3 Cloning the master branch directly from GitHub is #[strong NOT] the proper way to install Wiki.js!
+                .body-2.mt-3 Cloning the dev branch directly from GitHub is #[strong NOT] the proper way to install Wiki.js!
                 .body-2 Read the #[a(href='https://docs.requarks.io/install', style='color: #FFF;') documentation] on correctly installing the latest stable version.
               .text-center
                 img.setup-logo.animated.fadeInUp.wait-p2s(src='/_assets/svg/logo-wikijs-full.svg', alt='Wiki.js Logo')
@@ -249,7 +249,7 @@ export default {
       height: 100vh;
       z-index: 0;
       background-color: transparent;
-      background-image: url(/_assets/svg/motif-grid.svg) !important;
+      background-image: url(../static/svg/motif-grid.svg) !important;
       background-size: 100px;
       background-repeat: repeat;
       animation: bg-anim 100s linear infinite;

+ 0 - 0
client/static/fonts/arabic/BalooBhaijaan-Regular.woff → client/fonts/arabic/BalooBhaijaan-Regular.woff


+ 0 - 0
client/static/fonts/arabic/BalooBhaijaan-Regular.woff2 → client/fonts/arabic/BalooBhaijaan-Regular.woff2


+ 0 - 0
client/static/fonts/arabic/Tajawal-Bold.woff → client/fonts/arabic/Tajawal-Bold.woff


+ 0 - 0
client/static/fonts/arabic/Tajawal-Bold.woff2 → client/fonts/arabic/Tajawal-Bold.woff2


+ 0 - 0
client/static/fonts/arabic/Tajawal-Medium.woff → client/fonts/arabic/Tajawal-Medium.woff


+ 0 - 0
client/static/fonts/arabic/Tajawal-Medium.woff2 → client/fonts/arabic/Tajawal-Medium.woff2


+ 0 - 0
client/static/fonts/arabic/Tajawal-Regular.woff → client/fonts/arabic/Tajawal-Regular.woff


+ 0 - 0
client/static/fonts/arabic/Tajawal-Regular.woff2 → client/fonts/arabic/Tajawal-Regular.woff2


+ 0 - 0
client/static/fonts/default/Roboto-Bold.woff → client/fonts/default/Roboto-Bold.woff


+ 0 - 0
client/static/fonts/default/Roboto-Bold.woff2 → client/fonts/default/Roboto-Bold.woff2


+ 0 - 0
client/static/fonts/default/Roboto-BoldItalic.woff → client/fonts/default/Roboto-BoldItalic.woff


+ 0 - 0
client/static/fonts/default/Roboto-BoldItalic.woff2 → client/fonts/default/Roboto-BoldItalic.woff2


+ 0 - 0
client/static/fonts/default/Roboto-Italic.woff → client/fonts/default/Roboto-Italic.woff


+ 0 - 0
client/static/fonts/default/Roboto-Italic.woff2 → client/fonts/default/Roboto-Italic.woff2


+ 0 - 0
client/static/fonts/default/Roboto-Medium.woff → client/fonts/default/Roboto-Medium.woff


+ 0 - 0
client/static/fonts/default/Roboto-Medium.woff2 → client/fonts/default/Roboto-Medium.woff2


+ 0 - 0
client/static/fonts/default/Roboto-MediumItalic.woff → client/fonts/default/Roboto-MediumItalic.woff


+ 0 - 0
client/static/fonts/default/Roboto-MediumItalic.woff2 → client/fonts/default/Roboto-MediumItalic.woff2


+ 0 - 0
client/static/fonts/default/Roboto-Regular.woff → client/fonts/default/Roboto-Regular.woff


+ 0 - 0
client/static/fonts/default/Roboto-Regular.woff2 → client/fonts/default/Roboto-Regular.woff2


+ 0 - 0
client/static/fonts/default/RobotoMono-Regular.woff → client/fonts/default/RobotoMono-Regular.woff


+ 0 - 0
client/static/fonts/default/RobotoMono-Regular.woff2 → client/fonts/default/RobotoMono-Regular.woff2


+ 1 - 0
client/index-legacy.js

@@ -1,3 +1,4 @@
 require('./scss/legacy.scss')
+require('./scss/fonts/default.scss')
 
 window.WIKI = null

+ 10 - 10
client/scss/fonts/arabic.scss

@@ -1,39 +1,39 @@
 @font-face {
   font-family: 'Tajawal';
-  src: url('/_assets/fonts/arabic/Tajawal-Bold.woff2') format('woff2'),
-      url('/_assets/fonts/arabic/Tajawal-Bold.woff') format('woff');
+  src: url('../../fonts/arabic/Tajawal-Bold.woff2') format('woff2'),
+      url('../../fonts/arabic/Tajawal-Bold.woff') format('woff');
   font-weight: bold;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Tajawal';
-  src: url('/_assets/fonts/arabic/Tajawal-Regular.woff2') format('woff2'),
-      url('/_assets/fonts/arabic/Tajawal-Regular.woff') format('woff');
+  src: url('../../fonts/arabic/Tajawal-Regular.woff2') format('woff2'),
+      url('../../fonts/arabic/Tajawal-Regular.woff') format('woff');
   font-weight: normal;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Tajawal';
-  src: url('/_assets/fonts/arabic/Tajawal-Medium.woff2') format('woff2'),
-      url('/_assets/fonts/arabic/Tajawal-Medium.woff') format('woff');
+  src: url('../../fonts/arabic/Tajawal-Medium.woff2') format('woff2'),
+      url('../../fonts/arabic/Tajawal-Medium.woff') format('woff');
   font-weight: 500;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'BalooBhaijaan';
-  src: url('/_assets/fonts/arabic/BalooBhaijaan-Regular.woff2') format('woff2'),
-      url('/_assets/fonts/arabic/BalooBhaijaan-Regular.woff') format('woff');
+  src: url('../../fonts/arabic/BalooBhaijaan-Regular.woff2') format('woff2'),
+      url('../../fonts/arabic/BalooBhaijaan-Regular.woff') format('woff');
   font-weight: normal;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Roboto Mono';
-  src: url('/_assets/fonts/default/RobotoMono-Regular.woff2') format('woff2'),
-      url('/_assets/fonts/default/RobotoMono-Regular.woff') format('woff');
+  src: url('../../fonts/default/RobotoMono-Regular.woff2') format('woff2'),
+      url('../../fonts/default/RobotoMono-Regular.woff') format('woff');
   font-weight: normal;
   font-style: normal;
 }

+ 14 - 14
client/scss/fonts/default.scss

@@ -1,55 +1,55 @@
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-MediumItalic.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-MediumItalic.woff') format('woff');
+  src: url('../../fonts/default/Roboto-MediumItalic.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-MediumItalic.woff') format('woff');
   font-weight: 500;
   font-style: italic;
 }
 
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-Italic.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-Italic.woff') format('woff');
+  src: url('../../fonts/default/Roboto-Italic.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-Italic.woff') format('woff');
   font-weight: normal;
   font-style: italic;
 }
 
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-Bold.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-Bold.woff') format('woff');
+  src: url('../../fonts/default/Roboto-Bold.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-Bold.woff') format('woff');
   font-weight: bold;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-Regular.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-Regular.woff') format('woff');
+  src: url('../../fonts/default/Roboto-Regular.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-Regular.woff') format('woff');
   font-weight: normal;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-BoldItalic.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-BoldItalic.woff') format('woff');
+  src: url('../../fonts/default/Roboto-BoldItalic.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-BoldItalic.woff') format('woff');
   font-weight: bold;
   font-style: italic;
 }
 
 @font-face {
   font-family: 'Roboto';
-  src: url('/_assets/fonts/default/Roboto-Medium.woff2') format('woff2'),
-      url('/_assets/fonts/default/Roboto-Medium.woff') format('woff');
+  src: url('../../fonts/default/Roboto-Medium.woff2') format('woff2'),
+      url('../../fonts/default/Roboto-Medium.woff') format('woff');
   font-weight: 500;
   font-style: normal;
 }
 
 @font-face {
   font-family: 'Roboto Mono';
-  src: url('/_assets/fonts/default/RobotoMono-Regular.woff2') format('woff2'),
-      url('/_assets/fonts/default/RobotoMono-Regular.woff') format('woff');
+  src: url('../../fonts/default/RobotoMono-Regular.woff2') format('woff2'),
+      url('../../fonts/default/RobotoMono-Regular.woff') format('woff');
   font-weight: normal;
   font-style: normal;
 }

+ 0 - 1
client/scss/legacy.scss

@@ -1,7 +1,6 @@
 @import "global";
 
 @import "./base/icons.scss";
-@import "./fonts/default.scss";
 @import '~katex/dist/katex.min.css';
 @import '~@mdi/font/css/materialdesignicons.css';
 

+ 1 - 4
dev/webpack/webpack.dev.js

@@ -162,10 +162,7 @@ module.exports = {
         ]
       },
       {
-        test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
-        exclude: [
-          path.join(process.cwd(), 'client')
-        ],
+        test: /\.(woff2|woff|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
         use: [{
           loader: 'file-loader',
           options: {

+ 1 - 4
dev/webpack/webpack.prod.js

@@ -168,10 +168,7 @@ module.exports = {
         ]
       },
       {
-        test: /\.(woff(2)?|ttf|eot|svg)(\?v=\d+\.\d+\.\d+)?$/,
-        exclude: [
-          path.join(process.cwd(), 'client')
-        ],
+        test: /\.(woff2|woff|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
         use: [{
           loader: 'file-loader',
           options: {

+ 66 - 66
package.json

@@ -38,26 +38,26 @@
   "dependencies": {
     "@aoberoi/passport-slack": "1.0.5",
     "@azure/storage-blob": "12.1.2",
-    "@bugsnag/js": "7.2.0",
+    "@bugsnag/js": "7.3.0",
     "@exlinc/keycloak-passport": "1.0.2",
     "@root/csr": "0.8.1",
-    "@root/keypairs": "0.9.0",
+    "@root/keypairs": "0.10.1",
     "@root/pem": "1.0.4",
     "acme": "3.0.3",
     "akismet-api": "5.0.0",
-    "algoliasearch": "4.3.0",
+    "algoliasearch": "4.4.0",
     "apollo-fetch": "0.7.0",
-    "apollo-server": "2.15.1",
-    "apollo-server-express": "2.15.1",
+    "apollo-server": "2.16.1",
+    "apollo-server-express": "2.16.1",
     "auto-load": "3.0.4",
-    "aws-sdk": "2.713.0",
+    "aws-sdk": "2.738.0",
     "azure-search-client": "3.1.5",
     "bcryptjs-then": "1.0.1",
     "bluebird": "3.7.2",
     "body-parser": "1.19.0",
     "chalk": "4.1.0",
     "cheerio": "1.0.0-rc.3",
-    "chokidar": "3.4.0",
+    "chokidar": "3.4.2",
     "chromium-pickle-js": "0.2.0",
     "clean-css": "4.2.3",
     "command-exists": "1.2.9",
@@ -69,7 +69,7 @@
     "custom-error-instance": "2.1.2",
     "dependency-graph": "0.9.0",
     "diff": "4.0.2",
-    "diff2html": "3.1.9",
+    "diff2html": "3.1.12",
     "dompurify": "2.0.12",
     "dotize": "0.3.0",
     "elasticsearch6": "npm:@elastic/elasticsearch@6",
@@ -79,7 +79,7 @@
     "express": "4.17.1",
     "express-brute": "1.0.1",
     "express-session": "1.17.1",
-    "file-type": "14.6.2",
+    "file-type": "14.7.1",
     "filesize": "6.1.0",
     "fs-extra": "9.0.1",
     "getos": "3.2.1",
@@ -87,22 +87,22 @@
     "graphql-list-fields": "2.0.2",
     "graphql-rate-limit-directive": "1.2.1",
     "graphql-subscriptions": "1.1.0",
-    "graphql-tools": "6.0.12",
+    "graphql-tools": "6.0.18",
     "he": "1.2.0",
-    "highlight.js": "10.1.1",
-    "i18next": "19.6.0",
+    "highlight.js": "10.1.2",
+    "i18next": "19.7.0",
     "i18next-express-middleware": "2.0.0",
     "i18next-node-fs-backend": "2.1.3",
     "image-size": "0.8.3",
-    "js-base64": "2.6.3",
+    "js-base64": "3.4.5",
     "js-binary": "1.2.0",
     "js-yaml": "3.14.0",
-    "jsdom": "16.3.0",
+    "jsdom": "16.4.0",
     "jsonwebtoken": "8.5.1",
-    "katex": "0.11.1",
+    "katex": "0.12.0",
     "klaw": "3.0.0",
-    "knex": "0.21.2",
-    "lodash": "4.17.19",
+    "knex": "0.21.5",
+    "lodash": "4.17.20",
     "luxon": "1.24.1",
     "markdown-it": "11.0.0",
     "markdown-it-abbr": "1.0.4",
@@ -122,16 +122,16 @@
     "mime-types": "2.1.27",
     "moment": "2.27.0",
     "moment-timezone": "0.5.31",
-    "mongodb": "3.5.9",
+    "mongodb": "3.6.0",
     "ms": "2.1.2",
-    "mssql": "6.2.0",
+    "mssql": "6.2.1",
     "multer": "1.4.2",
     "mysql2": "2.1.0",
-    "nanoid": "3.1.10",
+    "nanoid": "3.1.12",
     "node-2fa": "1.1.2",
     "node-cache": "5.1.2",
-    "nodemailer": "6.4.10",
-    "objection": "2.2.1",
+    "nodemailer": "6.4.11",
+    "objection": "2.2.3",
     "passport": "0.4.1",
     "passport-auth0": "1.3.3",
     "passport-azure-ad": "4.2.1",
@@ -149,13 +149,13 @@
     "passport-oauth2": "1.5.0",
     "passport-okta-oauth": "0.0.1",
     "passport-openidconnect": "0.0.2",
-    "passport-saml": "1.3.3",
+    "passport-saml": "1.3.4",
     "passport-twitch-oauth": "1.0.0",
     "pem-jwk": "2.0.0",
-    "pg": "8.3.0",
+    "pg": "8.3.2",
     "pg-hstore": "2.3.3",
     "pg-pubsub": "0.5.0",
-    "pg-query-stream": "3.2.0",
+    "pg-query-stream": "3.2.2",
     "pg-tsquery": "8.1.0",
     "pug": "3.0.0",
     "punycode": "2.1.1",
@@ -163,33 +163,33 @@
     "raven": "2.6.4",
     "remove-markdown": "0.3.0",
     "request": "2.88.2",
-    "request-promise": "4.2.5",
+    "request-promise": "4.2.6",
     "safe-regex": "2.1.1",
     "sanitize-filename": "1.6.3",
     "scim-query-filter-parser": "2.0.4",
     "semver": "7.3.2",
     "serve-favicon": "2.5.0",
-    "simple-git": "2.12.0",
+    "simple-git": "2.19.0",
     "solr-node": "1.2.1",
     "sqlite3": "5.0.0",
     "ssh2": "0.8.9",
     "ssh2-promise": "0.1.7",
     "striptags": "3.1.1",
-    "subscriptions-transport-ws": "0.9.17",
+    "subscriptions-transport-ws": "0.9.18",
     "tar-fs": "2.1.0",
-    "twemoji": "13.0.0",
+    "twemoji": "13.0.1",
     "uslug": "1.0.4",
-    "uuid": "8.2.0",
+    "uuid": "8.3.0",
     "validate.js": "0.13.1",
     "winston": "3.3.3",
-    "xss": "1.0.7",
+    "xss": "1.0.8",
     "yargs": "15.4.1"
   },
   "devDependencies": {
-    "@babel/cli": "^7.10.4",
-    "@babel/core": "^7.10.4",
+    "@babel/cli": "^7.10.5",
+    "@babel/core": "^7.11.4",
     "@babel/plugin-proposal-class-properties": "^7.10.4",
-    "@babel/plugin-proposal-decorators": "^7.10.4",
+    "@babel/plugin-proposal-decorators": "^7.10.5",
     "@babel/plugin-proposal-export-namespace-from": "^7.10.4",
     "@babel/plugin-proposal-function-sent": "^7.10.4",
     "@babel/plugin-proposal-json-strings": "^7.10.4",
@@ -198,11 +198,11 @@
     "@babel/plugin-syntax-dynamic-import": "^7.8.3",
     "@babel/plugin-syntax-import-meta": "^7.10.4",
     "@babel/polyfill": "^7.10.4",
-    "@babel/preset-env": "^7.10.4",
-    "@mdi/font": "5.3.45",
+    "@babel/preset-env": "^7.11.0",
+    "@mdi/font": "5.5.55",
     "@panter/vue-i18next": "0.15.2",
     "@requarks/ckeditor5": "19.0.1-wiki.2",
-    "@vue/babel-preset-app": "4.4.6",
+    "@vue/babel-preset-app": "4.5.4",
     "animate-sass": "0.8.2",
     "animated-number-vue": "1.0.0",
     "apollo-cache-inmemory": "1.6.6",
@@ -214,11 +214,11 @@
     "apollo-link-persisted-queries": "0.2.2",
     "apollo-link-ws": "1.0.20",
     "apollo-utilities": "1.3.4",
-    "autoprefixer": "9.8.5",
+    "autoprefixer": "9.8.6",
     "babel-eslint": "10.1.0",
-    "babel-jest": "26.1.0",
+    "babel-jest": "26.3.0",
     "babel-loader": "^8.1.0",
-    "babel-plugin-graphql-tag": "2.5.0",
+    "babel-plugin-graphql-tag": "3.0.0",
     "babel-plugin-lodash": "3.3.4",
     "babel-plugin-prismjs": "2.0.1",
     "babel-plugin-transform-imports": "2.0.0",
@@ -228,16 +228,16 @@
     "chart.js": "2.9.3",
     "clean-webpack-plugin": "3.0.0",
     "clipboard": "2.0.6",
-    "codemirror": "5.55.0",
+    "codemirror": "5.57.0",
     "copy-webpack-plugin": "6.0.3",
     "core-js": "3.6.5",
-    "css-loader": "3.6.0",
+    "css-loader": "4.2.1",
     "cssnano": "4.1.10",
-    "cypress": "4.10.0",
+    "cypress": "5.0.0",
     "d3": "5.16.0",
     "duplicate-package-checker-webpack-plugin": "3.0.0",
     "epic-spinners": "1.1.0",
-    "eslint": "7.4.0",
+    "eslint": "7.7.0",
     "eslint-config-requarks": "1.0.7",
     "eslint-config-standard": "14.1.1",
     "eslint-plugin-cypress": "2.11.1",
@@ -248,11 +248,11 @@
     "eslint-plugin-vue": "6.2.2",
     "fibers": "5.0.0",
     "file-loader": "6.0.0",
-    "filepond": "4.18.0",
+    "filepond": "4.19.2",
     "filepond-plugin-file-validate-type": "1.2.5",
     "filesize.js": "2.0.0",
     "graphql-persisted-document-loader": "2.0.0",
-    "graphql-tag": "^2.10.4",
+    "graphql-tag": "^2.11.0",
     "hammerjs": "2.0.8",
     "html-webpack-plugin": "4.3.0",
     "html-webpack-pug-plugin": "2.0.0",
@@ -260,11 +260,11 @@
     "i18next-localstorage-backend": "3.1.1",
     "i18next-xhr-backend": "3.2.2",
     "ignore-loader": "0.1.2",
-    "jest": "26.1.0",
-    "js-beautify": "1.11.0",
+    "jest": "26.4.2",
+    "js-beautify": "1.13.0",
     "js-cookie": "2.2.1",
-    "mermaid": "8.5.2",
-    "mini-css-extract-plugin": "0.9.0",
+    "mermaid": "8.7.0",
+    "mini-css-extract-plugin": "0.10.0",
     "moment-duration-format": "2.3.2",
     "moment-timezone-data-webpack-plugin": "1.3.0",
     "offline-plugin": "5.0.7",
@@ -277,52 +277,52 @@
     "postcss-loader": "3.0.0",
     "postcss-preset-env": "6.7.0",
     "postcss-selector-parser": "6.0.2",
-    "prismjs": "1.20.0",
+    "prismjs": "1.21.0",
     "pug-lint": "2.6.0",
     "pug-loader": "2.4.0",
     "pug-plain-loader": "1.0.0",
     "raw-loader": "4.0.1",
     "resolve-url-loader": "3.1.1",
     "sass": "1.26.10",
-    "sass-loader": "9.0.2",
-    "sass-resources-loader": "2.0.3",
+    "sass-loader": "9.0.3",
+    "sass-resources-loader": "2.1.0",
     "script-ext-html-webpack-plugin": "2.1.4",
     "simple-progress-webpack-plugin": "1.1.2",
     "style-loader": "1.2.1",
-    "terser": "4.8.0",
+    "terser": "5.2.1",
     "twemoji-awesome": "1.0.6",
     "url-loader": "4.1.0",
     "velocity-animate": "1.5.2",
     "viz.js": "2.1.2",
-    "vue": "2.6.11",
-    "vue-apollo": "3.0.3",
-    "vue-chartjs": "3.5.0",
+    "vue": "2.6.12",
+    "vue-apollo": "3.0.4",
+    "vue-chartjs": "3.5.1",
     "vue-clipboards": "1.3.0",
     "vue-filepond": "6.0.2",
     "vue-hot-reload-api": "2.3.4",
     "vue-loader": "15.9.3",
     "vue-moment": "4.1.0",
-    "vue-router": "3.3.4",
+    "vue-router": "3.4.3",
     "vue-status-indicator": "1.2.1",
-    "vue-template-compiler": "2.6.11",
+    "vue-template-compiler": "2.6.12",
     "vue2-animate": "2.1.3",
-    "vuedraggable": "2.24.0",
-    "vuescroll": "4.16.0",
-    "vuetify": "2.3.4",
+    "vuedraggable": "2.24.1",
+    "vuescroll": "4.16.1",
+    "vuetify": "2.3.9",
     "vuetify-loader": "1.6.0",
     "vuex": "3.5.1",
     "vuex-pathify": "1.4.1",
-    "vuex-persistedstate": "3.0.1",
-    "webpack": "4.43.0",
+    "vuex-persistedstate": "3.1.0",
+    "webpack": "4.44.1",
     "webpack-bundle-analyzer": "3.8.0",
     "webpack-cli": "3.3.12",
     "webpack-dev-middleware": "3.7.2",
     "webpack-hot-middleware": "2.25.0",
-    "webpack-merge": "5.0.9",
+    "webpack-merge": "5.1.2",
     "webpack-modernizr-loader": "5.0.0",
     "webpack-subresource-integrity": "1.4.1",
     "webpackbar": "4.0.0",
-    "whatwg-fetch": "3.2.0",
+    "whatwg-fetch": "3.4.0",
     "write-file-webpack-plugin": "4.5.1",
     "xterm": "4.8.1",
     "zxcvbn": "4.4.2"

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

@@ -44,6 +44,7 @@ type AuthenticationMutation {
   loginTFA(
     continuationToken: String!
     securityCode: String!
+    setup: Boolean
   ): AuthenticationLoginResponse @rateLimit(limit: 5, duration: 60)
 
   loginChangePassword(
@@ -108,8 +109,10 @@ type AuthenticationLoginResponse {
   jwt: String
   mustChangePwd: Boolean
   mustProvideTFA: Boolean
+  mustSetupTFA: Boolean
   continuationToken: String
   redirect: String
+  tfaQRImage: String
 }
 
 type AuthenticationRegisterResponse {

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

@@ -140,6 +140,7 @@ type User {
   createdAt: Date!
   updatedAt: Date!
   lastLoginAt: Date
+  tfaIsActive: Boolean!
   groups: [Group]!
 }
 

+ 1 - 1
server/models/assets.js

@@ -185,7 +185,7 @@ module.exports = class Asset extends Model {
   static async getAssetFromStorage(assetPath, res) {
     const localLocations = await WIKI.models.storage.getLocalLocations({
       asset: {
-        path: assetPath,
+        path: assetPath
       }
     })
     for (let location of _.filter(localLocations, location => Boolean(location.path))) {

+ 14 - 8
server/models/userKeys.js

@@ -1,8 +1,8 @@
 /* global WIKI */
 
 const Model = require('objection').Model
-const moment = require('moment')
-const nanoid = require('nanoid').nanoid
+const { DateTime } = require('luxon')
+const { nanoid } = require('nanoid')
 
 /**
  * Users model
@@ -41,25 +41,27 @@ module.exports = class UserKey extends Model {
   async $beforeInsert(context) {
     await super.$beforeInsert(context)
 
-    this.createdAt = moment.utc().toISOString()
+    this.createdAt = DateTime.utc().toISO()
   }
 
   static async generateToken ({ userId, kind }, context) {
-    const token = nanoid()
+    const token = await nanoid()
     await WIKI.models.userKeys.query().insert({
       kind,
       token,
-      validUntil: moment.utc().add(1, 'days').toISOString(),
+      validUntil: DateTime.utc().plus({ days: 1 }).toISO(),
       userId
     })
     return token
   }
 
-  static async validateToken ({ kind, token }, context) {
+  static async validateToken ({ kind, token, skipDelete }, context) {
     const res = await WIKI.models.userKeys.query().findOne({ kind, token }).withGraphJoined('user')
     if (res) {
-      await WIKI.models.userKeys.query().deleteById(res.id)
-      if (moment.utc().isAfter(moment.utc(res.validUntil))) {
+      if (skipDelete !== true) {
+        await WIKI.models.userKeys.query().deleteById(res.id)
+      }
+      if (DateTime.utc() > DateTime.fromISO(res.validUntil)) {
         throw new WIKI.Error.AuthValidationTokenInvalid()
       }
       return res.user
@@ -67,4 +69,8 @@ module.exports = class UserKey extends Model {
       throw new WIKI.Error.AuthValidationTokenInvalid()
     }
   }
+
+  static async destroyToken ({ token }) {
+    return WIKI.models.userKeys.query().findOne({ token }).delete()
+  }
 }

+ 106 - 72
server/models/users.js

@@ -6,6 +6,7 @@ const tfa = require('node-2fa')
 const jwt = require('jsonwebtoken')
 const Model = require('objection').Model
 const validate = require('validate.js')
+const qr = require('qr-image')
 
 const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/
 
@@ -118,14 +119,22 @@ module.exports = class User extends Model {
     }
   }
 
-  async enableTFA() {
+  async generateTFA() {
     let tfaInfo = tfa.generateSecret({
-      name: WIKI.config.site.title
+      name: WIKI.config.title,
+      account: this.email
     })
-    return this.$query.patch({
-      tfaIsActive: true,
+    await WIKI.models.users.query().findById(this.id).patch({
+      tfaIsActive: false,
       tfaSecret: tfaInfo.secret
     })
+    return qr.imageSync(`otpauth://totp/${WIKI.config.title}:${this.email}?secret=${tfaInfo.secret}`, { type: 'svg' })
+  }
+
+  async enableTFA() {
+    return WIKI.models.users.query().findById(this.id).patch({
+      tfaIsActive: true
+    })
   }
 
   async disableTFA() {
@@ -135,7 +144,7 @@ module.exports = class User extends Model {
     })
   }
 
-  async verifyTFA(code) {
+  verifyTFA(code) {
     let result = tfa.verifyToken(this.tfaSecret, code)
     return (result && _.has(result, 'delta') && result.delta === 0)
   }
@@ -281,55 +290,12 @@ module.exports = class User extends Model {
           if (err) { return reject(err) }
           if (!user) { return reject(new WIKI.Error.AuthLoginFailed()) }
 
-          // Get redirect target
-          user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions', 'redirectOnLogin')
-          let redirect = '/'
-          if (user.groups && user.groups.length > 0) {
-            redirect = user.groups[0].redirectOnLogin
+          try {
+            const resp = await WIKI.models.users.afterLoginChecks(user, context)
+            resolve(resp)
+          } catch (err) {
+            reject(err)
           }
-
-          // 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,
-                redirect
-              })
-            } catch (errc) {
-              WIKI.logger.warn(errc)
-              return reject(new WIKI.Error.AuthGenericError())
-            }
-          }
-
-          // Is 2FA required?
-          if (user.tfaIsActive) {
-            try {
-              const tfaToken = await WIKI.models.userKeys.generateToken({
-                kind: 'tfa',
-                userId: user.id
-              })
-              return resolve({
-                tfaRequired: true,
-                continuationToken: tfaToken,
-                redirect
-              })
-            } catch (errc) {
-              WIKI.logger.warn(errc)
-              return reject(new WIKI.Error.AuthGenericError())
-            }
-          }
-
-          context.req.logIn(user, { session: !strInfo.useForm }, async errc => {
-            if (errc) { return reject(errc) }
-            const jwtToken = await WIKI.models.users.refreshToken(user)
-            resolve({ jwt: jwtToken.token, redirect })
-          })
         })(context.req, context.res, () => {})
       })
     } else {
@@ -337,6 +303,79 @@ module.exports = class User extends Model {
     }
   }
 
+  static async afterLoginChecks (user, context, { skipTFA, skipChangePwd } = { skipTFA: false, skipChangePwd: false }) {
+    // Get redirect target
+    user.groups = await user.$relatedQuery('groups').select('groups.id', 'permissions', 'redirectOnLogin')
+    let redirect = '/'
+    if (user.groups && user.groups.length > 0) {
+      redirect = user.groups[0].redirectOnLogin
+    }
+
+    // Is 2FA required?
+    if (!skipTFA) {
+      if (user.tfaIsActive && user.tfaSecret) {
+        try {
+          const tfaToken = await WIKI.models.userKeys.generateToken({
+            kind: 'tfa',
+            userId: user.id
+          })
+          return {
+            mustProvideTFA: true,
+            continuationToken: tfaToken,
+            redirect
+          }
+        } catch (errc) {
+          WIKI.logger.warn(errc)
+          throw new WIKI.Error.AuthGenericError()
+        }
+      } else if (WIKI.config.auth.enforce2FA || (user.tfaIsActive && !user.tfaSecret)) {
+        try {
+          const tfaQRImage = await user.generateTFA()
+          const tfaToken = await WIKI.models.userKeys.generateToken({
+            kind: 'tfaSetup',
+            userId: user.id
+          })
+          return {
+            mustSetupTFA: true,
+            continuationToken: tfaToken,
+            tfaQRImage,
+            redirect
+          }
+        } catch (errc) {
+          WIKI.logger.warn(errc)
+          throw new WIKI.Error.AuthGenericError()
+        }
+      }
+    }
+
+    // Must Change Password?
+    if (!skipChangePwd && user.mustChangePwd) {
+      try {
+        const pwdChangeToken = await WIKI.models.userKeys.generateToken({
+          kind: 'changePwd',
+          userId: user.id
+        })
+
+        return {
+          mustChangePwd: true,
+          continuationToken: pwdChangeToken,
+          redirect
+        }
+      } catch (errc) {
+        WIKI.logger.warn(errc)
+        throw new WIKI.Error.AuthGenericError()
+      }
+    }
+
+    return new Promise((resolve, reject) => {
+      context.req.login(user, { session: false }, async errc => {
+        if (errc) { return reject(errc) }
+        const jwtToken = await WIKI.models.users.refreshToken(user)
+        resolve({ jwt: jwtToken.token, redirect })
+      })
+    })
+  }
+
   static async refreshToken(user) {
     if (_.isSafeInteger(user)) {
       user = await WIKI.models.users.query().findById(user).withGraphFetched('groups').modifyGraph('groups', builder => {
@@ -384,26 +423,21 @@ module.exports = class User extends Model {
     }
   }
 
-  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) {
-        let userId = _.toSafeInteger(result)
-        if (userId && userId > 0) {
-          let user = await WIKI.models.users.query().findById(userId)
-          if (user && user.verifyTFA(opts.securityCode)) {
-            return Promise.fromCallback(clb => {
-              context.req.logIn(user, clb)
-            }).return({
-              succeeded: true,
-              message: 'Login Successful'
-            }).catch(err => {
-              WIKI.logger.warn(err)
-              throw new WIKI.Error.AuthGenericError()
-            })
-          } else {
-            throw new WIKI.Error.AuthTFAFailed()
+  static async loginTFA ({ securityCode, continuationToken, setup }, context) {
+    if (securityCode.length === 6 && continuationToken.length > 1) {
+      const user = await WIKI.models.userKeys.validateToken({
+        kind: setup ? 'tfaSetup' : 'tfa',
+        token: continuationToken,
+        skipDelete: setup
+      })
+      if (user) {
+        if (user.verifyTFA(securityCode)) {
+          if (setup) {
+            await user.enableTFA()
           }
+          return WIKI.models.users.afterLoginChecks(user, context, { skipTFA: true })
+        } else {
+          throw new WIKI.Error.AuthTFAFailed()
         }
       }
     }

+ 1 - 1
server/setup.js

@@ -442,7 +442,7 @@ module.exports = () => {
     WIKI.logger.info('HTTP Server: [ RUNNING ]')
     WIKI.logger.info('🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻🔻')
     WIKI.logger.info('')
-    WIKI.logger.info(`Browse to http://localhost:${WIKI.config.port}/ to complete setup!`)
+    WIKI.logger.info(`Browse to http://YOUR-SERVER-IP:${WIKI.config.port}/ to complete setup!`)
     WIKI.logger.info('')
     WIKI.logger.info('🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺🔺')
   })

File diff suppressed because it is too large
+ 274 - 387
yarn.lock


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