Sfoglia il codice sorgente

feat(admin): migrate nav + auth to vue 3 composition, convert lodash to lodash-es

Nicolas Giard 3 anni fa
parent
commit
4675348c43

+ 1 - 0
ux/package.json

@@ -69,6 +69,7 @@
     "js-cookie": "3.0.1",
     "jwt-decode": "3.1.2",
     "lodash": "4.17.21",
+    "lodash-es": "4.17.21",
     "luxon": "2.4.0",
     "pinia": "2.0.14",
     "pug": "3.0.2",

+ 1 - 1
ux/src/layouts/AdminLayout.vue

@@ -95,7 +95,7 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
           q-item-section {{ t('admin.navigation.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white')
+        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/rendering`', v-ripple, active-class='bg-primary text-white', disabled)
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
           q-item-section {{ t('admin.rendering.title') }}

+ 1 - 1
ux/src/pages/AdminApi.vue

@@ -116,7 +116,7 @@ q-page.admin-api
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, watch } from 'vue'

+ 279 - 260
ux/src/pages/AdminAuth.vue

@@ -4,8 +4,8 @@ q-page.admin-mail
     .col-auto
       img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-security-lock.svg')
     .col.q-pl-md
-      .text-h5.text-primary.animated.fadeInLeft {{ $t('admin.auth.title') }}
-      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ $t('admin.auth.subtitle') }}
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.auth.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.auth.subtitle') }}
     .col-auto
       q-btn.q-mr-sm.acrylic-btn(
         icon='las la-question-circle'
@@ -17,11 +17,11 @@ q-page.admin-mail
         )
       q-btn(
         unelevated
-        icon='mdi-check'
-        :label='$t(`common.actions.apply`)'
+        icon='fa-solid fa-check'
+        :label='t(`common.actions.apply`)'
         color='secondary'
         @click='save'
-        :loading='loading'
+        :loading='state.loading > 0'
       )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
@@ -33,11 +33,11 @@ q-page.admin-mail
           dark
           )
           q-item(
-            v-for='str of activeStrategies'
+            v-for='str of state.activeStrategies'
             :key='str.key'
             active-class='bg-primary text-white'
-            :active='selectedStrategy === str.key'
-            @click='selectedStrategy = str.key'
+            :active='state.selectedStrategy === str.key'
+            @click='state.selectedStrategy = str.key'
             clickable
             )
             q-item-section(side)
@@ -52,7 +52,7 @@ q-page.admin-mail
       q-btn.q-mt-sm.full-width(
         color='primary'
         icon='las la-plus'
-        :label='$t(`admin.auth.addStrategy`)'
+        :label='t(`admin.auth.addStrategy`)'
         )
         q-menu(auto-close)
           q-list(style='min-width: 350px;')
@@ -261,275 +261,294 @@ q-page.admin-mail
     //-         .body-2 HTTP-POST
 </template>
 
-<script>
-import _ from 'lodash'
+<script setup>
 import gql from 'graphql-tag'
+import { find, reject } from 'lodash-es'
 import { v4 as uuid } from 'uuid'
-import { createMetaMixin } from 'quasar'
+
+import { useI18n } from 'vue-i18n'
+import { useMeta, useQuasar } from 'quasar'
+import { computed, onMounted, reactive, watch, nextTick } from 'vue'
+
+import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 import draggable from 'vuedraggable'
 
-export default {
-  mixins: [
-    createMetaMixin(function () {
-      return {
-        title: this.$t('admin.auth.title')
-      }
-    })
-  ],
-  components: {
-    draggable
-  },
-  filters: {
-    startCase (val) { return _.startCase(val) }
-  },
-  data () {
-    return {
-      groups: [],
-      strategies: [],
-      activeStrategies: [
-        {
-          key: 'local',
-          strategy: {
-            key: 'local',
-            title: 'Username-Password Authentication',
-            description: '',
-            useForm: true,
-            icon: '/_assets/icons/ultraviolet-data-protection.svg',
-            website: ''
-          },
-          config: [],
-          isEnabled: true,
-          displayName: 'Local Database',
-          selfRegistration: false,
-          domainWhitelist: '',
-          autoEnrollGroups: []
-        },
-        {
-          key: 'google',
-          strategy: {
-            key: 'google',
-            title: 'Google',
-            description: '',
-            useForm: true,
-            icon: '/_assets/icons/ultraviolet-google.svg',
-            website: ''
-          },
-          config: [],
-          isEnabled: true,
-          displayName: 'Google',
-          selfRegistration: false,
-          domainWhitelist: '',
-          autoEnrollGroups: []
-        },
-        {
-          key: 'slack',
-          strategy: {
-            key: 'slack',
-            title: 'Slack',
-            description: '',
-            useForm: true,
-            icon: '/_assets/icons/ultraviolet-slack.svg',
-            website: ''
-          },
-          config: [],
-          isEnabled: false,
-          displayName: 'Slack',
-          selfRegistration: false,
-          domainWhitelist: '',
-          autoEnrollGroups: []
-        }
-      ],
-      selectedStrategy: '',
-      host: '',
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.auth.title')
+})
+
+// DATA
+
+const state = reactive({
+  loading: 0,
+  groups: [],
+  strategies: [],
+  activeStrategies: [
+    {
+      key: 'local',
       strategy: {
-        strategy: {}
-      }
-    }
-  },
-  watch: {
-    selectedStrategy (newValue, oldValue) {
-      this.strategy = _.find(this.activeStrategies, ['key', newValue]) || {}
-    },
-    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'
-      })
+        key: 'local',
+        title: 'Username-Password Authentication',
+        description: '',
+        useForm: true,
+        icon: '/_assets/icons/ultraviolet-data-protection.svg',
+        website: ''
+      },
+      config: [],
+      isEnabled: true,
+      displayName: 'Local Database',
+      selfRegistration: false,
+      domainWhitelist: '',
+      autoEnrollGroups: []
     },
-    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,
-        isEnabled: true,
-        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])
+    {
+      key: 'google',
+      strategy: {
+        key: 'google',
+        title: 'Google',
+        description: '',
+        useForm: true,
+        icon: '/_assets/icons/ultraviolet-google.svg',
+        website: ''
+      },
+      config: [],
+      isEnabled: true,
+      displayName: 'Google',
+      selfRegistration: false,
+      domainWhitelist: '',
+      autoEnrollGroups: []
     },
-    async save () {
-      this.$store.commit('loadingStart', 'admin-auth-savestrategies')
-      try {
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation($strategies: [AuthenticationStrategyInput]!) {
-              authentication {
-                updateStrategies(strategies: $strategies) {
-                  responseResult {
-                    succeeded
-                    errorCode
-                    slug
-                    message
-                  }
-                }
-              }
-            }
-          `,
-          variables: {
-            strategies: this.activeStrategies.map((str, idx) => ({
-              key: str.key,
-              strategyKey: str.strategy.key,
-              displayName: str.displayName,
-              order: idx,
-              isEnabled: str.isEnabled,
-              config: str.config.map(cfg => ({ ...cfg, value: JSON.stringify({ v: cfg.value.value }) })),
-              selfRegistration: str.selfRegistration,
-              domainWhitelist: str.domainWhitelist,
-              autoEnrollGroups: str.autoEnrollGroups
-            }))
-          }
-        })
-        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)
-      }
-      this.$store.commit('loadingStop', 'admin-auth-savestrategies')
+    {
+      key: 'slack',
+      strategy: {
+        key: 'slack',
+        title: 'Slack',
+        description: '',
+        useForm: true,
+        icon: '/_assets/icons/ultraviolet-slack.svg',
+        website: ''
+      },
+      config: [],
+      isEnabled: false,
+      displayName: 'Slack',
+      selfRegistration: false,
+      domainWhitelist: '',
+      autoEnrollGroups: []
     }
-  },
-  apollo: {
-    strategies: {
-      query: gql`
-        query {
-          authentication {
-            strategies {
-              key
-              title
-              description
-              isAvailable
-              useForm
-              logo
-              website
-              props {
-                key
-                value
-              }
-            }
-          }
-        }
-      `,
-      skip: true,
-      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')
+  ],
+  selectedStrategy: '',
+  host: '',
+  strategy: {
+    strategy: {}
+  }
+})
+
+// WATCHERS
+
+watch(() => state.selectedStrategy, (newValue, oldValue) => {
+  state.strategy = find(state.activeStrategies, ['key', newValue]) || {}
+})
+watch(() => state.activeStrategies, (newValue, oldValue) => {
+  state.selectedStrategy = 'local'
+})
+
+// METHODS
+
+async function 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'
+  })
+}
+
+function addStrategy (str) {
+  const newStr = {
+    key: uuid(),
+    strategy: str,
+    config: str.props.map(c => ({
+      key: c.key,
+      value: {
+        ...c,
+        value: c.default
       }
-    },
-    activeStrategies: {
-      query: gql`
-        query {
+    })),
+    order: state.activeStrategies.length,
+    isEnabled: true,
+    displayName: str.title,
+    selfRegistration: false,
+    domainWhitelist: [],
+    autoEnrollGroups: []
+  }
+  state.activeStrategies = [...state.activeStrategies, newStr]
+  nextTick(() => {
+    state.selectedStrategy = newStr.key
+  })
+}
+
+function deleteStrategy () {
+  state.activeStrategies = reject(state.activeStrategies, ['key', state.strategy.key])
+}
+
+async function save () {
+  state.loading++
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation($strategies: [AuthenticationStrategyInput]!) {
           authentication {
-            activeStrategies {
-              key
-              strategy {
-                key
-                title
-                description
-                useForm
-                logo
-                website
+            updateStrategies(strategies: $strategies) {
+              responseResult {
+                succeeded
+                errorCode
+                slug
+                message
               }
-              config {
-                key
-                value
-              }
-              order
-              isEnabled
-              displayName
-              selfRegistration
-              domainWhitelist
-              autoEnrollGroups
             }
           }
         }
       `,
-      skip: true,
-      fetchPolicy: 'network-only',
-      update: (data) => _.sortBy(_.get(data, 'authentication.activeStrategies', []).map(str => ({
-        ...str,
-        config: _.sortBy(str.config.map(cfg => ({
-          ...cfg,
-          value: JSON.parse(cfg.value)
-        })), [t => t.value.order])
-      })), ['order']),
-      watchLoading (isLoading) {
-        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-activestrategies-refresh')
-      }
-    },
-    groups: {
-      query: gql`{ test }`,
-      fetchPolicy: 'network-only',
-      update: (data) => data.groups.list,
-      watchLoading (isLoading) {
-        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh')
-      }
-    },
-    host: {
-      query: gql`{ test }`,
-      fetchPolicy: 'network-only',
-      update: (data) => _.cloneDeep(data.site.config.host),
-      watchLoading (isLoading) {
-        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-host-refresh')
+      variables: {
+        strategies: this.activeStrategies.map((str, idx) => ({
+          key: str.key,
+          strategyKey: str.strategy.key,
+          displayName: str.displayName,
+          order: idx,
+          isEnabled: str.isEnabled,
+          config: str.config.map(cfg => ({ ...cfg, value: JSON.stringify({ v: cfg.value.value }) })),
+          selfRegistration: str.selfRegistration,
+          domainWhitelist: str.domainWhitelist,
+          autoEnrollGroups: str.autoEnrollGroups
+        }))
       }
+    })
+    if (resp?.data?.authentication?.updateStrategies?.operation.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.auth.saveSuccess')
+      })
+    } else {
+      throw new Error(resp?.data?.authentication?.updateStrategies?.operation?.message || t('common.error.unexpected'))
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to save site theme config',
+      caption: err.message
+    })
   }
+  state.loading--
 }
+
+// apollo: {
+//   strategies: {
+//     query: gql`
+//       query {
+//         authentication {
+//           strategies {
+//             key
+//             title
+//             description
+//             isAvailable
+//             useForm
+//             logo
+//             website
+//             props {
+//               key
+//               value
+//             }
+//           }
+//         }
+//       }
+//     `,
+//     skip: true,
+//     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
+//             isEnabled
+//             displayName
+//             selfRegistration
+//             domainWhitelist
+//             autoEnrollGroups
+//           }
+//         }
+//       }
+//     `,
+//     skip: true,
+//     fetchPolicy: 'network-only',
+//     update: (data) => _.sortBy(_.get(data, 'authentication.activeStrategies', []).map(str => ({
+//       ...str,
+//       config: _.sortBy(str.config.map(cfg => ({
+//         ...cfg,
+//         value: JSON.parse(cfg.value)
+//       })), [t => t.value.order])
+//     })), ['order']),
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-activestrategies-refresh')
+//     }
+//   },
+//   groups: {
+//     query: gql`{ test }`,
+//     fetchPolicy: 'network-only',
+//     update: (data) => data.groups.list,
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-groups-refresh')
+//     }
+//   },
+//   host: {
+//     query: gql`{ test }`,
+//     fetchPolicy: 'network-only',
+//     update: (data) => _.cloneDeep(data.site.config.host),
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-auth-host-refresh')
+//     }
+//   }
 </script>

+ 1 - 1
ux/src/pages/AdminExtensions.vue

@@ -99,7 +99,7 @@ q-page.admin-extensions
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, watch } from 'vue'

+ 1 - 1
ux/src/pages/AdminFlags.vue

@@ -82,7 +82,7 @@ q-page.admin-flags
 <script setup>
 import gql from 'graphql-tag'
 import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
-import _transform from 'lodash/transform'
+import { transform } from 'lodash-es'
 import { useMeta, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 

+ 1 - 1
ux/src/pages/AdminGeneral.vue

@@ -391,7 +391,7 @@ q-page.admin-general
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive, watch } from 'vue'

+ 1 - 1
ux/src/pages/AdminGroups.vue

@@ -93,7 +93,7 @@ q-page.admin-groups
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'

+ 3 - 5
ux/src/pages/AdminLocale.vue

@@ -143,9 +143,7 @@ q-page.admin-locale
 
 <script setup>
 import gql from 'graphql-tag'
-import filter from 'lodash/filter'
-import _get from 'lodash/get'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep, filter } from 'lodash-es'
 
 import LocaleInstallDialog from '../components/LocaleInstallDialog.vue'
 
@@ -278,7 +276,7 @@ async function download (lc) {
       locale: lc.code
     }
   })
-  const resp = _get(respRaw, 'data.localization.downloadLocale.responseResult', {})
+  const resp = respRaw?.data?.localization?.downloadLocale?.responseResult || {}
   if (resp.succeeded) {
     lc.isDownloading = false
     lc.isInstalled = true
@@ -331,7 +329,7 @@ async function save () {
       namespaces: state.namespaces
     }
   })
-  const resp = _get(respRaw, 'data.localization.updateLocale.responseResult', {})
+  const resp = respRaw?.data?.localization?.updateLocale?.responseResult || {}
   if (resp.succeeded) {
     // Change UI language
     this.$i18n.locale = state.selectedLocale

+ 1 - 2
ux/src/pages/AdminLogin.vue

@@ -176,8 +176,7 @@ q-page.admin-login
 </template>
 
 <script setup>
-import { get } from 'vuex-pathify'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import gql from 'graphql-tag'
 import draggable from 'vuedraggable'
 

+ 4 - 6
ux/src/pages/AdminMail.vue

@@ -298,9 +298,7 @@ q-page.admin-mail
 </template>
 
 <script setup>
-import toSafeInteger from 'lodash/toSafeInteger'
-import _get from 'lodash/get'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep, toSafeInteger } from 'lodash-es'
 import gql from 'graphql-tag'
 
 import { useI18n } from 'vue-i18n'
@@ -477,7 +475,7 @@ async function sendTest () {
           sendMailTest(
             recipientEmail: $recipientEmail
           ) {
-            status {
+            operation {
               succeeded
               slug
               message
@@ -489,8 +487,8 @@ async function sendTest () {
         recipientEmail: state.testEmail
       }
     })
-    if (!_get(resp, 'data.sendMailTest.status.succeeded', false)) {
-      throw new Error(_get(resp, 'data.sendMailTest.status.message', 'An unexpected error occurred.'))
+    if (!resp?.data?.sendMailTest?.operation?.succeeded) {
+      throw new Error(resp?.data?.sendMailTest?.operation?.message || 'An unexpected error occurred.')
     }
 
     state.testEmail = ''

+ 270 - 251
ux/src/pages/AdminNavigation.vue

@@ -4,8 +4,8 @@ q-page.admin-navigation
     .col-auto
       img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-tree-structure.svg')
     .col.q-pl-md
-      .text-h5.text-primary.animated.fadeInLeft {{ $t('admin.navigation.title') }}
-      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ $t('admin.navigation.subtitle') }}
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.navigation.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.navigation.subtitle') }}
     .col-auto
       q-btn.acrylic-btn.q-mr-sm(
         icon='las la-question-circle'
@@ -19,16 +19,16 @@ q-page.admin-navigation
         icon='las la-redo-alt'
         flat
         color='secondary'
-        :loading='loading > 0'
+        :loading='state.loading > 0'
         @click='load'
         )
       q-btn(
         unelevated
-        icon='mdi-check'
-        :label='$t(`common.actions.apply`)'
+        icon='fa-solid fa-check'
+        :label='t(`common.actions.apply`)'
         color='secondary'
         @click='save'
-        :disabled='loading > 0'
+        :disabled='state.loading > 0'
       )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
@@ -306,278 +306,297 @@ q-page.admin-navigation
 //-   page-selector(mode='select', v-model='selectPageModal', :open-handler='selectPageHandle', path='home', :locale='currentLang')
 </template>
 
-<script>
-import _ from 'lodash'
+<script setup>
 import gql from 'graphql-tag'
+import { find, intersectionBy, pull, unionBy } from 'lodash-es'
 import { v4 as uuid } from 'uuid'
-import { createMetaMixin } from 'quasar'
+
+import { useI18n } from 'vue-i18n'
+import { useMeta, useQuasar } from 'quasar'
+import { computed, onMounted, reactive, watch, nextTick } from 'vue'
+
+import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 import draggable from 'vuedraggable'
 
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+const siteStore = useSiteStore()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.navigation.title')
+})
+
+// DATA
+
 const siteConfig = { lang: 'en' }
 const siteLangs = [{ code: 'en' }]
 
-export default {
-  mixins: [
-    createMetaMixin(function () {
-      return {
-        title: this.$t('admin.navigation.title')
-      }
-    })
-  ],
-  components: {
-    draggable
-  },
-  meta () {
-    return {
-      title: this.$t('admin.navigation.title')
-    }
+const state = reactive({
+  loading: 0,
+  selectPageModal: false,
+  trees: [],
+  current: {},
+  currentLang: siteConfig.lang,
+  groups: [],
+  copyFromLocaleDialogIsShown: false,
+  config: {
+    mode: 'NONE'
   },
-  data () {
-    return {
-      loading: false,
-      selectPageModal: false,
-      trees: [],
-      current: {},
-      currentLang: siteConfig.lang,
-      groups: [],
-      copyFromLocaleDialogIsShown: false,
-      config: {
-        mode: 'NONE'
-      },
-      allLocales: [],
-      copyFromLocaleCode: 'en'
-    }
+  allLocales: [],
+  copyFromLocaleCode: 'en'
+})
+
+// COMPUTED
+
+const navTypes = computed(() => ([
+  { text: t('navigation.navType.external'), value: 'external' },
+  { text: t('navigation.navType.externalblank'), value: 'externalblank' },
+  { text: t('navigation.navType.home'), value: 'home' },
+  { text: t('navigation.navType.page'), value: 'page' }
+  // { text: t('navigation.navType.searchQuery'), value: 'search' }
+]))
+
+const locales = computed(() => {
+  return intersectionBy(state.allLocales, unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], 'code'), 'code')
+})
+
+const currentTree = computed({
+  get () {
+    return find(state.trees, ['locale', state.currentLang])?.items || []
   },
-  computed: {
-    navTypes () {
-      return [
-        { text: this.$t('navigation.navType.external'), value: 'external' },
-        { text: this.$t('navigation.navType.externalblank'), value: 'externalblank' },
-        { text: this.$t('navigation.navType.home'), value: 'home' },
-        { text: this.$t('navigation.navType.page'), value: 'page' }
-        // { text: this.$t('navigation.navType.searchQuery'), value: 'search' }
-      ]
-    },
-    locales () {
-      return _.intersectionBy(this.allLocales, _.unionBy(siteLangs, [{ code: 'en' }, { code: siteConfig.lang }], 'code'), 'code')
-    },
-    currentTree: {
-      get () {
-        return _.get(_.find(this.trees, ['locale', this.currentLang]), 'items', null) || []
-      },
-      set (val) {
-        const tree = _.find(this.trees, ['locale', this.currentLang])
-        if (tree) {
-          tree.items = val
-        } else {
-          this.trees = [...this.trees, {
-            locale: this.currentLang,
-            items: val
-          }]
-        }
-      }
+  set (val) {
+    const tree = find(state.trees, ['locale', state.currentLang])
+    if (tree) {
+      tree.items = val
+    } else {
+      state.trees = [...state.trees, {
+        locale: state.currentLang,
+        items: val
+      }]
     }
-  },
-  watch: {
-    currentLang (newValue, oldValue) {
-      this.$nextTick(() => {
-        if (this.currentTree.length > 0) {
-          this.current = this.currentTree[0]
-        } else {
-          this.current = {}
-        }
-      })
+  }
+})
+
+// WATCHERS
+
+watch(() => state.currentLang, (newValue, oldValue) => {
+  nextTick(() => {
+    if (state.currentTree.length > 0) {
+      state.current = state.currentTree[0]
+    } else {
+      state.current = {}
     }
-  },
-  methods: {
-    async load () {
-
-    },
-    addItem (kind) {
-      let newItem = {
-        id: uuid(),
-        kind,
-        visibilityMode: 'all',
-        visibilityGroups: []
-      }
-      switch (kind) {
-        case 'link':
-          newItem = {
-            ...newItem,
-            label: this.$t('navigation.untitled', { kind: this.$t('navigation.link') }),
-            icon: 'mdi-chevron-right',
-            targetType: 'home',
-            target: ''
-          }
-          break
-        case 'header':
-          newItem.label = this.$t('navigation.untitled', { kind: this.$t('navigation.header') })
-          break
+  })
+})
+
+// METHODS
+
+async function load () {
+
+}
+
+function addItem (kind) {
+  let newItem = {
+    id: uuid(),
+    kind,
+    visibilityMode: 'all',
+    visibilityGroups: []
+  }
+  switch (kind) {
+    case 'link':
+      newItem = {
+        ...newItem,
+        label: t('navigation.untitled', { kind: t('navigation.link') }),
+        icon: 'mdi-chevron-right',
+        targetType: 'home',
+        target: ''
       }
-      this.currentTree = [...this.currentTree, newItem]
-      this.current = newItem
-    },
-    deleteItem (item) {
-      this.currentTree = _.pull(this.currentTree, item)
-      this.current = {}
-    },
-    selectItem (item) {
-      this.current = item
-    },
-    selectPage () {
-      this.selectPageModal = true
-    },
-    selectPageHandle ({ path, locale }) {
-      this.current.target = `/${locale}/${path}`
-    },
-    copyFromLocale () {
-      this.copyFromLocaleDialogIsShown = false
-      this.currentTree = [...this.currentTree, ..._.get(_.find(this.trees, ['locale', this.copyFromLocaleCode]), 'items', null) || []]
-    },
-    async save () {
-      this.$store.commit('loadingStart', 'admin-navigation-save')
-      try {
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {
-              navigation{
-                updateTree(tree: $tree) {
-                  responseResult {
-                    succeeded
-                    errorCode
-                    slug
-                    message
-                  }
-                },
-                updateConfig(mode: $mode) {
-                  responseResult {
-                    succeeded
-                    errorCode
-                    slug
-                    message
-                  }
-                }
+      break
+    case 'header':
+      newItem.label = t('navigation.untitled', { kind: t('navigation.header') })
+      break
+  }
+  state.currentTree = [...state.currentTree, newItem]
+  state.current = newItem
+}
+
+function deleteItem (item) {
+  state.currentTree = pull(state.currentTree, item)
+  state.current = {}
+}
+
+function selectItem (item) {
+  state.current = item
+}
+
+function selectPage () {
+  state.selectPageModal = true
+}
+
+function selectPageHandle ({ path, locale }) {
+  state.current.target = `/${locale}/${path}`
+}
+
+function copyFromLocale () {
+  state.copyFromLocaleDialogIsShown = false
+  state.currentTree = [...state.currentTree, ...find(state.trees, ['locale', state.copyFromLocaleCode])?.items || []]
+}
+
+async function save () {
+  this.$store.commit('loadingStart', 'admin-navigation-save')
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation ($tree: [NavigationTreeInput]!, $mode: NavigationMode!) {
+          navigation{
+            updateTree(tree: $tree) {
+              responseResult {
+                succeeded
+                errorCode
+                slug
+                message
+              }
+            },
+            updateConfig(mode: $mode) {
+              responseResult {
+                succeeded
+                errorCode
+                slug
+                message
               }
             }
-          `,
-          variables: {
-            tree: this.trees,
-            mode: this.config.mode
           }
-        })
-        if (_.get(resp, 'data.navigation.updateTree.responseResult.succeeded', false) && _.get(resp, 'data.navigation.updateConfig.responseResult.succeeded', false)) {
-          this.$store.commit('showNotification', {
-            message: this.$t('navigation.saveSuccess'),
-            style: 'success',
-            icon: 'check'
-          })
-        } else {
-          throw new Error(_.get(resp, 'data.navigation.updateTree.responseResult.message', 'An unexpected error occurred.'))
         }
-      } catch (err) {
-        this.$store.commit('pushGraphError', err)
+      `,
+      variables: {
+        tree: state.trees,
+        mode: state.config.mode
       }
-      this.$store.commit('loadingStop', 'admin-navigation-save')
-    },
-    async refresh () {
-      await this.$apollo.queries.trees.refetch()
-      this.current = {}
+    })
+    if (resp?.data.navigation.updateTree.responseResult.succeeded && resp?.data.navigation.updateConfig.responseResult.succeeded) {
       this.$store.commit('showNotification', {
-        message: 'Navigation has been refreshed.',
+        message: t('navigation.saveSuccess'),
         style: 'success',
-        icon: 'cached'
+        icon: 'check'
       })
+    } else {
+      throw new Error(resp?.data.navigation.updateTree.operation.message || 'An unexpected error occurred.')
     }
+  } catch (err) {
+    this.$store.commit('pushGraphError', err)
   }
-  // apollo: {
-  //   config: {
-  //     query: gql`
-  //       {
-  //         navigation {
-  //           config {
-  //             mode
-  //           }
-  //         }
-  //       }
-  //     `,
-  //     fetchPolicy: 'network-only',
-  //     update: (data) => _.cloneDeep(data.navigation.config),
-  //     watchLoading (isLoading) {
-  //       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-config')
-  //     }
-  //   },
-  //   trees: {
-  //     query: gql`
-  //       {
-  //         navigation {
-  //           tree {
-  //             locale
-  //             items {
-  //               id
-  //               kind
-  //               label
-  //               icon
-  //               targetType
-  //               target
-  //               visibilityMode
-  //               visibilityGroups
-  //             }
-  //           }
-  //         }
-  //       }
-  //     `,
-  //     fetchPolicy: 'network-only',
-  //     update: (data) => _.cloneDeep(data.navigation.tree),
-  //     watchLoading (isLoading) {
-  //       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-tree')
-  //     }
-  //   },
-  //   groups: {
-  //     query: gql`
-  //       query {
-  //         groups {
-  //           list {
-  //             id
-  //             name
-  //             isSystem
-  //             userCount
-  //             createdAt
-  //             updatedAt
-  //           }
-  //         }
-  //       }
-  //     `,
-  //     fetchPolicy: 'network-only',
-  //     update: (data) => data.groups.list,
-  //     watchLoading (isLoading) {
-  //       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-groups')
-  //     }
-  //   },
-  //   allLocales: {
-  //     query: gql`
-  //       {
-  //         localization {
-  //           locales {
-  //             code
-  //             name
-  //             nativeName
-  //           }
-  //         }
-  //       }
-  //     `,
-  //     fetchPolicy: 'network-only',
-  //     update: (data) => data.localization.locales,
-  //     watchLoading (isLoading) {
-  //       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-locales')
-  //     }
-  //   }
-  // }
+  this.$store.commit('loadingStop', 'admin-navigation-save')
 }
+
+async function refresh () {
+  load()
+  state.current = {}
+  this.$store.commit('showNotification', {
+    message: 'Navigation has been refreshed.',
+    style: 'success',
+    icon: 'cached'
+  })
+}
+
+// apollo: {
+//   config: {
+//     query: gql`
+//       {
+//         navigation {
+//           config {
+//             mode
+//           }
+//         }
+//       }
+//     `,
+//     fetchPolicy: 'network-only',
+//     update: (data) => _.cloneDeep(data.navigation.config),
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-config')
+//     }
+//   },
+//   trees: {
+//     query: gql`
+//       {
+//         navigation {
+//           tree {
+//             locale
+//             items {
+//               id
+//               kind
+//               label
+//               icon
+//               targetType
+//               target
+//               visibilityMode
+//               visibilityGroups
+//             }
+//           }
+//         }
+//       }
+//     `,
+//     fetchPolicy: 'network-only',
+//     update: (data) => _.cloneDeep(data.navigation.tree),
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-tree')
+//     }
+//   },
+//   groups: {
+//     query: gql`
+//       query {
+//         groups {
+//           list {
+//             id
+//             name
+//             isSystem
+//             userCount
+//             createdAt
+//             updatedAt
+//           }
+//         }
+//       }
+//     `,
+//     fetchPolicy: 'network-only',
+//     update: (data) => data.groups.list,
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-groups')
+//     }
+//   },
+//   allLocales: {
+//     query: gql`
+//       {
+//         localization {
+//           locales {
+//             code
+//             name
+//             nativeName
+//           }
+//         }
+//       }
+//     `,
+//     fetchPolicy: 'network-only',
+//     update: (data) => data.localization.locales,
+//     watchLoading (isLoading) {
+//       this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-navigation-locales')
+//     }
+//   }
+// }
 </script>
 
 <style lang='scss' scoped>
-
 .clickable {
   cursor: pointer;
 

+ 2 - 3
ux/src/pages/AdminSecurity.vue

@@ -323,9 +323,8 @@ q-page.admin-mail
 </template>
 
 <script setup>
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import gql from 'graphql-tag'
-import _get from 'lodash/get'
 import filesize from 'filesize'
 import filesizeParser from 'filesize-parser'
 
@@ -492,7 +491,7 @@ async function save () {
         uploadMaxFileSize: filesizeParser(state.humanUploadMaxFileSize || '0')
       }
     })
-    const resp = _get(respRaw, 'data.updateSystemSecurity.status', {})
+    const resp = respRaw?.data?.updateSystemSecurity?.status || {}
     if (resp.succeeded) {
       $q.notify({
         type: 'positive',

+ 1 - 3
ux/src/pages/AdminStorage.vue

@@ -583,10 +583,8 @@ q-page.admin-storage
 </template>
 
 <script setup>
-import find from 'lodash/find'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep, find, transform } from 'lodash-es'
 import gql from 'graphql-tag'
-import transform from 'lodash/transform'
 import * as VNetworkGraph from 'v-network-graph'
 
 import { useI18n } from 'vue-i18n'

+ 1 - 1
ux/src/pages/AdminSystem.vue

@@ -227,7 +227,7 @@ q-page.admin-system
 </template>
 
 <script setup>
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import gql from 'graphql-tag'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'

+ 1 - 2
ux/src/pages/AdminTheme.vue

@@ -203,8 +203,7 @@ q-page.admin-theme
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
-import startCase from 'lodash/startCase'
+import { cloneDeep, startCase } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive, watch } from 'vue'

+ 1 - 1
ux/src/pages/AdminUsers.vue

@@ -106,7 +106,7 @@ q-page.admin-groups
 
 <script setup>
 import gql from 'graphql-tag'
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import { DateTime } from 'luxon'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'

+ 1 - 1
ux/src/pages/AdminWebhooks.vue

@@ -92,7 +92,7 @@ q-page.admin-webhooks
 </template>
 
 <script setup>
-import cloneDeep from 'lodash/cloneDeep'
+import { cloneDeep } from 'lodash-es'
 import gql from 'graphql-tag'
 
 import { useI18n } from 'vue-i18n'

+ 2 - 2
ux/src/router/routes.js

@@ -35,12 +35,12 @@ const routes = [
       { path: ':siteid/editors', component: () => import('../pages/AdminEditors.vue') },
       { path: ':siteid/locale', component: () => import('../pages/AdminLocale.vue') },
       { path: ':siteid/login', component: () => import('../pages/AdminLogin.vue') },
-      // { path: ':siteid/navigation', component: () => import('../pages/AdminNavigation.vue') },
+      { path: ':siteid/navigation', component: () => import('../pages/AdminNavigation.vue') },
       // { path: ':siteid/rendering', component: () => import('../pages/AdminRendering.vue') },
       { path: ':siteid/storage/:id?', component: () => import('../pages/AdminStorage.vue') },
       { path: ':siteid/theme', component: () => import('../pages/AdminTheme.vue') },
       // -> Users
-      // { path: 'auth', component: () => import('../pages/AdminAuth.vue') },
+      { path: 'auth', component: () => import('../pages/AdminAuth.vue') },
       { path: 'groups/:id?/:section?', component: () => import('../pages/AdminGroups.vue') },
       { path: 'users/:id?/:section?', component: () => import('../pages/AdminUsers.vue') },
       // -> System

+ 8 - 0
ux/yarn.lock

@@ -4702,6 +4702,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"lodash-es@npm:4.17.21":
+  version: 4.17.21
+  resolution: "lodash-es@npm:4.17.21"
+  checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2
+  languageName: node
+  linkType: hard
+
 "lodash._reinterpolate@npm:^3.0.0":
   version: 3.0.0
   resolution: "lodash._reinterpolate@npm:3.0.0"
@@ -6844,6 +6851,7 @@ __metadata:
     js-cookie: 3.0.1
     jwt-decode: 3.1.2
     lodash: 4.17.21
+    lodash-es: 4.17.21
     luxon: 2.4.0
     pinia: 2.0.14
     pug: 3.0.2