Bladeren bron

feat: new login experience (#2139)

* feat: multiple auth instances

* fix: auth setup + strategy initialization

* feat: admin auth - add strategy

* feat: redirect on login - group setting

* feat: oauth2 generic - props definitions

* feat: new login UI (wip)

* feat: new login UI (wip)

* feat: admin security login settings

* feat: tabset editor indicators + print view improvements

* fix: code styling
Nicolas Giard 4 jaren geleden
bovenliggende
commit
c009cc1392
46 gewijzigde bestanden met toevoegingen van 1355 en 700 verwijderingen
  1. 7 0
      client/.modernizrrc.js
  2. 1 1
      client/components/admin.vue
  3. 313 212
      client/components/admin/admin-auth.vue
  4. 9 17
      client/components/admin/admin-groups-edit-permissions.vue
  5. 1 1
      client/components/admin/admin-groups-edit-rules.vue
  6. 128 10
      client/components/admin/admin-groups-edit.vue
  7. 3 1
      client/components/admin/admin-groups.vue
  8. 123 20
      client/components/admin/admin-security.vue
  9. 4 8
      client/components/admin/admin-users-create.vue
  10. 5 9
      client/components/admin/admin-users.vue
  11. 4 4
      client/components/common/notify.vue
  12. 40 0
      client/components/editor/editor-markdown.vue
  13. 16 0
      client/components/editor/markdown/tabset.js
  14. 343 237
      client/components/login.vue
  15. 0 12
      client/graph/admin/auth/auth-mutation-save-strategies.gql
  16. 0 12
      client/graph/admin/groups/groups-mutation-delete.gql
  17. 0 12
      client/graph/admin/groups/groups-mutation-update.gql
  18. 0 25
      client/graph/admin/groups/groups-query-single.gql
  19. 2 0
      client/index-app.js
  20. 3 0
      client/libs/modernizr/modernizr.js
  21. BIN
      client/static/img/splash/1.jpg
  22. BIN
      client/static/img/splash/2.jpg
  23. 2 1
      client/store/site.js
  24. 16 7
      client/themes/default/components/page.vue
  25. 4 0
      client/themes/default/scss/app.scss
  26. 6 1
      dev/webpack/webpack.dev.js
  27. 2 0
      package.json
  28. 6 0
      server/app/data.yml
  29. 3 4
      server/core/auth.js
  30. 23 0
      server/db/migrations-sqlite/2.5.1.js
  31. 8 0
      server/db/migrations-sqlite/2.5.12.js
  32. 23 0
      server/db/migrations/2.5.1.js
  33. 8 0
      server/db/migrations/2.5.12.js
  34. 52 14
      server/graph/resolvers/authentication.js
  35. 5 0
      server/graph/resolvers/group.js
  36. 13 14
      server/graph/resolvers/page.js
  37. 14 1
      server/graph/resolvers/site.js
  38. 13 13
      server/graph/schemas/authentication.graphql
  39. 2 0
      server/graph/schemas/group.graphql
  40. 10 0
      server/graph/schemas/site.graphql
  41. 25 48
      server/models/authentication.js
  42. 2 0
      server/models/groups.js
  43. 1 1
      server/modules/authentication/local/definition.yml
  44. 49 4
      server/modules/authentication/oauth2/definition.yml
  45. 11 3
      server/setup.js
  46. 55 8
      yarn.lock

+ 7 - 0
client/.modernizrrc.js

@@ -0,0 +1,7 @@
+module.exports = {
+  classPrefix: 'mdz-',
+  options: ['setClasses'],
+  'feature-detects': [
+    'css/backdropfilter'
+  ]
+}

+ 1 - 1
client/components/admin.vue

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

+ 313 - 212
client/components/admin/admin-auth.vue

@@ -18,195 +18,188 @@
 
       v-flex(lg3, xs12)
         v-card.animated.fadeInUp
-          v-toolbar(flat, color='primary', dark, dense)
-            .subtitle-1 {{$t('admin:auth.strategies')}}
+          v-toolbar(flat, color='teal', dark, dense)
+            .subtitle-1 {{$t('admin:auth.activeStrategies')}}
           v-list(two-line, dense).py-0
-            template(v-for='(str, idx) in strategies')
-              v-list-item(:key='str.key', @click='selectedStrategy = str.key', :disabled='!str.isAvailable')
-                v-list-item-avatar(size='24')
-                  v-icon(color='grey', v-if='!str.isAvailable') mdi-minus-box-outline
-                  v-icon(color='primary', v-else-if='str.isEnabled && str.key !== `local`', v-ripple, @click='str.isEnabled = false') mdi-checkbox-marked-outline
-                  v-icon(color='primary', v-else-if='str.isEnabled && str.key === `local`') mdi-checkbox-marked-outline
-                  v-icon(color='grey', v-else, v-ripple, @click='str.isEnabled = true') mdi-checkbox-blank-outline
-                v-list-item-content
-                  v-list-item-title.body-2(:class='!str.isAvailable ? `grey--text` : (selectedStrategy === str.key ? `primary--text` : ``)') {{ str.title }}
-                  v-list-item-subtitle: .caption(:class='!str.isAvailable ? `grey--text text--lighten-1` : (selectedStrategy === str.key ? `blue--text ` : ``)') {{ str.description }}
-                v-list-item-avatar(v-if='selectedStrategy === str.key', size='24')
-                  v-icon.animated.fadeInLeft(color='primary', large) mdi-chevron-right
-              v-divider(v-if='idx < strategies.length - 1')
-
-        v-card.mt-3.animated.fadeInUp.wait-p2s
-          v-toolbar(flat, color='primary', dark, dense)
-            .subtitle-1 {{$t('admin:auth.globalAdvSettings')}}
-          v-card-text
-            v-text-field.md2(
-              v-model='jwtAudience'
-              outlined
-              prepend-icon='mdi-account-group-outline'
-              :label='$t(`admin:auth.jwtAudience`)'
-              :hint='$t(`admin:auth.jwtAudienceHint`)'
-              persistent-hint
-            )
-            v-text-field.mt-3.md2(
-              v-model='jwtExpiration'
-              outlined
-              prepend-icon='mdi-clock-outline'
-              :label='$t(`admin:auth.tokenExpiration`)'
-              :hint='$t(`admin:auth.tokenExpirationHint`)'
-              persistent-hint
-            )
-            v-text-field.mt-3.md2(
-              v-model='jwtRenewablePeriod'
-              outlined
-              prepend-icon='mdi-update'
-              :label='$t(`admin:auth.tokenRenewalPeriod`)'
-              :hint='$t(`admin:auth.tokenRenewalPeriodHint`)'
-              persistent-hint
-            )
+            draggable(
+              v-model='activeStrategies'
+              handle='.is-handle'
+              direction='vertical'
+              :store='order'
+              )
+              transition-group
+                v-list-item(
+                  v-for='(str, idx) in activeStrategies'
+                  :key='str.key'
+                  @click='selectedStrategy = str.key'
+                  :class='selectedStrategy === str.key ? ($vuetify.theme.dark ? `grey darken-5` : `teal lighten-5`) : ``'
+                  )
+                  v-list-item-avatar.is-handle(size='24')
+                    v-icon(:color='selectedStrategy === str.key ? `teal` : `grey`') mdi-drag-horizontal
+                  v-list-item-content
+                    v-list-item-title.body-2(:class='selectedStrategy === str.key ? `teal--text` : ``') {{ str.displayName }}
+                    v-list-item-subtitle: .caption(:class='selectedStrategy === str.key ? `teal--text ` : ``') {{ str.strategy.title }}
+                  v-list-item-avatar(v-if='selectedStrategy === str.key', size='24')
+                    v-icon.animated.fadeInLeft(color='teal', large) mdi-chevron-right
+          v-card-chin
+            v-menu(offset-y, bottom, min-width='250px', max-width='550px', max-height='50vh', style='flex: 1 1;', center)
+              template(v-slot:activator='{ on }')
+                v-btn(v-on='on', color='primary', depressed, block)
+                  v-icon(left) mdi-plus
+                  span {{$t('admin:auth.addStrategy')}}
+              v-list(dense)
+                template(v-for='(str, idx) of strategies')
+                  v-list-item(
+                    :key='str.key'
+                    :disabled='str.isDisabled'
+                    @click='addStrategy(str)'
+                    )
+                    v-list-item-avatar(height='24', width='48', tile)
+                      v-img(:src='str.logo', width='48px', height='24px', contain, :style='str.isDisabled ? `opacity: .25;` : ``')
+                    v-list-item-content
+                      v-list-item-title {{str.title}}
+                      v-list-item-subtitle: .caption(:style='str.isDisabled ? `opacity: .4;` : ``') {{str.description}}
+                  v-divider(v-if='idx < strategies.length - 1')
 
       v-flex(xs12, lg9)
         v-card.animated.fadeInUp.wait-p2s
           v-toolbar(color='primary', dense, flat, dark)
-            .subtitle-1 {{strategy.title}}
+            .subtitle-1 {{strategy.displayName}} #[em ({{strategy.strategy.title}})]
             v-spacer
-            v-switch(
-              dark
-              color='blue lighten-5'
-              label='Active'
-              v-model='strategy.isEnabled'
-              hide-details
-              inset
-              :disabled='strategy.key === `local`'
-              )
+            v-btn(small, outlined, dark, color='white', :disabled='strategy.key === `local`', @click='deleteStrategy()')
+              v-icon(left) mdi-close
+              span {{$t('common:actions.delete')}}
+          v-card-info(color='blue')
+            div
+              span {{strategy.strategy.description}}
+              .caption: a(:href='strategy.strategy.website') {{strategy.strategy.website}}
+            v-spacer
+            .authlogo
+              img(:src='strategy.strategy.logo', :alt='strategy.strategy.title')
           v-card-text
-            v-form
-              .authlogo
-                img(:src='strategy.logo', :alt='strategy.title')
-              .body-2.pt-3 {{strategy.description}}
-              .body-2.pt-3.pb-5: a(:href='strategy.website') {{strategy.website}}
-              i18next.body-2(path='admin:auth.strategyState', tag='div', v-if='strategy.isEnabled')
-                v-chip(color='green', small, dark, label, place='state') {{$t('admin:auth.strategyStateActive')}}
-                span(v-if='selectedStrategy === `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
-              .overline.my-5 {{$t('admin:auth.strategyConfiguration')}}
-              .body-2.ml-3(v-if='!strategy.config || strategy.config.length < 1'): em {{$t('admin:auth.strategyNoConfiguration')}}
-              template(v-else, v-for='cfg in strategy.config')
-                v-select.mb-3(
-                  v-if='cfg.value.type === "string" && cfg.value.enum'
-                  outlined
-                  :items='cfg.value.enum'
-                  :key='cfg.key'
-                  :label='cfg.value.title'
-                  v-model='cfg.value.value'
-                  prepend-icon='mdi-cog-box'
-                  :hint='cfg.value.hint ? cfg.value.hint : ""'
-                  persistent-hint
-                  :class='cfg.value.hint ? "mb-2" : ""'
-                  :style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
+            .overline.mb-5 {{$t('admin:auth.strategyConfiguration')}}
+            v-text-field.mb-3(
+              outlined
+              label='Display Name'
+              v-model='strategy.displayName'
+              prepend-icon='mdi-format-title'
+              hint='The title shown to the end user for this authentication strategy.'
+              persistent-hint
+              )
+            template(v-for='cfg in strategy.config')
+              v-select.mb-3(
+                v-if='cfg.value.type === "string" && cfg.value.enum'
+                outlined
+                :items='cfg.value.enum'
+                :key='cfg.key'
+                :label='cfg.value.title'
+                v-model='cfg.value.value'
+                prepend-icon='mdi-cog-box'
+                :hint='cfg.value.hint ? cfg.value.hint : ""'
+                persistent-hint
+                :class='cfg.value.hint ? "mb-2" : ""'
+                :style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
+              )
+              v-switch.mb-6(
+                v-else-if='cfg.value.type === "boolean"'
+                :key='cfg.key'
+                :label='cfg.value.title'
+                v-model='cfg.value.value'
+                color='primary'
+                prepend-icon='mdi-cog-box'
+                :hint='cfg.value.hint ? cfg.value.hint : ""'
+                persistent-hint
+                inset
                 )
-                v-switch.mb-6(
-                  v-else-if='cfg.value.type === "boolean"'
-                  :key='cfg.key'
-                  :label='cfg.value.title'
-                  v-model='cfg.value.value'
-                  color='primary'
-                  prepend-icon='mdi-cog-box'
-                  :hint='cfg.value.hint ? cfg.value.hint : ""'
-                  persistent-hint
-                  inset
-                  )
-                v-textarea.mb-3(
-                  v-else-if='cfg.value.type === "string" && cfg.value.multiline'
-                  outlined
-                  :key='cfg.key'
-                  :label='cfg.value.title'
-                  v-model='cfg.value.value'
-                  prepend-icon='mdi-cog-box'
-                  :hint='cfg.value.hint ? cfg.value.hint : ""'
-                  persistent-hint
-                  :class='cfg.value.hint ? "mb-2" : ""'
-                  )
-                v-text-field.mb-3(
-                  v-else
-                  outlined
-                  :key='cfg.key'
-                  :label='cfg.value.title'
-                  v-model='cfg.value.value'
-                  prepend-icon='mdi-cog-box'
-                  :hint='cfg.value.hint ? cfg.value.hint : ""'
-                  persistent-hint
-                  :class='cfg.value.hint ? "mb-2" : ""'
-                  :style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
-                  )
-              v-divider.mt-3
-              .overline.my-5 {{$t('admin:auth.registration')}}
-              .pr-3
-                v-switch.ml-3(
-                  v-model='strategy.selfRegistration'
-                  :label='$t(`admin:auth.selfRegistration`)'
-                  color='primary'
-                  :hint='$t(`admin:auth.selfRegistrationHint`)'
-                  persistent-hint
-                  inset
+              v-textarea.mb-3(
+                v-else-if='cfg.value.type === "string" && cfg.value.multiline'
+                outlined
+                :key='cfg.key'
+                :label='cfg.value.title'
+                v-model='cfg.value.value'
+                prepend-icon='mdi-cog-box'
+                :hint='cfg.value.hint ? cfg.value.hint : ""'
+                persistent-hint
+                :class='cfg.value.hint ? "mb-2" : ""'
                 )
-                v-combobox.ml-3.mt-3(
-                  :label='$t(`admin:auth.domainsWhitelist`)'
-                  v-model='strategy.domainWhitelist'
-                  prepend-icon='mdi-email-check-outline'
-                  outlined
-                  :disabled='!strategy.selfRegistration'
-                  :hint='$t(`admin:auth.domainsWhitelistHint`)'
-                  persistent-hint
-                  small-chips
-                  deletable-chips
-                  clearable
-                  multiple
-                  chips
-                  )
-                v-autocomplete.mt-3.ml-3(
-                  outlined
-                  :disabled='!strategy.selfRegistration'
-                  :items='groups'
-                  item-text='name'
-                  item-value='id'
-                  :label='$t(`admin:auth.autoEnrollGroups`)'
-                  v-model='strategy.autoEnrollGroups'
-                  prepend-icon='mdi-account-group'
-                  :hint='$t(`admin:auth.autoEnrollGroupsHint`)'
-                  small-chips
-                  persistent-hint
-                  deletable-chips
-                  clearable
-                  multiple
-                  chips
-                  )
-              template(v-if='strategy.useForm')
-                v-divider.mt-3
-                .d-flex.my-5.align-center
-                  .overline {{$t('admin:auth.security')}}
-                  v-chip.ml-3.grey--text(outlined, small, label) Coming soon
-                v-switch.ml-3(
-                  v-if='strategy.key === `local`'
-                  :disabled='!strategy.selfRegistration || true'
-                  v-model='strategy.recaptcha'
-                  label='Use reCAPTCHA by Google'
-                  color='primary'
-                  hint='Protects against spam robots and malicious registrations.'
-                  persistent-hint
-                  inset
+              v-text-field.mb-3(
+                v-else
+                outlined
+                :key='cfg.key'
+                :label='cfg.value.title'
+                v-model='cfg.value.value'
+                prepend-icon='mdi-cog-box'
+                :hint='cfg.value.hint ? cfg.value.hint : ""'
+                persistent-hint
+                :class='cfg.value.hint ? "mb-2" : ""'
+                :style='cfg.value.maxWidth > 0 ? `max-width:` + cfg.value.maxWidth + `px;` : ``'
                 )
-                v-switch.ml-3(
-                  v-model='strategy.recaptcha'
-                  :disabled='true'
-                  :label='$t(`admin:auth.force2fa`)'
-                  color='primary'
-                  :hint='$t(`admin:auth.force2faHint`)'
-                  persistent-hint
-                  inset
+            v-divider.mt-3
+            .overline.my-5 {{$t('admin:auth.registration')}}
+            .pr-3
+              v-switch.ml-3(
+                v-model='strategy.selfRegistration'
+                :label='$t(`admin:auth.selfRegistration`)'
+                color='primary'
+                :hint='$t(`admin:auth.selfRegistrationHint`)'
+                persistent-hint
+                inset
+              )
+              v-combobox.ml-3.mt-3(
+                :label='$t(`admin:auth.domainsWhitelist`)'
+                v-model='strategy.domainWhitelist'
+                prepend-icon='mdi-email-check-outline'
+                outlined
+                :disabled='!strategy.selfRegistration'
+                :hint='$t(`admin:auth.domainsWhitelistHint`)'
+                persistent-hint
+                small-chips
+                deletable-chips
+                clearable
+                multiple
+                chips
                 )
+              v-autocomplete.mt-3.ml-3(
+                outlined
+                :disabled='!strategy.selfRegistration'
+                :items='groups'
+                item-text='name'
+                item-value='id'
+                :label='$t(`admin:auth.autoEnrollGroups`)'
+                v-model='strategy.autoEnrollGroups'
+                prepend-icon='mdi-account-group'
+                :hint='$t(`admin:auth.autoEnrollGroupsHint`)'
+                small-chips
+                persistent-hint
+                deletable-chips
+                clearable
+                multiple
+                chips
+                )
+            template(v-if='strategy.useForm')
+              v-divider.mt-3
+              .d-flex.my-5.align-center
+                .overline {{$t('admin:auth.security')}}
+                v-chip.ml-3.grey--text(outlined, small, label) Coming soon
+              v-switch.ml-3(
+                v-if='strategy.key === `local`'
+                :disabled='!strategy.selfRegistration || true'
+                v-model='strategy.recaptcha'
+                label='Use reCAPTCHA by Google'
+                color='primary'
+                hint='Protects against spam robots and malicious registrations.'
+                persistent-hint
+                inset
+              )
+              v-switch.ml-3(
+                v-model='strategy.recaptcha'
+                :disabled='true'
+                :label='$t(`admin:auth.force2fa`)'
+                color='primary'
+                :hint='$t(`admin:auth.force2faHint`)'
+                persistent-hint
+                inset
+              )
 
         v-card.mt-4.wiki-form.animated.fadeInUp.wait-p4s(v-if='selectedStrategy !== `local`')
           v-toolbar(color='primary', dense, flat, dark)
@@ -236,13 +229,18 @@
 
 <script>
 import _ from 'lodash'
+import gql from 'graphql-tag'
+import { v4 as uuid } from 'uuid'
 
 import groupsQuery from 'gql/admin/auth/auth-query-groups.gql'
-import strategiesQuery from 'gql/admin/auth/auth-query-strategies.gql'
-import strategiesSaveMutation from 'gql/admin/auth/auth-mutation-save-strategies.gql'
 import hostQuery from 'gql/admin/auth/auth-query-host.gql'
 
+import draggable from 'vuedraggable'
+
 export default {
+  components: {
+    draggable
+  },
   filters: {
     startCase(val) { return _.startCase(val) }
   },
@@ -250,62 +248,107 @@ export default {
     return {
       groups: [],
       strategies: [],
+      activeStrategies: [],
       selectedStrategy: '',
       host: '',
-      strategy: {},
-      jwtAudience: 'urn:wiki.js',
-      jwtExpiration: '30m',
-      jwtRenewablePeriod: '14d'
+      strategy: {
+        strategy: {}
+      }
     }
   },
   computed: {
-    activeStrategies() {
-      return _.filter(this.strategies, 'isEnabled')
+    order: {
+      get () {
+        return this.strategies
+      },
+      set (val) {
+
+      }
     }
   },
   watch: {
     selectedStrategy(newValue, oldValue) {
-      this.strategy = _.find(this.strategies, ['key', newValue]) || {}
+      this.strategy = _.find(this.activeStrategies, ['key', newValue]) || {}
     },
-    strategies(newValue, oldValue) {
+    activeStrategies(newValue, oldValue) {
       this.selectedStrategy = 'local'
     }
   },
   methods: {
     async refresh() {
       await this.$apollo.queries.strategies.refetch()
+      await this.$apollo.queries.activeStrategies.refetch()
       this.$store.commit('showNotification', {
         message: this.$t('admin:auth.refreshSuccess'),
         style: 'success',
         icon: 'cached'
       })
     },
+    addStrategy (str) {
+      const newStr = {
+        key: uuid(),
+        strategy: str,
+        config: str.props.map(c => ({
+          key: c.key,
+          value: {
+            ...c,
+            value: c.default
+          }
+        })),
+        order: this.activeStrategies.length,
+        displayName: str.title,
+        selfRegistration: false,
+        domainWhitelist: [],
+        autoEnrollGroups: []
+      }
+      this.activeStrategies = [...this.activeStrategies, newStr]
+      this.$nextTick(() => {
+        this.selectedStrategy = newStr.key
+      })
+    },
+    deleteStrategy () {
+      this.activeStrategies = _.reject(this.activeStrategies, ['key', this.strategy.key])
+    },
     async save() {
       this.$store.commit(`loadingStart`, 'admin-auth-savestrategies')
       try {
-        await this.$apollo.mutate({
-          mutation: strategiesSaveMutation,
+        const resp = await this.$apollo.mutate({
+          mutation: gql`
+            mutation($strategies: [AuthenticationStrategyInput]!) {
+              authentication {
+                updateStrategies(strategies: $strategies) {
+                  responseResult {
+                    succeeded
+                    errorCode
+                    slug
+                    message
+                  }
+                }
+              }
+            }
+          `,
           variables: {
-            config: {
-              audience: this.jwtAudience,
-              tokenExpiration: this.jwtExpiration,
-              tokenRenewal: this.jwtRenewablePeriod
-            },
-            strategies: this.strategies.map(str => _.pick(str, [
-              'isEnabled',
-              'key',
-              'config',
-              'selfRegistration',
-              'domainWhitelist',
-              'autoEnrollGroups'
-            ])).map(str => ({...str, config: str.config.map(cfg => ({...cfg, value: JSON.stringify({ v: cfg.value.value })}))}))
+            strategies: this.activeStrategies.map(str => ({
+              key: str.key,
+              strategyKey: str.strategy.key,
+              displayName: str.displayName,
+              order: str.order,
+              config: str.config.map(cfg => ({...cfg, value: JSON.stringify({ v: cfg.value.value })})),
+              selfRegistration: str.selfRegistration,
+              domainWhitelist: str.domainWhitelist,
+              autoEnrollGroups: str.autoEnrollGroups
+            }))
           }
         })
-        this.$store.commit('showNotification', {
-          message: this.$t('admin:auth.saveSuccess'),
-          style: 'success',
-          icon: 'check'
-        })
+        if (_.get(resp, 'data.authentication.updateStrategies.responseResult.succeeded', false)) {
+          this.$store.commit('showNotification', {
+            message: this.$t('admin:auth.saveSuccess'),
+            style: 'success',
+            icon: 'check'
+          })
+        } else {
+          throw new Error(_.get(resp, 'data.authentication.updateStrategies.responseResult.message', this.$t('common:error.unexpected')))
+        }
       } catch (err) {
         this.$store.commit('pushGraphError', err)
       }
@@ -314,9 +357,67 @@ export default {
   },
   apollo: {
     strategies: {
-      query: strategiesQuery,
+      query: gql`
+        query {
+          authentication {
+            strategies {
+              key
+              title
+              description
+              isAvailable
+              useForm
+              logo
+              website
+              props {
+                key
+                value
+              }
+            }
+          }
+        }
+      `,
+      fetchPolicy: 'network-only',
+      update: (data) => _.get(data, 'authentication.strategies', []).map(str => ({
+        ...str,
+        isDisabled: !str.isAvailable || str.key === `local`,
+        props: _.sortBy(str.props.map(cfg => ({
+          key: cfg.key,
+          ...JSON.parse(cfg.value)
+        })), [t => t.order])
+      })),
+      watchLoading (isLoading) {
+        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-strategies-refresh')
+      }
+    },
+    activeStrategies: {
+      query: gql`
+        query {
+          authentication {
+            activeStrategies {
+              key
+              strategy {
+                key
+                title
+                description
+                useForm
+                logo
+                website
+              }
+              config {
+                key
+                value
+              }
+              order
+              displayName
+              selfRegistration
+              domainWhitelist
+              autoEnrollGroups
+            }
+          }
+        }
+      `,
       fetchPolicy: 'network-only',
-      update: (data) => _.cloneDeep(data.authentication.strategies).map(str => ({
+      update: (data) => _.get(data, 'authentication.activeStrategies', []).map(str => ({
         ...str,
         config: _.sortBy(str.config.map(cfg => ({
           ...cfg,
@@ -324,7 +425,7 @@ export default {
         })), [t => t.value.order])
       })),
       watchLoading (isLoading) {
-        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-refresh')
+        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-activestrategies-refresh')
       }
     },
     groups: {
@@ -351,7 +452,7 @@ export default {
 
 .authlogo {
   width: 250px;
-  height: 85px;
+  height: 60px;
   float:right;
   display: flex;
   justify-content: flex-end;

+ 9 - 17
client/components/admin/admin-groups-edit-permissions.vue

@@ -1,23 +1,15 @@
 <template lang="pug">
   v-card(flat)
-    v-card-text
-      v-text-field(
-        outlined
-        v-model='group.name'
-        label='Group Name'
-        counter='255'
-        prepend-icon='mdi-account-group'
-        )
-      v-alert.radius-7(
-        v-if='group.isSystem'
-        color='orange darken-2'
-        :class='$vuetify.theme.dark ? "grey darken-4" : "orange lighten-5"'
-        outlined
-        :value='true'
-        icon='mdi-lock-outline'
-        ) This is a system group. Some permissions cannot be modified.
-    v-container.px-3.pb-3.pt-0(fluid, grid-list-md)
+    v-container.px-3.pb-3.pt-3(fluid, grid-list-md)
       v-layout(row, wrap)
+        v-flex(xs12, v-if='group.isSystem')
+          v-alert.radius-7.mb-0(
+            color='orange darken-2'
+            :class='$vuetify.theme.dark ? "grey darken-4" : "orange lighten-5"'
+            outlined
+            :value='true'
+            icon='mdi-lock-outline'
+            ) This is a system group. Some permissions cannot be modified.
         v-flex(xs12, md6, lg4, v-for='pmGroup in permissions', :key='pmGroup.category')
           v-card.md2(flat, :class='$vuetify.theme.dark ? "grey darken-3-d5" : "grey lighten-5"')
             .overline.px-5.pt-5.pb-3.grey--text.text--darken-2 {{pmGroup.category}}

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

@@ -1,7 +1,7 @@
 <template lang="pug">
   v-card(flat)
     v-card-text(v-if='group.id === 1')
-      v-alert.radius-7(
+      v-alert.radius-7.mb-0(
         :class='$vuetify.theme.dark ? "grey darken-4" : "orange lighten-5"'
         color='orange darken-2'
         outlined

+ 128 - 10
client/components/admin/admin-groups-edit.vue

@@ -12,7 +12,7 @@
             v-icon mdi-arrow-left
           v-dialog(v-model='deleteGroupDialog', max-width='500', v-if='!group.isSystem')
             template(v-slot:activator='{ on }')
-              v-btn.ml-2(color='red', icon, outlined, v-on='on')
+              v-btn.ml-3(color='red', icon, outlined, v-on='on')
                 v-icon(color='red') mdi-trash-can-outline
             v-card
               .dialog-header.is-red Delete Group?
@@ -21,11 +21,14 @@
                 v-spacer
                 v-btn(text, @click='deleteGroupDialog = false') Cancel
                 v-btn(color='red', dark, @click='deleteGroup') Delete
-          v-btn.ml-2(color='success', large, depressed, @click='updateGroup')
+          v-btn.ml-3(color='success', large, depressed, @click='updateGroup')
             v-icon(left) mdi-check
             span Update Group
         v-card.mt-3
           v-tabs.grad-tabs(v-model='tab', :color='$vuetify.theme.dark ? `blue` : `primary`', fixed-tabs, show-arrows, icons-and-text)
+            v-tab(key='settings')
+              span Settings
+              v-icon mdi-cog-box
             v-tab(key='permissions')
               span Permissions
               v-icon mdi-lock-pattern
@@ -36,6 +39,44 @@
               span Users
               v-icon mdi-account-group
 
+            v-tab-item(key='settings', :transition='false', :reverse-transition='false')
+              v-card(flat)
+                template(v-if='group.id <= 2')
+                  v-card-text
+                    v-alert.radius-7.mb-0(
+                      color='orange darken-2'
+                      :class='$vuetify.theme.dark ? "grey darken-4" : "orange lighten-5"'
+                      outlined
+                      :value='true'
+                      icon='mdi-lock-outline'
+                      ) This is a system group and its settings cannot be modified.
+                  v-divider
+                v-card-text
+                  v-text-field(
+                    outlined
+                    v-model='group.name'
+                    label='Group Name'
+                    hide-details
+                    prepend-icon='mdi-account-group'
+                    style='max-width: 600px;'
+                    :disabled='group.id <= 2'
+                  )
+                template(v-if='group.id > 2')
+                  v-divider
+                  v-card-text
+                    v-text-field(
+                      outlined
+                      v-model='group.redirectOnLogin'
+                      label='Redirect on Login'
+                      persistent-hint
+                      hint='The path / URL where the user will be redirected upon successful login.'
+                      prepend-icon='mdi-arrow-top-left-thick'
+                      append-icon='mdi-folder-search'
+                      @click:append='selectPage'
+                      style='max-width: 850px;'
+                      :counter='255'
+                    )
+
             v-tab-item(key='permissions', :transition='false', :reverse-transition='false')
               group-permissions(v-model='group', @refresh='refresh')
 
@@ -44,21 +85,23 @@
 
             v-tab-item(key='users', :transition='false', :reverse-transition='false')
               group-users(v-model='group', @refresh='refresh')
+
           v-card-chin
             v-spacer
             .caption.grey--text.pr-2 Group ID #[strong {{group.id}}]
+
+    page-selector(mode='select', v-model='selectPageModal', :open-handler='selectPageHandle', path='home', :locale='currentLang')
 </template>
 
 <script>
 import _ from 'lodash'
+import gql from 'graphql-tag'
 
 import GroupPermissions from './admin-groups-edit-permissions.vue'
 import GroupRules from './admin-groups-edit-rules.vue'
 import GroupUsers from './admin-groups-edit-users.vue'
 
-import groupQuery from 'gql/admin/groups/groups-query-single.gql'
-import deleteGroupMutation from 'gql/admin/groups/groups-mutation-delete.gql'
-import updateGroupMutation from 'gql/admin/groups/groups-mutation-update.gql'
+/* global siteConfig */
 
 export default {
   components: {
@@ -74,20 +117,55 @@ export default {
         isSystem: false,
         permissions: [],
         pageRules: [],
-        users: []
+        users: [],
+        redirectOnLogin: '/'
       },
       deleteGroupDialog: false,
-      tab: null
+      tab: null,
+      selectPageModal: false,
+      currentLang: siteConfig.lang
     }
   },
   methods: {
+    selectPage () {
+      this.selectPageModal = true
+    },
+    selectPageHandle ({ path, locale }) {
+      this.group.redirectOnLogin = `/${locale}/${path}`
+    },
     async updateGroup() {
       try {
         await this.$apollo.mutate({
-          mutation: updateGroupMutation,
+          mutation: gql`
+            mutation (
+              $id: Int!
+              $name: String!
+              $redirectOnLogin: String!
+              $permissions: [String]!
+              $pageRules: [PageRuleInput]!
+            ) {
+              groups {
+                update(
+                  id: $id
+                  name: $name
+                  redirectOnLogin: $redirectOnLogin
+                  permissions: $permissions
+                  pageRules: $pageRules
+                ) {
+                  responseResult {
+                    succeeded
+                    errorCode
+                    slug
+                    message
+                  }
+                }
+              }
+            }
+          `,
           variables: {
             id: this.group.id,
             name: this.group.name,
+            redirectOnLogin: this.group.redirectOnLogin,
             permissions: this.group.permissions,
             pageRules: this.group.pageRules
           },
@@ -108,7 +186,20 @@ export default {
       this.deleteGroupDialog = false
       try {
         await this.$apollo.mutate({
-          mutation: deleteGroupMutation,
+          mutation: gql`
+            mutation ($id: Int!) {
+              groups {
+                delete(id: $id) {
+                  responseResult {
+                    succeeded
+                    errorCode
+                    slug
+                    message
+                  }
+                }
+              }
+            }
+          `,
           variables: {
             id: this.group.id
           },
@@ -132,7 +223,34 @@ export default {
   },
   apollo: {
     group: {
-      query: groupQuery,
+      query: gql`
+        query ($id: Int!) {
+          groups {
+            single(id: $id) {
+              id
+              name
+              redirectOnLogin
+              isSystem
+              permissions
+              pageRules {
+                id
+                path
+                roles
+                match
+                deny
+                locales
+              }
+              users {
+                id
+                name
+                email
+              }
+              createdAt
+              updatedAt
+            }
+          }
+        }
+      `,
       variables() {
         return {
           id: _.toSafeInteger(this.$route.params.id)

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

@@ -8,7 +8,9 @@
             .headline.blue--text.text--darken-2.animated.fadeInLeft Groups
             .subtitle-1.grey--text.animated.fadeInLeft.wait-p4s Manage groups and their permissions
           v-spacer
-          v-btn.animated.fadeInDown.wait-p2s.mr-3(color='grey', outlined, @click='refresh', icon)
+          v-btn.animated.fadeInDown.wait-p3s(icon, outlined, color='grey', href='https://docs.requarks.io/groups', target='_blank')
+            v-icon mdi-help-circle
+          v-btn.animated.fadeInDown.wait-p2s.mx-3(color='grey', outlined, @click='refresh', icon)
             v-icon mdi-refresh
           v-dialog(v-model='newGroupDialog', max-width='500')
             template(v-slot:activator='{ on }')

+ 123 - 20
client/components/admin/admin-security.vue

@@ -93,25 +93,25 @@
                     .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(
-                    inset
-                    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
-                  )
+                  //- v-divider.mt-3
+                  //- v-switch(
+                  //-   inset
+                  //-   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
+                  //- )
 
             v-flex(lg6 xs12)
               v-card.animated.fadeInUp.wait-p2s
@@ -142,6 +142,62 @@
                     :suffix='$t(`admin:security.maxUploadBatchSuffix`)'
                     style='max-width: 450px;'
                     )
+
+              v-card.mt-3.animated.fadeInUp.wait-p2s
+                v-toolbar(flat, color='primary', dark, dense)
+                  .subtitle-1 {{$t('admin:security.login')}}
+                //- v-card-info(color='blue')
+                //-   span {{$t('admin:security.loginInfo')}}
+                .overline.grey--text.pa-4 {{$t('admin:security.loginScreen')}}
+                .px-4.pb-3
+                  v-text-field(
+                    outlined
+                    :label='$t(`admin:security.loginBgUrl`)'
+                    v-model='config.authLoginBgUrl'
+                    :hint='$t(`admin:security.loginBgUrlHint`)'
+                    persistent-hint
+                    prepend-icon='mdi-image-area'
+                    append-icon='mdi-folder-image'
+                    @click:append='browseLoginBg'
+                  )
+                  v-switch(
+                    inset
+                    :label='$t(`admin:security.bypassLogin`)'
+                    color='red darken-2'
+                    v-model='config.authAutoLogin'
+                    prepend-icon='mdi-fast-forward'
+                    persistent-hint
+                    :hint='$t(`admin:security.bypassLoginHint`)'
+                    )
+                v-divider.mt-3
+                .overline.grey--text.pa-4 {{$t('admin:security.jwt')}}
+                .px-4.pb-3
+                  v-text-field(
+                    v-model='config.authJwtAudience'
+                    outlined
+                    prepend-icon='mdi-account-group-outline'
+                    :label='$t(`admin:auth.jwtAudience`)'
+                    :hint='$t(`admin:auth.jwtAudienceHint`)'
+                    persistent-hint
+                  )
+                  v-text-field.mt-3(
+                    v-model='config.authJwtExpiration'
+                    outlined
+                    prepend-icon='mdi-clock-outline'
+                    :label='$t(`admin:auth.tokenExpiration`)'
+                    :hint='$t(`admin:auth.tokenExpirationHint`)'
+                    persistent-hint
+                  )
+                  v-text-field.mt-3(
+                    v-model='config.authJwtRenewablePeriod'
+                    outlined
+                    prepend-icon='mdi-update'
+                    :label='$t(`admin:auth.tokenRenewalPeriod`)'
+                    :hint='$t(`admin:auth.tokenRenewalPeriodHint`)'
+                    persistent-hint
+                  )
+
+    component(:is='activeModal')
 </template>
 
 <script>
@@ -149,7 +205,17 @@ import _ from 'lodash'
 import { sync } from 'vuex-pathify'
 import gql from 'graphql-tag'
 
+import editorStore from '../../store/editor'
+
+/* global WIKI */
+
+WIKI.$store.registerModule('editor', editorStore)
+
 export default {
+  i18nOptions: { namespaces: 'editor' },
+  components: {
+    editorModalMedia: () => import(/* webpackChunkName: "editor", webpackMode: "lazy" */ '../editor/editor-modal-media.vue')
+  },
   data() {
     return {
       config: {
@@ -163,7 +229,12 @@ export default {
         securityHSTS: false,
         securityHSTSDuration: 0,
         securityCSP: false,
-        securityCSPDirectives: ''
+        securityCSPDirectives: '',
+        authAutoLogin: false,
+        authLoginBgUrl: '',
+        authJwtAudience: 'urn:wiki.js',
+        authJwtExpiration: '30m',
+        authJwtRenewablePeriod: '14d'
       },
       hstsDurations: [
         { value: 300, text: '5 minutes' },
@@ -184,6 +255,11 @@ export default {
         await this.$apollo.mutate({
           mutation: gql`
             mutation (
+              $authAutoLogin: Boolean
+              $authLoginBgUrl: String
+              $authJwtAudience: String
+              $authJwtExpiration: String
+              $authJwtRenewablePeriod: String
               $uploadMaxFileSize: Int
               $uploadMaxFiles: Int
               $securityOpenRedirect: Boolean
@@ -198,6 +274,11 @@ export default {
             ) {
               site {
                 updateConfig(
+                  authAutoLogin: $authAutoLogin,
+                  authLoginBgUrl: $authLoginBgUrl,
+                  authJwtAudience: $authJwtAudience,
+                  authJwtExpiration: $authJwtExpiration,
+                  authJwtRenewablePeriod: $authJwtRenewablePeriod,
                   uploadMaxFileSize: $uploadMaxFileSize,
                   uploadMaxFiles: $uploadMaxFiles,
                   securityOpenRedirect: $securityOpenRedirect,
@@ -221,6 +302,11 @@ export default {
             }
           `,
           variables: {
+            authAutoLogin: _.get(this.config, 'authAutoLogin', false),
+            authLoginBgUrl: _.get(this.config, 'authLoginBgUrl', ''),
+            authJwtAudience: _.get(this.config, 'authJwtAudience', ''),
+            authJwtExpiration: _.get(this.config, 'authJwtExpiration', ''),
+            authJwtRenewablePeriod: _.get(this.config, 'authJwtRenewablePeriod', ''),
             uploadMaxFileSize: _.toSafeInteger(_.get(this.config, 'uploadMaxFileSize', 0)),
             uploadMaxFiles: _.toSafeInteger(_.get(this.config, 'uploadMaxFiles', 0)),
             securityOpenRedirect: _.get(this.config, 'securityOpenRedirect', false),
@@ -245,14 +331,31 @@ export default {
       } catch (err) {
         this.$store.commit('pushGraphError', err)
       }
+    },
+    browseLoginBg () {
+      this.$store.set('editor/editorKey', 'common')
+      this.activeModal = 'editorModalMedia'
     }
   },
+  mounted () {
+    this.$root.$on('editorInsert', opts => {
+      this.config.loginBgUrl = opts.path
+    })
+  },
+  beforeDestroy() {
+    this.$root.$off('editorInsert')
+  },
   apollo: {
     config: {
       query: gql`
         {
           site {
             config {
+              authAutoLogin
+              authLoginBgUrl
+              authJwtAudience
+              authJwtExpiration
+              authJwtRenewablePeriod
               uploadMaxFileSize
               uploadMaxFiles
               securityOpenRedirect

+ 4 - 8
client/components/admin/admin-users-create.vue

@@ -11,7 +11,7 @@
       v-card-text.pt-5
         v-select(
           :items='providers'
-          item-text='title'
+          item-text='displayName'
           item-value='key'
           outlined
           prepend-icon='mdi-domain'
@@ -230,19 +230,15 @@ export default {
       query: gql`
         query {
           authentication {
-            strategies(
-              isEnabled: true
-            ) {
+            activeStrategies {
               key
-              title
-              icon
-              color
+              displayName
             }
           }
         }
       `,
       fetchPolicy: 'network-only',
-      update: (data) => data.authentication.strategies,
+      update: (data) => data.authentication.activeStrategies,
       watchLoading (isLoading) {
         this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-users-strategies-refresh')
       }

+ 5 - 9
client/components/admin/admin-users.vue

@@ -33,7 +33,7 @@
               label='Identity Provider'
               :items='strategies'
               v-model='filterStrategy'
-              item-text='title'
+              item-text='displayName'
               item-value='key'
               style='max-width: 300px;'
               dense
@@ -162,13 +162,9 @@ export default {
       query: gql`
         query {
           authentication {
-            strategies(
-              isEnabled: true
-            ) {
+            activeStrategies {
               key
-              title
-              icon
-              color
+              displayName
             }
           }
         }
@@ -177,8 +173,8 @@ export default {
       update: (data) => {
         return _.concat({
           key: 'all',
-          title: 'All Providers'
-        }, data.authentication.strategies)
+          displayName: 'All Providers'
+        }, data.authentication.activeStrategies)
       },
       watchLoading (isLoading) {
         this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-users-strategies-refresh')

+ 4 - 4
client/components/common/notify.vue

@@ -4,6 +4,7 @@
     top
     multi-line
     v-model='notificationState'
+    :timeout='6000'
     )
     .text-left
       v-icon.mr-3(dark) mdi-{{ notification.icon }}
@@ -26,16 +27,15 @@ export default {
 
 <style lang='scss'>
 .nav-notify {
-  // top: 60px;
+  top: -64px;
+  padding-top: 0;
   z-index: 999;
 
   .v-snack__wrapper {
     border-top-left-radius: 0;
     border-top-right-radius: 0;
-  }
-
-  .v-snack__content {
     position: relative;
+    margin-top: 0;
 
     &::after {
       content: '';

+ 40 - 0
client/components/editor/editor-markdown.vue

@@ -245,6 +245,7 @@ import mermaid from 'mermaid'
 
 // Helpers
 import katexHelper from './common/katex'
+import tabsetHelper from './markdown/tabset'
 
 // ========================================
 // INIT
@@ -433,6 +434,7 @@ export default {
       this.$store.set('editor/content', newContent)
       this.previewHTML = DOMPurify.sanitize(md.render(newContent))
       this.$nextTick(() => {
+        tabsetHelper.format()
         this.renderMermaidDiagrams()
         Prism.highlightAllUnder(this.$refs.editorPreview)
         Array.from(this.$refs.editorPreview.querySelectorAll('pre.line-numbers')).forEach(pre => pre.classList.add('prismjs'))
@@ -856,6 +858,44 @@ $editor-height-mobile: calc(100vh - 112px - 16px);
       p.line {
         overflow-wrap: break-word;
       }
+
+      .tabset {
+        background-color: mc('teal', '700');
+        color: mc('teal', '100') !important;
+        padding: 5px 12px;
+        font-size: 14px;
+        font-weight: 500;
+        border-radius: 5px 0 0 0;
+        font-style: italic;
+
+        &::after {
+          display: none;
+        }
+
+        &-header {
+          background-color: mc('teal', '500');
+          color: #FFF !important;
+          padding: 5px 12px;
+          font-size: 14px;
+          font-weight: 500;
+          margin-top: 0 !important;
+
+          &::after {
+            display: none;
+          }
+        }
+
+        &-content {
+          border-left: 5px solid mc('teal', '500');
+          background-color: mc('teal', '50');
+          padding: 0 15px 15px;
+          overflow: hidden;
+
+          @at-root .theme--dark & {
+            background-color: rgba(mc('teal', '500'), .1);
+          }
+        }
+      }
     }
   }
 

+ 16 - 0
client/components/editor/markdown/tabset.js

@@ -0,0 +1,16 @@
+import cash from 'cash-dom'
+import _ from 'lodash'
+
+export default {
+  format () {
+    for (let i = 1; i < 6; i++) {
+      cash(`.editor-markdown-preview-content h${i}.tabset`).each((idx, elm) => {
+        elm.innerHTML = 'Tabset ( rendered upon saving )'
+        cash(elm).nextUntil(_.times(i, t => `h${t + 1}`).join(', '), `h${i + 1}`).each((hidx, hd) => {
+          hd.classList.add('tabset-header')
+          cash(hd).nextUntil(_.times(i + 1, t => `h${t + 1}`).join(', ')).wrapAll('<div class="tabset-content"></div>')
+        })
+      })
+    }
+  }
+}

+ 343 - 237
client/components/login.vue

@@ -1,189 +1,240 @@
 <template lang="pug">
   v-app
     .login
-      v-container(grid-list-lg)
-        v-layout(row, wrap)
-          v-flex(
-            xs12
-            offset-sm1, sm10
-            offset-md2, md8
-            offset-lg3, lg6
-            offset-xl4, xl4
+      .login-sd
+        .d-flex
+          .login-logo
+            v-avatar(tile, size='34')
+              v-img(:src='logoUrl')
+          .login-title
+            .text-h6 {{ siteTitle }}
+        //-------------------------------------------------
+        //- PROVIDERS LIST
+        //-------------------------------------------------
+        template(v-if='screen === `login` && strategies.length > 1')
+          .login-subtitle.mt-5
+            .text-subtitle-1 Select Authentication Provider
+          .login-list
+            v-list.elevation-1.radius-7(nav)
+              v-list-item-group(v-model='selectedStrategyKey')
+                v-list-item(
+                  v-for='(stg, idx) of strategies'
+                  :key='stg.key'
+                  :value='stg.key'
+                  :color='stg.strategy.color'
+                  )
+                  v-avatar.mr-3(tile, size='24', v-html='stg.strategy.icon')
+                  span.text-none {{stg.displayName}}
+        //-------------------------------------------------
+        //- LOGIN FORM
+        //-------------------------------------------------
+        template(v-if='screen === `login` && selectedStrategy.strategy.useForm')
+          .login-subtitle
+            .text-subtitle-1 Enter your credentials
+          .login-form
+            v-text-field(
+              solo
+              flat
+              prepend-inner-icon='mdi-clipboard-account'
+              background-color='white'
+              hide-details
+              ref='iptEmail'
+              v-model='username'
+              :placeholder='$t("auth:fields.emailUser")'
+              )
+            v-text-field.mt-2(
+              solo
+              flat
+              prepend-inner-icon='mdi-form-textbox-password'
+              background-color='white'
+              hide-details
+              ref='iptPassword'
+              v-model='password'
+              :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
+              @click:append='() => (hidePassword = !hidePassword)'
+              :type='hidePassword ? "password" : "text"'
+              :placeholder='$t("auth:fields.password")'
+              @keyup.enter='login'
             )
-            transition(name='fadeUp')
-              v-card.elevation-5(v-show='isShown', light)
-                v-toolbar(color='indigo', 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.indigo--text.py-2 {{ siteTitle }}
-                  template(v-if='screen === "login"')
-                    v-text-field.mt-3(
-                      solo
-                      flat
-                      prepend-icon='mdi-clipboard-account'
-                      background-color='grey lighten-4'
-                      hide-details
-                      ref='iptEmail'
-                      v-model='username'
-                      :placeholder='$t("auth:fields.emailUser")'
-                      )
-                    v-text-field.mt-2(
-                      solo
-                      flat
-                      prepend-icon='mdi-textbox-password'
-                      background-color='grey lighten-4'
-                      hide-details
-                      ref='iptPassword'
-                      v-model='password'
-                      :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
-                      @click:append='() => (hidePassword = !hidePassword)'
-                      :type='hidePassword ? "password" : "text"'
-                      :placeholder='$t("auth:fields.password")'
-                      @keyup.enter='login'
-                    )
-                  template(v-else-if='screen === "tfa"')
-                    .body-2 Enter the security code generated from your trusted device:
-                    v-text-field.centered.mt-2(
-                      solo
-                      flat
-                      background-color='grey lighten-4'
-                      hide-details
-                      ref='iptTFA'
-                      v-model='securityCode'
-                      :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.mt-3(
-                      solo
-                      flat
-                      prepend-icon='mdi-email'
-                      background-color='grey lighten-4'
-                      hide-details
-                      ref='iptEmailForgot'
-                      v-model='username'
-                      :placeholder='$t("auth:fields.email")'
-                      )
-                v-card-actions.pb-4
-                  v-spacer
-                  v-btn(
-                    width='100%'
-                    max-width='250px'
-                    v-if='screen === "login"'
-                    large
-                    color='primary'
-                    dark
-                    @click='login'
-                    rounded
-                    :loading='isLoading'
-                    ) {{ $t('auth:actions.login') }}
-                  v-btn(
-                    width='100%'
-                    max-width='250px'
-                    v-else-if='screen === "tfa"'
-                    large
-                    color='primary'
-                    dark
-                    @click='verifySecurityCode'
-                    rounded
-                    :loading='isLoading'
-                    ) {{ $t('auth:tfa.verifyToken') }}
-                  v-btn(
-                    width='100%'
-                    max-width='250px'
-                    v-else-if='screen === "changePwd"'
-                    large
-                    color='primary'
-                    dark
-                    @click='changePassword'
-                    rounded
-                    :loading='isLoading'
-                    ) {{ $t('auth:changePwd.proceed') }}
-                  v-btn(
-                    width='100%'
-                    max-width='250px'
-                    v-else-if='screen === "forgot"'
-                    large
-                    color='primary'
-                    dark
-                    @click='forgotPasswordSubmit'
-                    rounded
-                    :loading='isLoading'
-                    ) {{ $t('auth:sendResetPassword') }}
-                  v-spacer
-                v-card-actions.pb-3(v-if='screen === "login" && selectedStrategy.key === "local"')
-                  v-spacer
-                  a.caption(@click.stop.prevent='forgotPassword', href='#forgot') {{ $t('auth:forgotPasswordLink') }}
-                  v-spacer
-                v-card-actions.pb-3(v-else-if='screen === "forgot"')
-                  v-spacer
-                  a.caption(@click.stop.prevent='screen = `login`', href='#cancelforgot') {{ $t('auth:forgotPasswordCancel') }}
-                  v-spacer
-                template(v-if='screen === "login" && isSocialShown')
-                  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-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.key === `local` && selectedStrategy.selfRegistration')
-                  v-divider
-                  v-card-actions.py-3(:class='isSocialShown ? "" : "grey lighten-4"')
-                    v-spacer
-                    i18next.caption(path='auth:switchToRegister.text', tag='div')
-                      a.caption(href='/register', place='link') {{ $t('auth:switchToRegister.link') }}
-                    v-spacer
+            v-btn.mt-2.text-none(
+              width='100%'
+              v-if='screen === "login"'
+              large
+              color='primary'
+              dark
+              @click='login'
+              :loading='isLoading'
+              ) {{ $t('auth:actions.login') }}
+            .text-center.mt-5(v-if='screen === "login"')
+              v-btn.text-none(
+                text
+                rounded
+                color='grey darken-3'
+                @click.stop.prevent='forgotPassword'
+                href='#forgot'
+                ): .caption {{ $t('auth:forgotPasswordLink') }}
+              v-btn.text-none(
+                v-if='screen === "login" && selectedStrategyKey === `local` && selectedStrategy.selfRegistration'
+                color='indigo darken-2'
+                text
+                rounded
+                href='/register'
+                ): .caption {{ $t('auth:switchToRegister.link') }}
+      //- .login-main
+      //- v-container(grid-list-lg, fluid)
+      //-   v-row(no-gutters)
+      //-     v-col(cols='12', xl='4')
+            //- transition(name='fadeUp')
+            //-   v-card.elevation-5(v-show='isShown', light)
+            //-     v-toolbar(color='indigo', 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.indigo--text.py-2 {{ siteTitle }}
+            //-       template(v-if='screen === "login"')
+            //-         v-text-field.mt-3(
+            //-           solo
+            //-           flat
+            //-           prepend-icon='mdi-clipboard-account'
+            //-           background-color='grey lighten-4'
+            //-           hide-details
+            //-           ref='iptEmail'
+            //-           v-model='username'
+            //-           :placeholder='$t("auth:fields.emailUser")'
+            //-           )
+            //-         v-text-field.mt-2(
+            //-           solo
+            //-           flat
+            //-           prepend-icon='mdi-textbox-password'
+            //-           background-color='grey lighten-4'
+            //-           hide-details
+            //-           ref='iptPassword'
+            //-           v-model='password'
+            //-           :append-icon='hidePassword ? "mdi-eye-off" : "mdi-eye"'
+            //-           @click:append='() => (hidePassword = !hidePassword)'
+            //-           :type='hidePassword ? "password" : "text"'
+            //-           :placeholder='$t("auth:fields.password")'
+            //-           @keyup.enter='login'
+            //-         )
+            //-       template(v-else-if='screen === "tfa"')
+            //-         .body-2 Enter the security code generated from your trusted device:
+            //-         v-text-field.centered.mt-2(
+            //-           solo
+            //-           flat
+            //-           background-color='grey lighten-4'
+            //-           hide-details
+            //-           ref='iptTFA'
+            //-           v-model='securityCode'
+            //-           :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.mt-3(
+            //-           solo
+            //-           flat
+            //-           prepend-icon='mdi-email'
+            //-           background-color='grey lighten-4'
+            //-           hide-details
+            //-           ref='iptEmailForgot'
+            //-           v-model='username'
+            //-           :placeholder='$t("auth:fields.email")'
+            //-           )
+            //-     v-card-actions.pb-4
+            //-       v-spacer
+            //-       v-btn(
+            //-         width='100%'
+            //-         max-width='250px'
+            //-         v-if='screen === "login"'
+            //-         large
+            //-         color='primary'
+            //-         dark
+            //-         @click='login'
+            //-         rounded
+            //-         :loading='isLoading'
+            //-         ) {{ $t('auth:actions.login') }}
+            //-       v-btn(
+            //-         width='100%'
+            //-         max-width='250px'
+            //-         v-else-if='screen === "tfa"'
+            //-         large
+            //-         color='primary'
+            //-         dark
+            //-         @click='verifySecurityCode'
+            //-         rounded
+            //-         :loading='isLoading'
+            //-         ) {{ $t('auth:tfa.verifyToken') }}
+            //-       v-btn(
+            //-         width='100%'
+            //-         max-width='250px'
+            //-         v-else-if='screen === "changePwd"'
+            //-         large
+            //-         color='primary'
+            //-         dark
+            //-         @click='changePassword'
+            //-         rounded
+            //-         :loading='isLoading'
+            //-         ) {{ $t('auth:changePwd.proceed') }}
+            //-       v-btn(
+            //-         width='100%'
+            //-         max-width='250px'
+            //-         v-else-if='screen === "forgot"'
+            //-         large
+            //-         color='primary'
+            //-         dark
+            //-         @click='forgotPasswordSubmit'
+            //-         rounded
+            //-         :loading='isLoading'
+            //-         ) {{ $t('auth:sendResetPassword') }}
+            //-       v-spacer
+            //-     v-card-actions.pb-3(v-if='screen === "login" && selectedStrategy.key === "local"')
+            //-       v-spacer
+            //-       a.caption(@click.stop.prevent='forgotPassword', href='#forgot') {{ $t('auth:forgotPasswordLink') }}
+            //-       v-spacer
+            //-     v-card-actions.pb-3(v-else-if='screen === "forgot"')
+            //-       v-spacer
+            //-       a.caption(@click.stop.prevent='screen = `login`', href='#cancelforgot') {{ $t('auth:forgotPasswordCancel') }}
+            //-       v-spacer
 
     loader(v-model='isLoading', :color='loaderColor', :title='loaderTitle', :subtitle='$t(`auth:pleaseWait`)')
-    nav-footer(color='grey darken-5', dark-color='grey darken-5')
     notify
 </template>
 
 <script>
 /* global siteConfig */
 
+// <span>Photo by <a href="https://unsplash.com/@isaacquesada?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Isaac Quesada</a> on <a href="/t/textures-patterns?utm_source=unsplash&amp;utm_medium=referral&amp;utm_content=creditCopyText">Unsplash</a></span>
+
 import _ from 'lodash'
 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'
+import gql from 'graphql-tag'
+import { sync } from 'vuex-pathify'
 
 export default {
   i18nOptions: { namespaces: 'auth' },
@@ -191,7 +242,8 @@ export default {
     return {
       error: false,
       strategies: [],
-      selectedStrategy: { key: 'local' },
+      selectedStrategyKey: 'local',
+      selectedStrategy: { key: 'local', strategy: { useForm: true } },
       screen: 'login',
       username: '',
       password: '',
@@ -207,40 +259,39 @@ export default {
     }
   },
   computed: {
+    activeModal: sync('editor/activeModal'),
     siteTitle () {
       return siteConfig.title
     },
     isSocialShown () {
       return this.strategies.length > 1
-    }
+    },
+    logoUrl () { return siteConfig.logoUrl }
   },
   watch: {
     strategies(newValue, oldValue) {
-      this.selectedStrategy = _.find(newValue, ['key', 'local'])
+      this.selectedStrategy = _.head(newValue)
+    },
+    selectedStrategyKey (newValue, oldValue) {
+      this.selectedStrategy = _.find(this.strategies, ['key', newValue])
+      this.screen = 'login'
+      if (!this.selectedStrategy.strategy.useForm) {
+        this.isLoading = true
+        window.location.assign('/login/' + newValue)
+      } else {
+        this.$nextTick(() => {
+          this.$refs.iptEmail.focus()
+        })
+      }
     }
   },
   mounted () {
     this.isShown = true
     this.$nextTick(() => {
-      this.$refs.iptEmail.focus()
+      // this.$refs.iptEmail.focus()
     })
   },
   methods: {
-    /**
-     * SELECT STRATEGY
-     */
-    selectStrategy (strategy) {
-      this.selectedStrategy = strategy
-      this.screen = 'login'
-      if (!strategy.useForm) {
-        this.isLoading = true
-        window.location.assign('/login/' + strategy.key)
-      } else {
-        this.$nextTick(() => {
-          this.$refs.iptEmail.focus()
-        })
-      }
-    },
     /**
      * LOGIN
      */
@@ -265,7 +316,24 @@ export default {
         this.isLoading = true
         try {
           let resp = await this.$apollo.mutate({
-            mutation: loginMutation,
+            mutation: gql`
+              mutation($username: String!, $password: String!, $strategy: String!) {
+                authentication {
+                  login(username: $username, password: $password, strategy: $strategy) {
+                    responseResult {
+                      succeeded
+                      errorCode
+                      slug
+                      message
+                    }
+                    jwt
+                    mustChangePwd
+                    mustProvideTFA
+                    continuationToken
+                  }
+                }
+              }
+            `,
             variables: {
               username: this.username,
               password: this.password,
@@ -334,7 +402,11 @@ export default {
       } else {
         this.isLoading = true
         this.$apollo.mutate({
-          mutation: tfaMutation,
+          mutation: gql`
+            {
+
+            }
+          `,
           variables: {
             continuationToken: this.continuationToken,
             securityCode: this.securityCode
@@ -377,7 +449,11 @@ export default {
       this.loaderTitle = this.$t('auth:changePwd.loading')
       this.isLoading = true
       const resp = await this.$apollo.mutate({
-        mutation: changePasswordMutation,
+        mutation: gql`
+          {
+
+          }
+        `,
         variables: {
           continuationToken: this.continuationToken,
           newPassword: this.newPassword
@@ -421,8 +497,26 @@ export default {
   },
   apollo: {
     strategies: {
-      query: strategiesQuery,
-      update: (data) => data.authentication.strategies,
+      query: gql`
+        {
+          authentication {
+            activeStrategies {
+              key
+              strategy {
+                key
+                logo
+                color
+                icon
+                useForm
+              }
+              displayName
+              order
+              selfRegistration
+            }
+          }
+        }
+      `,
+      update: (data) => _.sortBy(data.authentication.activeStrategies, ['order']),
       watchLoading (isLoading) {
         this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'login-strategies-refresh')
       }
@@ -433,61 +527,73 @@ export default {
 
 <style lang="scss">
   .login {
-    background-color: mc('indigo', '900');
-    background-image: url('../static/svg/motif-blocks.svg');
-    background-repeat: repeat;
-    background-size: 200px;
+    background-image: url('/_assets/img/splash/1.jpg');
+    background-size: cover;
+    background-position: center center;
     width: 100%;
     height: 100%;
-    animation: loginBgReveal 20s linear infinite;
 
-    @include keyframes(loginBgReveal) {
-      0% {
-        background-position-y: 0;
+    &-sd {
+      background-color: rgba(255,255,255,.8);
+      backdrop-filter: blur(10px);
+      -webkit-backdrop-filter: blur(10px);
+      border-left: 1px solid rgba(255,255,255,.85);
+      border-right: 1px solid rgba(255,255,255,.85);
+      width: 450px;
+      height: 100%;
+      margin-left: 5vw;
+
+      @at-root .no-backdropfilter & {
+        background-color: rgba(255,255,255,.95);
       }
-      100% {
-        background-position-y: 800px;
+
+      @include until($tablet) {
+        margin-left: 0;
+        width: 100%;
       }
     }
 
-    &::before {
-      content: '';
-      position: absolute;
-      background-image: url('../static/svg/motif-overlay.svg');
-      background-attachment: fixed;
-      background-size: cover;
-      opacity: .5;
-      top: 0;
-      left: 0;
-      width: 100vw;
-      height: 100vh;
+    &-logo {
+      padding: 12px 0 0 12px;
+      width: 58px;
+      height: 58px;
+      background-color: #222;
+      margin-left: 12px;
+      border-bottom-left-radius: 7px;
+      border-bottom-right-radius: 7px;
     }
 
-    > .container {
-      height: 100%;
-      align-items: center;
+    &-title {
+      height: 58px;
+      padding-left: 12px;
       display: flex;
+      align-items: center;
+      text-shadow: .5px .5px #FFF;
     }
 
-    .social-login-btn {
-      cursor: pointer;
-      transition: opacity .2s ease;
-      &:hover {
-        opacity: .8;
-      }
-      margin: .25rem 0;
-      svg {
-        width: 24px;
-        height: 24px;
-        bottom: 0;
-        path {
-          fill: #FFF;
-        }
-      }
+    &-subtitle {
+      padding: 24px 12px 12px 12px;
+      color: #111;
+      font-weight: 500;
+      text-shadow: 1px 1px rgba(255,255,255,.5);
+      background-image: linear-gradient(to bottom, rgba(0,0,0,0), rgba(0,0,0,.15));
+      text-align: center;
+      border-bottom: 1px solid rgba(0,0,0,.3);
     }
 
-    .v-text-field.centered input {
-      text-align: center;
+    &-list {
+      border-top: 1px solid rgba(255,255,255,.85);
+      padding: 12px;
+    }
+
+    &-form {
+      padding: 12px;
+      border-top: 1px solid rgba(255,255,255,.85);
+    }
+
+    &-main {
+      flex: 1 0 100vw;
+      height: 100vh;
     }
   }
 </style>

+ 0 - 12
client/graph/admin/auth/auth-mutation-save-strategies.gql

@@ -1,12 +0,0 @@
-mutation($strategies: [AuthenticationStrategyInput]!, $config: AuthenticationConfigInput) {
-  authentication {
-    updateStrategies(strategies: $strategies, config: $config) {
-      responseResult {
-        succeeded
-        errorCode
-        slug
-        message
-      }
-    }
-  }
-}

+ 0 - 12
client/graph/admin/groups/groups-mutation-delete.gql

@@ -1,12 +0,0 @@
-mutation ($id: Int!) {
-  groups {
-    delete(id: $id) {
-      responseResult {
-        succeeded
-        errorCode
-        slug
-        message
-      }
-    }
-  }
-}

+ 0 - 12
client/graph/admin/groups/groups-mutation-update.gql

@@ -1,12 +0,0 @@
-mutation ($id: Int!, $name: String!, $permissions: [String]!, $pageRules: [PageRuleInput]!) {
-  groups {
-    update(id: $id, name: $name, permissions: $permissions, pageRules: $pageRules) {
-      responseResult {
-        succeeded
-        errorCode
-        slug
-        message
-      }
-    }
-  }
-}

+ 0 - 25
client/graph/admin/groups/groups-query-single.gql

@@ -1,25 +0,0 @@
-query ($id: Int!) {
-  groups {
-    single(id: $id) {
-      id
-      name
-      isSystem
-      permissions
-      pageRules {
-        id
-        path
-        roles
-        match
-        deny
-        locales
-      }
-      users {
-        id
-        name
-        email
-      }
-      createdAt
-      updatedAt
-    }
-  }
-}

+ 2 - 0
client/index-app.js

@@ -14,6 +14,8 @@ switch (window.document.documentElement.lang) {
     break
 }
 
+require('modernizr')
+
 require('./scss/app.scss')
 import(/* webpackChunkName: "theme" */ './themes/' + siteConfig.theme + '/scss/app.scss')
 

+ 3 - 0
client/libs/modernizr/modernizr.js

@@ -0,0 +1,3 @@
+/*! modernizr 3.6.0 (Custom Build) | MIT *
+ * https://modernizr.com/download/?-setclasses !*/
+!function(n,e,s){function o(n){var e=r.className,s=Modernizr._config.classPrefix||"";if(c&&(e=e.baseVal),Modernizr._config.enableJSClass){var o=new RegExp("(^|\\s)"+s+"no-js(\\s|$)");e=e.replace(o,"$1"+s+"js$2")}Modernizr._config.enableClasses&&(e+=" "+s+n.join(" "+s),c?r.className.baseVal=e:r.className=e)}function a(n,e){return typeof n===e}function i(){var n,e,s,o,i,l,r;for(var c in f)if(f.hasOwnProperty(c)){if(n=[],e=f[c],e.name&&(n.push(e.name.toLowerCase()),e.options&&e.options.aliases&&e.options.aliases.length))for(s=0;s<e.options.aliases.length;s++)n.push(e.options.aliases[s].toLowerCase());for(o=a(e.fn,"function")?e.fn():e.fn,i=0;i<n.length;i++)l=n[i],r=l.split("."),1===r.length?Modernizr[r[0]]=o:(!Modernizr[r[0]]||Modernizr[r[0]]instanceof Boolean||(Modernizr[r[0]]=new Boolean(Modernizr[r[0]])),Modernizr[r[0]][r[1]]=o),t.push((o?"":"no-")+r.join("-"))}}var t=[],f=[],l={_version:"3.6.0",_config:{classPrefix:"",enableClasses:!0,enableJSClass:!0,usePrefixes:!0},_q:[],on:function(n,e){var s=this;setTimeout(function(){e(s[n])},0)},addTest:function(n,e,s){f.push({name:n,fn:e,options:s})},addAsyncTest:function(n){f.push({name:null,fn:n})}},Modernizr=function(){};Modernizr.prototype=l,Modernizr=new Modernizr;var r=e.documentElement,c="svg"===r.nodeName.toLowerCase();i(),o(t),delete l.addTest,delete l.addAsyncTest;for(var u=0;u<Modernizr._q.length;u++)Modernizr._q[u]();n.Modernizr=Modernizr}(window,document);

BIN
client/static/img/splash/1.jpg


BIN
client/static/img/splash/2.jpg


+ 2 - 1
client/store/site.js

@@ -13,7 +13,8 @@ const state = {
   searchIsFocused: false,
   searchIsLoading: false,
   searchRestrictLocale: false,
-  searchRestrictPath: false
+  searchRestrictPath: false,
+  printView: false
 }
 
 export default {

+ 16 - 7
client/themes/default/components/page.vue

@@ -1,8 +1,8 @@
 <template lang="pug">
   v-app(v-scroll='upBtnScroll', :dark='$vuetify.theme.dark', :class='$vuetify.rtl ? `is-rtl` : `is-ltr`')
-    nav-header
+    nav-header(v-if='!printView')
     v-navigation-drawer(
-      v-if='navMode !== `NONE`'
+      v-if='navMode !== `NONE` && !printView'
       :class='$vuetify.theme.dark ? `grey darken-4-d4` : `primary`'
       dark
       app
@@ -171,7 +171,8 @@
                   )
                 v-tooltip(bottom)
                   template(v-slot:activator='{ on }')
-                    v-btn(icon, tile, v-on='on', @click='print'): v-icon(color='grey') mdi-printer
+                    v-btn(icon, tile, v-on='on', @click='print')
+                      v-icon(:color='printView ? `primary` : `grey`') mdi-printer
                   span {{$t('common:page.printFormat')}}
                 v-spacer
 
@@ -264,7 +265,7 @@
               .caption {{$t('common:page.unpublishedWarning')}}
             .contents(ref='container')
               slot(name='contents')
-            .comments-container#discussion(v-if='commentsEnabled && commentsPerms.read')
+            .comments-container#discussion(v-if='commentsEnabled && commentsPerms.read && !printView')
               .comments-header
                 v-icon.mr-2(dark) mdi-comment-text-outline
                 span {{$t('common:comments.title')}}
@@ -297,7 +298,7 @@ import Tabset from './tabset.vue'
 import NavSidebar from './nav-sidebar.vue'
 import Prism from 'prismjs'
 import mermaid from 'mermaid'
-import { get } from 'vuex-pathify'
+import { get, sync } from 'vuex-pathify'
 import _ from 'lodash'
 import ClipboardJS from 'clipboard'
 import Vue from 'vue'
@@ -490,7 +491,8 @@ export default {
     hasAnyPagePermissions () {
       return this.hasAdminPermission || this.hasWritePagesPermission || this.hasManagePagesPermission ||
         this.hasDeletePagesPermission || this.hasReadSourcePermission || this.hasReadHistoryPermission
-    }
+    },
+    printView: sync('site/printView')
   },
   created() {
     this.$store.set('page/authorId', this.authorId)
@@ -566,7 +568,14 @@ export default {
       this.upBtnShown = scrollOffset > window.innerHeight * 0.33
     },
     print () {
-      window.print()
+      if (this.printView) {
+        this.printView = false
+      } else {
+        this.printView = true
+        this.$nextTick(() => {
+          window.print()
+        })
+      }
     },
     pageEdit () {
       this.$root.$emit('pageEdit')

+ 4 - 0
client/themes/default/scss/app.scss

@@ -991,4 +991,8 @@
       }
     }
   }
+
+  .comments-container {
+    display: none;
+  }
 }

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

@@ -173,6 +173,10 @@ module.exports = {
             outputPath: 'fonts/'
           }
         }]
+      },
+      {
+        loader: 'webpack-modernizr-loader',
+        test: /\.modernizrrc\.js$/
       }
     ]
   },
@@ -253,7 +257,8 @@ module.exports = {
       // Duplicates fixes:
       'apollo-link': path.join(process.cwd(), 'node_modules/apollo-link'),
       'apollo-utilities': path.join(process.cwd(), 'node_modules/apollo-utilities'),
-      'uc.micro': path.join(process.cwd(), 'node_modules/uc.micro')
+      'uc.micro': path.join(process.cwd(), 'node_modules/uc.micro'),
+      'modernizr$': path.resolve(process.cwd(), 'client/.modernizrrc.js')
     },
     extensions: [
       '.js',

+ 2 - 0
package.json

@@ -224,6 +224,7 @@
     "babel-plugin-transform-imports": "2.0.0",
     "cache-loader": "4.1.0",
     "canvas-confetti": "1.2.0",
+    "cash-dom": "8.0.0",
     "chart.js": "2.9.3",
     "clean-webpack-plugin": "3.0.0",
     "clipboard": "2.0.6",
@@ -318,6 +319,7 @@
     "webpack-dev-middleware": "3.7.2",
     "webpack-hot-middleware": "2.25.0",
     "webpack-merge": "4.2.2",
+    "webpack-modernizr-loader": "5.0.0",
     "webpack-subresource-integrity": "1.4.1",
     "webpackbar": "4.0.0",
     "whatwg-fetch": "3.0.0",

+ 6 - 0
server/app/data.yml

@@ -53,6 +53,12 @@ defaults:
       theme: 'default'
       iconset: 'md'
       darkMode: false
+    auth:
+      autoLogin: false
+      loginBgUrl: ''
+      audience: 'urn:wiki.js'
+      tokenExpiration: '30m'
+      tokenRenewal: '14d'
     features:
       featurePageRatings: true
       featurePageComments: true

+ 3 - 4
server/core/auth.js

@@ -78,9 +78,8 @@ module.exports = {
       const enabledStrategies = await WIKI.models.authentication.getStrategies()
       for (let idx in enabledStrategies) {
         const stg = enabledStrategies[idx]
-        if (!stg.isEnabled) { continue }
         try {
-          const strategy = require(`../modules/authentication/${stg.key}/authentication.js`)
+          const strategy = require(`../modules/authentication/${stg.strategyKey}/authentication.js`)
 
           stg.config.callbackURL = `${WIKI.config.host}/login/${stg.key}/callback`
           strategy.init(passport, stg.config)
@@ -90,9 +89,9 @@ module.exports = {
             ...strategy,
             ...stg
           }
-          WIKI.logger.info(`Authentication Strategy ${stg.key}: [ OK ]`)
+          WIKI.logger.info(`Authentication Strategy ${stg.displayName}: [ OK ]`)
         } catch (err) {
-          WIKI.logger.error(`Authentication Strategy ${stg.key}: [ FAILED ]`)
+          WIKI.logger.error(`Authentication Strategy ${stg.displayName} (${stg.key}): [ FAILED ]`)
           WIKI.logger.error(err)
         }
       }

+ 23 - 0
server/db/migrations-sqlite/2.5.1.js

@@ -0,0 +1,23 @@
+exports.up = async knex => {
+  await knex('authentication').where('isEnabled', false).del()
+
+  await knex.schema
+    .alterTable('authentication', table => {
+      table.dropColumn('isEnabled')
+      table.integer('order').unsigned().notNullable().defaultTo(0)
+      table.string('strategyKey').notNullable().defaultTo('')
+      table.string('displayName').notNullable().defaultTo('')
+    })
+
+  // Fix pre-2.5 strategies
+  const strategies = await knex('authentication')
+  let idx = 1
+  for (const strategy of strategies) {
+    await knex('authentication').where('key', strategy.key).update({
+      strategyKey: strategy.key,
+      order: (strategy.key === 'local') ? 0 : idx++
+    })
+  }
+}
+
+exports.down = knex => { }

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

@@ -0,0 +1,8 @@
+exports.up = async knex => {
+  await knex.schema
+    .alterTable('groups', table => {
+      table.string('redirectOnLogin').notNullable().defaultTo('/')
+    })
+}
+
+exports.down = knex => { }

+ 23 - 0
server/db/migrations/2.5.1.js

@@ -0,0 +1,23 @@
+exports.up = async knex => {
+  await knex('authentication').where('isEnabled', false).del()
+
+  await knex.schema
+    .alterTable('authentication', table => {
+      table.dropColumn('isEnabled')
+      table.integer('order').unsigned().notNullable().defaultTo(0)
+      table.string('strategyKey').notNullable().defaultTo('')
+      table.string('displayName').notNullable().defaultTo('')
+    })
+
+  // Fix pre-2.5 strategies
+  const strategies = await knex('authentication')
+  let idx = 1
+  for (const strategy of strategies) {
+    await knex('authentication').where('key', strategy.key).update({
+      strategyKey: strategy.key,
+      order: (strategy.key === 'local') ? 0 : idx++
+    })
+  }
+}
+
+exports.down = knex => { }

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

@@ -0,0 +1,8 @@
+exports.up = async knex => {
+  await knex.schema
+    .alterTable('groups', table => {
+      table.string('redirectOnLogin').notNullable().defaultTo('/')
+    })
+}
+
+exports.down = knex => { }

+ 52 - 14
server/graph/resolvers/authentication.js

@@ -34,16 +34,28 @@ module.exports = {
     apiState () {
       return WIKI.config.api.isEnabled
     },
+    async strategies () {
+      return WIKI.data.authentication.map(stg => ({
+        ...stg,
+        isAvailable: stg.isAvailable === true,
+        props: _.sortBy(_.transform(stg.props, (res, value, key) => {
+          res.push({
+            key,
+            value: JSON.stringify(value)
+          })
+        }, []), 'key')
+      }))
+    },
     /**
      * Fetch active authentication strategies
      */
-    async strategies (obj, args, context, info) {
-      let strategies = await WIKI.models.authentication.getStrategies(args.isEnabled)
+    async activeStrategies (obj, args, context, info) {
+      let strategies = await WIKI.models.authentication.getStrategies()
       strategies = strategies.map(stg => {
-        const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.key]) || {}
+        const strategyInfo = _.find(WIKI.data.authentication, ['key', stg.strategyKey]) || {}
         return {
-          ...strategyInfo,
           ...stg,
+          strategy: strategyInfo,
           config: _.sortBy(_.transform(stg.config, (res, value, key) => {
             const configData = _.get(strategyInfo.props, key, false)
             if (configData) {
@@ -174,16 +186,18 @@ module.exports = {
      */
     async updateStrategies (obj, args, context) {
       try {
-        WIKI.config.auth = {
-          audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
-          tokenExpiration: _.get(args, 'config.tokenExpiration', WIKI.config.auth.tokenExpiration),
-          tokenRenewal: _.get(args, 'config.tokenRenewal', WIKI.config.auth.tokenRenewal)
-        }
-        await WIKI.configSvc.saveToDb(['auth'])
+        // WIKI.config.auth = {
+        //   audience: _.get(args, 'config.audience', WIKI.config.auth.audience),
+        //   tokenExpiration: _.get(args, 'config.tokenExpiration', WIKI.config.auth.tokenExpiration),
+        //   tokenRenewal: _.get(args, 'config.tokenRenewal', WIKI.config.auth.tokenRenewal)
+        // }
+        // await WIKI.configSvc.saveToDb(['auth'])
 
-        for (let str of args.strategies) {
-          await WIKI.models.authentication.query().patch({
-            isEnabled: str.isEnabled,
+        const previousStrategies = await WIKI.models.authentication.getStrategies()
+        for (const str of args.strategies) {
+          const newStr = {
+            displayName: str.displayName,
+            order: str.order,
             config: _.reduce(str.config, (result, value, key) => {
               _.set(result, `${value.key}`, _.get(JSON.parse(value.value), 'v', null))
               return result
@@ -191,8 +205,32 @@ module.exports = {
             selfRegistration: str.selfRegistration,
             domainWhitelist: { v: str.domainWhitelist },
             autoEnrollGroups: { v: str.autoEnrollGroups }
-          }).where('key', str.key)
+          }
+
+          if (_.some(previousStrategies, ['key', str.key])) {
+            await WIKI.models.authentication.query().patch({
+              key: str.key,
+              strategyKey: str.strategyKey,
+              ...newStr
+            }).where('key', str.key)
+          } else {
+            await WIKI.models.authentication.query().insert({
+              key: str.key,
+              strategyKey: str.strategyKey,
+              ...newStr
+            })
+          }
         }
+
+        for (const str of _.differenceBy(previousStrategies, args.strategies, 'key')) {
+          const hasUsers = await WIKI.models.users.query().count('* as total').where({ providerKey: str.key }).first()
+          if (_.toSafeInteger(hasUsers.total) > 0) {
+            throw new Error(`Cannot delete ${str.displayName} as 1 or more users are still using it.`)
+          } else {
+            await WIKI.models.authentication.query().delete().where('key', str.key)
+          }
+        }
+
         await WIKI.auth.activateStrategies()
         WIKI.events.outbound.emit('reloadAuthStrategies')
         return {

+ 5 - 0
server/graph/resolvers/group.js

@@ -102,8 +102,13 @@ module.exports = {
         throw new gql.GraphQLError('Some Page Rules contains unsafe or exponential time regex.')
       }
 
+      if (_.isEmpty(args.redirectOnLogin)) {
+        args.redirectOnLogin = '/'
+      }
+
       await WIKI.models.groups.query().patch({
         name: args.name,
+        redirectOnLogin: args.redirectOnLogin,
         permissions: JSON.stringify(args.permissions),
         pageRules: JSON.stringify(args.pageRules)
       }).where('id', args.id)

+ 13 - 14
server/graph/resolvers/page.js

@@ -171,18 +171,18 @@ module.exports = {
      * FETCH TAGS
      */
     async tags (obj, args, context, info) {
-      const pages = await WIKI.models.pages.query().column([
-        'path',
-        { locale: 'localeCode' },
-      ])
+      const pages = await WIKI.models.pages.query()
+        .column([
+          'path',
+          { locale: 'localeCode' }
+        ])
         .withGraphJoined('tags')
       const allTags = _.filter(pages, r => {
         return WIKI.auth.checkAccess(context.req.user, ['read:pages'], {
           path: r.path,
           locale: r.locale
         })
-      })
-        .flatMap(r => r.tags)
+      }).flatMap(r => r.tags)
       return _.orderBy(_.uniqBy(allTags, 'id'), ['tag'], ['asc'])
     },
     /**
@@ -190,10 +190,11 @@ module.exports = {
      */
     async searchTags (obj, args, context, info) {
       const query = _.trim(args.query)
-      const pages = await WIKI.models.pages.query().column([
-        'path',
-        { locale: 'localeCode' },
-      ])
+      const pages = await WIKI.models.pages.query()
+        .column([
+          'path',
+          { locale: 'localeCode' }
+        ])
         .withGraphJoined('tags')
         .modifyGraph('tags', builder => {
           builder.select('tag')
@@ -212,9 +213,7 @@ module.exports = {
           path: r.path,
           locale: r.locale
         })
-      })
-        .flatMap(r => r.tags)
-        .map(t => t.tag)
+      }).flatMap(r => r.tags).map(t => t.tag)
       return _.uniq(allTags).slice(0, 5)
     },
     /**
@@ -271,7 +270,7 @@ module.exports = {
      * FETCH PAGE LINKS
      */
     async links (obj, args, context, info) {
-      let results;
+      let results
 
       if (WIKI.config.db.type === 'mysql' || WIKI.config.db.type === 'mariadb' || WIKI.config.db.type === 'sqlite') {
         results = await WIKI.models.knex('pages')

+ 14 - 1
server/graph/resolvers/site.js

@@ -21,6 +21,11 @@ module.exports = {
         ...WIKI.config.seo,
         ...WIKI.config.features,
         ...WIKI.config.security,
+        authAutoLogin: WIKI.config.auth.autoLogin,
+        authLoginBgUrl: WIKI.config.auth.loginBgUrl,
+        authJwtAudience: WIKI.config.auth.audience,
+        authJwtExpiration: WIKI.config.auth.tokenExpiration,
+        authJwtRenewablePeriod: WIKI.config.auth.tokenRenewal,
         uploadMaxFileSize: WIKI.config.uploads.maxFileSize,
         uploadMaxFiles: WIKI.config.uploads.maxFiles
       }
@@ -60,6 +65,14 @@ module.exports = {
           analyticsId: _.get(args, 'analyticsId', WIKI.config.seo.analyticsId)
         }
 
+        WIKI.config.auth = {
+          autoLogin: _.get(args, 'authAutoLogin', WIKI.config.auth.autoLogin),
+          loginBgUrl: _.get(args, 'authLoginBgUrl', WIKI.config.auth.loginBgUrl),
+          audience: _.get(args, 'authJwtAudience', WIKI.config.auth.audience),
+          tokenExpiration: _.get(args, 'authJwtExpiration', WIKI.config.auth.tokenExpiration),
+          tokenRenewal: _.get(args, 'authJwtRenewablePeriod', WIKI.config.auth.tokenRenewal)
+        }
+
         WIKI.config.features = {
           featurePageRatings: _.get(args, 'featurePageRatings', WIKI.config.features.featurePageRatings),
           featurePageComments: _.get(args, 'featurePageComments', WIKI.config.features.featurePageComments),
@@ -83,7 +96,7 @@ module.exports = {
           maxFiles: _.get(args, 'uploadMaxFiles', WIKI.config.uploads.maxFiles)
         }
 
-        await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'contentLicense', 'seo', 'logoUrl', 'features', 'security', 'uploads'])
+        await WIKI.configSvc.saveToDb(['host', 'title', 'company', 'contentLicense', 'seo', 'logoUrl', 'auth', 'features', 'security', 'uploads'])
 
         if (WIKI.config.security.securityTrustProxy) {
           WIKI.app.enable('trust proxy')

+ 13 - 13
server/graph/schemas/authentication.graphql

@@ -19,9 +19,8 @@ type AuthenticationQuery {
 
   apiState: Boolean! @auth(requires: ["manage:system", "manage:api"])
 
-  strategies(
-    isEnabled: Boolean
-  ): [AuthenticationStrategy]
+  strategies: [AuthenticationStrategy] @auth(requires: ["manage:system"])
+  activeStrategies: [AuthenticationActiveStrategy]
 }
 
 # -----------------------------------------------
@@ -68,7 +67,6 @@ type AuthenticationMutation {
 
   updateStrategies(
     strategies: [AuthenticationStrategyInput]!
-    config: AuthenticationConfigInput
   ): DefaultResponse @auth(requires: ["manage:system"])
 
   regenerateCertificates: DefaultResponse @auth(requires: ["manage:system"])
@@ -81,9 +79,8 @@ type AuthenticationMutation {
 # -----------------------------------------------
 
 type AuthenticationStrategy {
-  isEnabled: Boolean!
   key: String!
-  props: [String]
+  props: [KeyValuePair] @auth(requires: ["manage:system"])
   title: String!
   description: String
   isAvailable: Boolean
@@ -92,6 +89,13 @@ type AuthenticationStrategy {
   color: String
   website: String
   icon: String
+}
+
+type AuthenticationActiveStrategy {
+  key: String!
+  strategy: AuthenticationStrategy!
+  displayName: String!
+  order: Int!
   config: [KeyValuePair] @auth(requires: ["manage:system"])
   selfRegistration: Boolean!
   domainWhitelist: [String]! @auth(requires: ["manage:system"])
@@ -112,20 +116,16 @@ type AuthenticationRegisterResponse {
 }
 
 input AuthenticationStrategyInput {
-  isEnabled: Boolean!
   key: String!
+  strategyKey: String!
   config: [KeyValuePairInput]
+  displayName: String!
+  order: Int!
   selfRegistration: Boolean!
   domainWhitelist: [String]!
   autoEnrollGroups: [Int]!
 }
 
-input AuthenticationConfigInput {
-  audience: String!
-  tokenExpiration: String!
-  tokenRenewal: String!
-}
-
 type AuthenticationApiKey {
   id: Int!
   name: String!

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

@@ -37,6 +37,7 @@ type GroupMutation {
   update(
     id: Int!
     name: String!
+    redirectOnLogin: String!
     permissions: [String]!
     pageRules: [PageRuleInput]!
   ): DefaultResponse @auth(requires: ["write:groups", "manage:groups", "manage:system"])
@@ -78,6 +79,7 @@ type Group {
   id: Int!
   name: String!
   isSystem: Boolean!
+  redirectOnLogin: String
   permissions: [String]!
   pageRules: [PageRule]
   users: [UserMinimal]

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

@@ -33,6 +33,11 @@ type SiteMutation {
     company: String
     contentLicense: String
     logoUrl: String
+    authAutoLogin: Boolean
+    authLoginBgUrl: String
+    authJwtAudience: String
+    authJwtExpiration: String
+    authJwtRenewablePeriod: String
     featurePageRatings: Boolean
     featurePageComments: Boolean
     featurePersonalWikis: Boolean
@@ -65,6 +70,11 @@ type SiteConfig {
   company: String!
   contentLicense: String!
   logoUrl: String!
+  authAutoLogin: Boolean
+  authLoginBgUrl: String
+  authJwtAudience: String
+  authJwtExpiration: String
+  authJwtRenewablePeriod: String
   featurePageRatings: Boolean!
   featurePageComments: Boolean!
   featurePersonalWikis: Boolean!

+ 25 - 48
server/models/authentication.js

@@ -17,11 +17,10 @@ module.exports = class Authentication extends Model {
   static get jsonSchema () {
     return {
       type: 'object',
-      required: ['key', 'isEnabled'],
+      required: ['key'],
 
       properties: {
         key: {type: 'string'},
-        isEnabled: {type: 'boolean'},
         selfRegistration: {type: 'boolean'}
       }
     }
@@ -35,8 +34,8 @@ module.exports = class Authentication extends Model {
     return WIKI.models.authentication.query().findOne({ key })
   }
 
-  static async getStrategies(isEnabled) {
-    const strategies = await WIKI.models.authentication.query().where(_.isBoolean(isEnabled) ? { isEnabled } : {})
+  static async getStrategies() {
+    const strategies = await WIKI.models.authentication.query().orderBy('order')
     return _.sortBy(strategies.map(str => ({
       ...str,
       domainWhitelist: _.get(str.domainWhitelist, 'v', []),
@@ -45,7 +44,7 @@ module.exports = class Authentication extends Model {
   }
 
   static async getStrategiesForLegacyClient() {
-    const strategies = await WIKI.models.authentication.query().select('key', 'selfRegistration').where({ isEnabled: true })
+    const strategies = await WIKI.models.authentication.query().select('key', 'selfRegistration')
     let formStrategies = []
     let socialStrategies = []
 
@@ -77,64 +76,42 @@ module.exports = class Authentication extends Model {
   }
 
   static async refreshStrategiesFromDisk() {
-    let trx
     try {
       const dbStrategies = await WIKI.models.authentication.query()
 
       // -> Fetch definitions from disk
       const authDirs = await fs.readdir(path.join(WIKI.SERVERPATH, 'modules/authentication'))
-      let diskStrategies = []
+      WIKI.data.authentication = []
       for (let dir of authDirs) {
-        const def = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), 'utf8')
-        diskStrategies.push(yaml.safeLoad(def))
+        const defRaw = await fs.readFile(path.join(WIKI.SERVERPATH, 'modules/authentication', dir, 'definition.yml'), 'utf8')
+        const def = yaml.safeLoad(defRaw)
+        WIKI.data.authentication.push({
+          ...def,
+          props: commonHelper.parseModuleProps(def.props)
+        })
       }
-      WIKI.data.authentication = diskStrategies.map(strategy => ({
-        ...strategy,
-        props: commonHelper.parseModuleProps(strategy.props)
-      }))
 
-      let newStrategies = []
-      for (let strategy of WIKI.data.authentication) {
-        if (!_.some(dbStrategies, ['key', strategy.key])) {
-          newStrategies.push({
-            key: strategy.key,
-            isEnabled: false,
-            config: _.transform(strategy.props, (result, value, key) => {
-              _.set(result, key, value.default)
-              return result
-            }, {}),
-            selfRegistration: false,
-            domainWhitelist: { v: [] },
-            autoEnrollGroups: { v: [] }
-          })
-        } else {
-          const strategyConfig = _.get(_.find(dbStrategies, ['key', strategy.key]), 'config', {})
+      for (const strategy of dbStrategies) {
+        const strategyDef = _.find(WIKI.data.authentication, ['key', strategy.strategyKey])
+        strategy.config = _.transform(strategyDef.props, (result, value, key) => {
+          if (!_.has(result, key)) {
+            _.set(result, key, value.default)
+          }
+          return result
+        }, strategy.config)
+
+        // Fix pre-2.5 strategies displayName
+        if (!strategy.displayName) {
           await WIKI.models.authentication.query().patch({
-            config: _.transform(strategy.props, (result, value, key) => {
-              if (!_.has(result, key)) {
-                _.set(result, key, value.default)
-              }
-              return result
-            }, strategyConfig)
+            displayName: strategyDef.title
           }).where('key', strategy.key)
         }
       }
-      if (newStrategies.length > 0) {
-        trx = await WIKI.models.Objection.transaction.start(WIKI.models.knex)
-        for (let strategy of newStrategies) {
-          await WIKI.models.authentication.query(trx).insert(strategy)
-        }
-        await trx.commit()
-        WIKI.logger.info(`Loaded ${newStrategies.length} new authentication strategies: [ OK ]`)
-      } else {
-        WIKI.logger.info(`No new authentication strategies found: [ SKIPPED ]`)
-      }
+
+      WIKI.logger.info(`Loaded ${WIKI.data.authentication.length} authentication strategies: [ OK ]`)
     } catch (err) {
       WIKI.logger.error(`Failed to scan or load new authentication providers: [ FAILED ]`)
       WIKI.logger.error(err)
-      if (trx) {
-        trx.rollback()
-      }
     }
   }
 }

+ 2 - 0
server/models/groups.js

@@ -14,6 +14,8 @@ module.exports = class Group extends Model {
       properties: {
         id: {type: 'integer'},
         name: {type: 'string'},
+        isSystem: {type: 'boolean'},
+        redirectOnLogin: {type: 'string'},
         createdAt: {type: 'string'},
         updatedAt: {type: 'string'}
       }

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

@@ -1,5 +1,5 @@
 key: local
-title: Local
+title: Local Database
 description: Built-in authentication for Wiki.js
 author: requarks.io
 logo: https://static.requarks.io/logo/wikijs.svg

+ 49 - 4
server/modules/authentication/oauth2/definition.yml

@@ -5,9 +5,54 @@ author: requarks.io
 logo: https://static.requarks.io/logo/oauth2.svg
 color: grey darken-4
 website: https://oauth.net/2/
+isAvailable: true
 useForm: false
 props:
-  clientId: String
-  clientSecret: String
-  authorizationURL: String
-  tokenURL: String
+  clientId:
+    type: String
+    title: Client ID
+    hint: Application Client ID
+    order: 1
+  clientSecret:
+    type: String
+    title: Client Secret
+    hint: Application Client Secret
+    order: 2
+  authorizationURL:
+    type: String
+    title: Authorization Endpoint URL
+    hint: The full URL to the authorization endpoint, used to get an authorization code.
+    order: 3
+  tokenURL:
+    type: String
+    title: Token Endpoint URL
+    hint: The full URL to the token endpoint, used to get an access token.
+    order: 4
+  mappingUID:
+    title: Unique ID Field Mapping
+    type: String
+    default: 'id'
+    hint: The field storing the user unique identifier, e.g. "id" or "_id".
+    maxWidth: 500
+    order: 20
+  mappingEmail:
+    title: Email Field Mapping
+    type: String
+    default: 'email'
+    hint: The field storing the user email, e.g. "email" or "mail".
+    maxWidth: 500
+    order: 21
+  mappingDisplayName:
+    title: Display Name Field Mapping
+    type: String
+    default: 'name'
+    hint: The field storing the user display name, e.g. "name", "displayName" or "username".
+    maxWidth: 500
+    order: 22
+  mappingPicture:
+    title: Avatar Picture Field Mapping
+    type: String
+    default: 'pictureUrl'
+    hint: The field storing the user avatar picture, e.g. "pictureUrl" or "avatarUrl".
+    maxWidth: 500
+    order: 23

+ 11 - 3
server/setup.js

@@ -253,9 +253,17 @@ module.exports = () => {
         throw new Error('Incorrect groups auto-increment configuration! Should start at 0 and increment by 1. Contact your database administrator.')
       }
 
-      // Load authentication strategies + enable local
-      await WIKI.models.authentication.refreshStrategiesFromDisk()
-      await WIKI.models.authentication.query().patch({ isEnabled: true }).where('key', 'local')
+      // Load local authentication strategy
+      await WIKI.models.authentication.query().insert({
+        key: 'local',
+        config: {},
+        selfRegistration: false,
+        domainWhitelist: {v: []},
+        autoEnrollGroups: {v: []},
+        order: 0,
+        strategyKey: 'local',
+        displayName: 'Local'
+      })
 
       // Load editors + enable default
       await WIKI.models.editors.refreshEditorsFromDisk()

+ 55 - 8
yarn.lock

@@ -5681,6 +5681,11 @@ caseless@~0.12.0:
   resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc"
   integrity sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=
 
+cash-dom@8.0.0:
+  version "8.0.0"
+  resolved "https://registry.yarnpkg.com/cash-dom/-/cash-dom-8.0.0.tgz#2069238ba1fc2caa7ef4b9066343e020afab8e87"
+  integrity sha512-uUv7JX91CQeACoovHur8eGV8Z3rnd3jjvU1t2EQzW0kOd96VzOnjZCDjbC0FP3LgMpWLcweQjK2UZ1rQ6MbljA==
+
 chalk@2.3.x:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.3.2.tgz#250dc96b07491bfd601e648d66ddf5f60c7a5c65"
@@ -8602,6 +8607,11 @@ file-type@14.6.2:
     token-types "^2.0.0"
     typedarray-to-buffer "^3.1.5"
 
+file@^0.2.2:
+  version "0.2.2"
+  resolved "https://registry.yarnpkg.com/file/-/file-0.2.2.tgz#c3dfd8f8cf3535ae455c2b423c2e52635d76b4d3"
+  integrity sha1-w9/Y+M81Na5FXCtCPC5SY112tNM=
+
 filepond-plugin-file-validate-type@1.2.5:
   version "1.2.5"
   resolved "https://registry.yarnpkg.com/filepond-plugin-file-validate-type/-/filepond-plugin-file-validate-type-1.2.5.tgz#b3588088d2b0d5dbdf706d068c0ecdf28140a8e3"
@@ -11266,7 +11276,7 @@ loader-utils@1.2.3, loader-utils@^1.0.2, loader-utils@^1.0.4, loader-utils@^1.1.
     emojis-list "^2.0.0"
     json5 "^1.0.1"
 
-loader-utils@^1.4.0:
+loader-utils@^1.0.0, loader-utils@^1.4.0:
   version "1.4.0"
   resolved "https://registry.yarnpkg.com/loader-utils/-/loader-utils-1.4.0.tgz#c579b5e34cb34b1a74edc6c1fb36bfa371d5a613"
   integrity sha512-qH0WSMBtn/oHuwjy/NucEgbx5dbxxnxup9s4PVXJUDHZBQY+s0NWA9rJf53RBnQZxfch7euUui7hpoAPvALZdA==
@@ -11647,6 +11657,17 @@ markdown-it@^8.4.2:
     mdurl "^1.0.1"
     uc.micro "^1.0.5"
 
+markdown-it@^10.0.0:
+  version "10.0.0"
+  resolved "https://registry.yarnpkg.com/markdown-it/-/markdown-it-10.0.0.tgz#abfc64f141b1722d663402044e43927f1f50a8dc"
+  integrity sha512-YWOP1j7UbDNz+TumYP1kpwnP0aEa711cJjrAQrzd0UXlbJfc5aAq0F/PZHjiioqDC1NKgvIMX+o+9Bk7yuM2dg==
+  dependencies:
+    argparse "^1.0.7"
+    entities "~2.0.0"
+    linkify-it "^2.0.0"
+    mdurl "^1.0.1"
+    uc.micro "^1.0.5"
+
 math-expression-evaluator@^1.2.14:
   version "1.2.17"
   resolved "https://registry.yarnpkg.com/math-expression-evaluator/-/math-expression-evaluator-1.2.17.tgz#de819fdbcd84dccd8fae59c6aeb79615b9d266ac"
@@ -12001,6 +12022,13 @@ mkdirp@0.3.0:
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.3.0.tgz#1bbf5ab1ba827af23575143490426455f481fe1e"
   integrity sha1-G79asbqCevI1dRQ0kEJkVfSB/h4=
 
+mkdirp@0.5.5, mkdirp@^0.5.3, mkdirp@^0.5.4:
+  version "0.5.5"
+  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
+  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
+  dependencies:
+    minimist "^1.2.5"
+
 mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1:
   version "0.5.1"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.1.tgz#30057438eac6cf7f8c4767f38648d6697d75c903"
@@ -12008,18 +12036,24 @@ mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@~0.5.1:
   dependencies:
     minimist "0.0.8"
 
-mkdirp@^0.5.3, mkdirp@^0.5.4:
-  version "0.5.5"
-  resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
-  integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
-  dependencies:
-    minimist "^1.2.5"
-
 mkdirp@^1.0.3, mkdirp@^1.0.4, mkdirp@~1.0.3:
   version "1.0.4"
   resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
   integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
 
+modernizr@^3.7.1:
+  version "3.11.2"
+  resolved "https://registry.yarnpkg.com/modernizr/-/modernizr-3.11.2.tgz#12410de335b79c4c02a19edfefd1bfca6896429b"
+  integrity sha512-sx262pafYJP1YPrPlMA4a+9WX5cQjvwp39213YNAqv1LZ4rHASFeNIEyLN+a5/F8/1yJR4ic/4wRFVsbK64TUQ==
+  dependencies:
+    doctrine "^3.0.0"
+    file "^0.2.2"
+    lodash "^4.17.15"
+    markdown-it "^10.0.0"
+    mkdirp "0.5.5"
+    requirejs "^2.3.6"
+    yargs "^15.3.1"
+
 moment-duration-format@2.3.2:
   version "2.3.2"
   resolved "https://registry.yarnpkg.com/moment-duration-format/-/moment-duration-format-2.3.2.tgz#5fa2b19b941b8d277122ff3f87a12895ec0d6212"
@@ -15530,6 +15564,11 @@ require_optional@^1.0.1:
     resolve-from "^2.0.0"
     semver "^5.1.0"
 
+requirejs@^2.3.6:
+  version "2.3.6"
+  resolved "https://registry.yarnpkg.com/requirejs/-/requirejs-2.3.6.tgz#e5093d9601c2829251258c0b9445d4d19fa9e7c9"
+  integrity sha512-ipEzlWQe6RK3jkzikgCupiTbTvm4S0/CAU5GlgptkN5SO6F3u0UD0K18wy6ErDqiCyP4J4YYe1HuAShvsxePLg==
+
 resolve-cwd@^2.0.0:
   version "2.0.0"
   resolved "https://registry.yarnpkg.com/resolve-cwd/-/resolve-cwd-2.0.0.tgz#00a9f7387556e27038eae232caa372a6a59b665a"
@@ -17984,6 +18023,14 @@ webpack-merge@4.2.2:
   dependencies:
     lodash "^4.17.15"
 
+webpack-modernizr-loader@5.0.0:
+  version "5.0.0"
+  resolved "https://registry.yarnpkg.com/webpack-modernizr-loader/-/webpack-modernizr-loader-5.0.0.tgz#6a7c07d3fac4b6e02964ee3be61819cf9ab811cc"
+  integrity sha512-D+FIZ03QtWNV536+cGp046qbJIcxPYEF0kGqP6YrM8Y1g6PqzXGdx8VYnK7VkfRuBdtqLL5rCerWYk1ncOSjJQ==
+  dependencies:
+    loader-utils "^1.0.0"
+    modernizr "^3.7.1"
+
 webpack-sources@^1.1.0, webpack-sources@^1.3.0, webpack-sources@^1.4.0, webpack-sources@^1.4.1, webpack-sources@^1.4.3:
   version "1.4.3"
   resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-1.4.3.tgz#eedd8ec0b928fbf1cbfe994e22d2d890f330a933"