Selaa lähdekoodia

feat(admin): migrate users to vue 3 composable

Nicolas Giard 3 vuotta sitten
vanhempi
sitoutus
47ed7b371c

+ 9 - 9
server/graph/resolvers/user.js

@@ -89,7 +89,7 @@ module.exports = {
         await WIKI.models.users.createNewUser({ ...args, passwordRaw: args.password, isVerified: true })
 
         return {
-          status: graphHelper.generateSuccess('User created successfully')
+          operation: graphHelper.generateSuccess('User created successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -106,7 +106,7 @@ module.exports = {
         WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' })
 
         return {
-          status: graphHelper.generateSuccess('User deleted successfully')
+          operation: graphHelper.generateSuccess('User deleted successfully')
         }
       } catch (err) {
         if (err.message.indexOf('foreign') >= 0) {
@@ -121,7 +121,7 @@ module.exports = {
         await WIKI.models.users.updateUser(args.id, args.patch)
 
         return {
-          status: graphHelper.generateSuccess('User updated successfully')
+          operation: graphHelper.generateSuccess('User updated successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -132,7 +132,7 @@ module.exports = {
         await WIKI.models.users.query().patch({ isVerified: true }).findById(args.id)
 
         return {
-          status: graphHelper.generateSuccess('User verified successfully')
+          operation: graphHelper.generateSuccess('User verified successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -143,7 +143,7 @@ module.exports = {
         await WIKI.models.users.query().patch({ isActive: true }).findById(args.id)
 
         return {
-          status: graphHelper.generateSuccess('User activated successfully')
+          operation: graphHelper.generateSuccess('User activated successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -160,7 +160,7 @@ module.exports = {
         WIKI.events.outbound.emit('addAuthRevoke', { id: args.id, kind: 'u' })
 
         return {
-          status: graphHelper.generateSuccess('User deactivated successfully')
+          operation: graphHelper.generateSuccess('User deactivated successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -171,7 +171,7 @@ module.exports = {
         await WIKI.models.users.query().patch({ tfaIsActive: true, tfaSecret: null }).findById(args.id)
 
         return {
-          status: graphHelper.generateSuccess('User 2FA enabled successfully')
+          operation: graphHelper.generateSuccess('User 2FA enabled successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -182,7 +182,7 @@ module.exports = {
         await WIKI.models.users.query().patch({ tfaIsActive: false, tfaSecret: null }).findById(args.id)
 
         return {
-          status: graphHelper.generateSuccess('User 2FA disabled successfully')
+          operation: graphHelper.generateSuccess('User 2FA disabled successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -225,7 +225,7 @@ module.exports = {
         const newToken = await WIKI.models.users.refreshToken(usr.id)
 
         return {
-          status: graphHelper.generateSuccess('User profile updated successfully'),
+          operation: graphHelper.generateSuccess('User profile updated successfully'),
           jwt: newToken.token
         }
       } catch (err) {

+ 1 - 0
ux/package.json

@@ -76,6 +76,7 @@
     "uuid": "8.3.2",
     "v-network-graph": "0.5.16",
     "vue": "3.2.31",
+    "vue-codemirror": "5.0.1",
     "vue-i18n": "9.1.10",
     "vue-router": "4.0.15",
     "vuedraggable": "4.1.0",

+ 1 - 1
ux/src/components/GroupEditOverlay.vue

@@ -501,7 +501,7 @@ import { fileOpen } from 'browser-fs-access'
 
 import { useI18n } from 'vue-i18n'
 import { exportFile, useQuasar } from 'quasar'
-import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
+import { computed, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'

+ 146 - 126
ux/src/components/UserChangePwdDialog.vue

@@ -1,24 +1,21 @@
 <template lang="pug">
-q-dialog(ref='dialog', @hide='onDialogHide')
+q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card(style='min-width: 650px;')
     q-card-section.card-header
       q-icon(name='img:/_assets/icons/fluent-password-reset.svg', left, size='sm')
-      span {{$t(`admin.users.changePassword`)}}
+      span {{t(`admin.users.changePassword`)}}
     q-form.q-py-sm(ref='changeUserPwdForm', @submit='save')
       q-item
         blueprint-icon(icon='password')
         q-item-section
           q-input(
             outlined
-            v-model='userPassword'
+            v-model='state.userPassword'
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.users.passwordMissing'),
-              val => val.length >= 8 || $t('admin.users.passwordTooShort')
-            ]`
+            :rules='userPasswordValidation'
             hide-bottom-space
-            :label='$t(`admin.users.password`)'
-            :aria-label='$t(`admin.users.password`)'
+            :label='t(`admin.users.password`)'
+            :aria-label='t(`admin.users.password`)'
             lazy-rules='ondemand'
             autofocus
             )
@@ -41,159 +38,182 @@ q-dialog(ref='dialog', @hide='onDialogHide')
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='password-reset')
         q-item-section
-          q-item-label {{$t(`admin.users.mustChangePwd`)}}
-          q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}}
+          q-item-label {{t(`admin.users.mustChangePwd`)}}
+          q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='userMustChangePassword'
+            v-model='state.userMustChangePassword'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.users.mustChangePwd`)'
+            :aria-label='t(`admin.users.mustChangePwd`)'
             )
     q-card-actions.card-actions
       q-space
       q-btn.acrylic-btn(
         flat
-        :label='$t(`common.actions.cancel`)'
+        :label='t(`common.actions.cancel`)'
         color='grey'
         padding='xs md'
-        @click='hide'
+        @click='onDialogCancel'
         )
       q-btn(
         unelevated
-        :label='$t(`common.actions.update`)'
+        :label='t(`common.actions.update`)'
         color='primary'
         padding='xs md'
         @click='save'
-        :loading='isLoading'
+        :loading='state.isLoading'
         )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
 import sampleSize from 'lodash/sampleSize'
 import zxcvbn from 'zxcvbn'
 
-export default {
-  props: {
-    userId: {
-      type: String,
-      required: true
-    }
-  },
-  emits: ['ok', 'hide'],
-  data () {
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, reactive, ref } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  userId: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  userPassword: '',
+  userMustChangePassword: false,
+  isLoading: false
+})
+
+// REFS
+
+const changeUserPwdForm = ref(null)
+
+// COMPUTED
+
+const passwordStrength = computed(() => {
+  if (state.userPassword.length < 8) {
     return {
-      userPassword: '',
-      userMustChangePassword: false,
-      isLoading: false
+      color: 'negative',
+      label: t('admin.users.pwdStrengthWeak')
     }
-  },
-  computed: {
-    passwordStrength () {
-      if (this.userPassword.length < 8) {
+  } else {
+    switch (zxcvbn(state.userPassword).score) {
+      case 1:
         return {
-          color: 'negative',
-          label: this.$t('admin.users.pwdStrengthWeak')
+          color: 'deep-orange-7',
+          label: t('admin.users.pwdStrengthPoor')
         }
-      } else {
-        switch (zxcvbn(this.userPassword).score) {
-          case 1:
-            return {
-              color: 'deep-orange-7',
-              label: this.$t('admin.users.pwdStrengthPoor')
-            }
-          case 2:
-            return {
-              color: 'purple-7',
-              label: this.$t('admin.users.pwdStrengthMedium')
-            }
-          case 3:
-            return {
-              color: 'blue-7',
-              label: this.$t('admin.users.pwdStrengthGood')
-            }
-          case 4:
-            return {
-              color: 'green-7',
-              label: this.$t('admin.users.pwdStrengthStrong')
-            }
-          default:
-            return {
-              color: 'negative',
-              label: this.$t('admin.users.pwdStrengthWeak')
-            }
+      case 2:
+        return {
+          color: 'purple-7',
+          label: t('admin.users.pwdStrengthMedium')
         }
-      }
-    }
-  },
-  methods: {
-    show () {
-      this.$refs.dialog.show()
-    },
-    hide () {
-      this.$refs.dialog.hide()
-    },
-    onDialogHide () {
-      this.$emit('hide')
-    },
-    randomizePassword () {
-      const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
-      this.userPassword = sampleSize(pwdChars, 16).join('')
-    },
-    async save () {
-      this.isLoading = true
-      try {
-        const isFormValid = await this.$refs.changeUserPwdForm.validate(true)
-        if (!isFormValid) {
-          throw new Error(this.$t('admin.users.createInvalidData'))
+      case 3:
+        return {
+          color: 'blue-7',
+          label: t('admin.users.pwdStrengthGood')
         }
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation adminUpdateUserPwd (
-              $id: UUID!
-              $patch: UserUpdateInput!
-              ) {
-              updateUser (
-                id: $id
-                patch: $patch
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
-            }
-          `,
-          variables: {
-            id: this.userId,
-            patch: {
-              newPassword: this.userPassword,
-              mustChangePassword: this.userMustChangePassword
+      case 4:
+        return {
+          color: 'green-7',
+          label: t('admin.users.pwdStrengthStrong')
+        }
+      default:
+        return {
+          color: 'negative',
+          label: t('admin.users.pwdStrengthWeak')
+        }
+    }
+  }
+})
+
+// VALIDATION RULES
+
+const userPasswordValidation = [
+  val => val.length > 0 || t('admin.users.passwordMissing'),
+  val => val.length >= 8 || t('admin.users.passwordTooShort')
+]
+
+// METHODS
+
+function randomizePassword () {
+  const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
+  state.userPassword = sampleSize(pwdChars, 16).join('')
+}
+
+async function save () {
+  state.isLoading = true
+  try {
+    const isFormValid = await changeUserPwdForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.users.createInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation adminUpdateUserPwd (
+          $id: UUID!
+          $patch: UserUpdateInput!
+          ) {
+          updateUser (
+            id: $id
+            patch: $patch
+            ) {
+            operation {
+              succeeded
+              message
             }
           }
-        })
-        if (resp?.data?.updateUser?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.users.createSuccess')
-          })
-          this.$emit('ok', {
-            mustChangePassword: this.userMustChangePassword
-          })
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.updateUser?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: props.userId,
+        patch: {
+          newPassword: state.userPassword,
+          mustChangePassword: state.userMustChangePassword
+        }
       }
-      this.isLoading = false
+    })
+    if (resp?.data?.updateUser?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.users.createSuccess')
+      })
+      onDialogOK({
+        mustChangePassword: state.userMustChangePassword
+      })
+    } else {
+      throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.isLoading = false
 }
 </script>

+ 220 - 192
ux/src/components/UserCreateDialog.vue

@@ -1,24 +1,21 @@
 <template lang="pug">
-q-dialog(ref='dialog', @hide='onDialogHide')
+q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card(style='min-width: 650px;')
     q-card-section.card-header
       q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm')
-      span {{$t(`admin.users.create`)}}
+      span {{t(`admin.users.create`)}}
     q-form.q-py-sm(ref='createUserForm', @submit='create')
       q-item
         blueprint-icon(icon='person')
         q-item-section
           q-input(
             outlined
-            v-model='userName'
+            v-model='state.userName'
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.users.nameMissing'),
-              val => /^[^<>"]+$/.test(val) || $t('admin.users.nameInvalidChars')
-            ]`
+            :rules='userNameValidation'
             hide-bottom-space
-            :label='$t(`common.field.name`)'
-            :aria-label='$t(`common.field.name`)'
+            :label='t(`common.field.name`)'
+            :aria-label='t(`common.field.name`)'
             lazy-rules='ondemand'
             autofocus
             ref='iptName'
@@ -28,16 +25,13 @@ q-dialog(ref='dialog', @hide='onDialogHide')
         q-item-section
           q-input(
             outlined
-            v-model='userEmail'
+            v-model='state.userEmail'
             dense
             type='email'
-            :rules=`[
-              val => val.length > 0 || $t('admin.users.emailMissing'),
-              val => /^.+\@.+\..+$/.test(val) || $t('admin.users.emailInvalid')
-            ]`
+            :rules='userEmailValidation'
             hide-bottom-space
-            :label='$t(`admin.users.email`)'
-            :aria-label='$t(`admin.users.email`)'
+            :label='t(`admin.users.email`)'
+            :aria-label='t(`admin.users.email`)'
             lazy-rules='ondemand'
             autofocus
             )
@@ -46,15 +40,12 @@ q-dialog(ref='dialog', @hide='onDialogHide')
         q-item-section
           q-input(
             outlined
-            v-model='userPassword'
+            v-model='state.userPassword'
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.users.passwordMissing'),
-              val => val.length >= 8 || $t('admin.users.passwordTooShort')
-            ]`
+            :rules='userPasswordValidation'
             hide-bottom-space
-            :label='$t(`admin.users.password`)'
-            :aria-label='$t(`admin.users.password`)'
+            :label='t(`admin.users.password`)'
+            :aria-label='t(`admin.users.password`)'
             lazy-rules='ondemand'
             autofocus
             )
@@ -79,8 +70,8 @@ q-dialog(ref='dialog', @hide='onDialogHide')
         q-item-section
           q-select(
             outlined
-            :options='groups'
-            v-model='userGroups'
+            :options='state.groups'
+            v-model='state.userGroups'
             multiple
             map-options
             emit-value
@@ -88,29 +79,26 @@ q-dialog(ref='dialog', @hide='onDialogHide')
             option-label='name'
             options-dense
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.users.groupsMissing')
-            ]`
+            :rules='userGroupsValidation'
             hide-bottom-space
-            :label='$t(`admin.users.groups`)'
-            :aria-label='$t(`admin.users.groups`)'
+            :label='t(`admin.users.groups`)'
+            :aria-label='t(`admin.users.groups`)'
             lazy-rules='ondemand'
-            :loading='loadingGroups'
+            :loading='state.loadingGroups'
             )
             template(v-slot:selected)
-              .text-caption(v-if='userGroups.length > 1')
+              .text-caption(v-if='state.userGroups.length > 1')
                 i18n-t(keypath='admin.users.groupsSelected')
                   template(#count)
-                    strong {{ userGroups.length }}
-              .text-caption(v-else-if='userGroups.length === 1')
+                    strong {{ state.userGroups.length }}
+              .text-caption(v-else-if='state.userGroups.length === 1')
                 i18n-t(keypath='admin.users.groupSelected')
                   template(#group)
                     strong {{ selectedGroupName }}
               span(v-else)
-            template(v-slot:option='{ itemProps, itemEvents, opt, selected, toggleOption }')
+            template(v-slot:option='{ itemProps, opt, selected, toggleOption }')
               q-item(
                 v-bind='itemProps'
-                v-on='itemEvents'
                 )
                 q-item-section(side)
                   q-checkbox(
@@ -123,214 +111,254 @@ q-dialog(ref='dialog', @hide='onDialogHide')
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='password-reset')
         q-item-section
-          q-item-label {{$t(`admin.users.mustChangePwd`)}}
-          q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}}
+          q-item-label {{t(`admin.users.mustChangePwd`)}}
+          q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='userMustChangePassword'
+            v-model='state.userMustChangePassword'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.users.mustChangePwd`)'
+            :aria-label='t(`admin.users.mustChangePwd`)'
             )
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='email-open')
         q-item-section
-          q-item-label {{$t(`admin.users.sendWelcomeEmail`)}}
-          q-item-label(caption) {{$t(`admin.users.sendWelcomeEmailHint`)}}
+          q-item-label {{t(`admin.users.sendWelcomeEmail`)}}
+          q-item-label(caption) {{t(`admin.users.sendWelcomeEmailHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='userSendWelcomeEmail'
+            v-model='state.userSendWelcomeEmail'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.users.sendWelcomeEmail`)'
+            :aria-label='t(`admin.users.sendWelcomeEmail`)'
             )
     q-card-actions.card-actions
       q-checkbox(
-        v-model='keepOpened'
+        v-model='state.keepOpened'
         color='primary'
-        :label='$t(`admin.users.createKeepOpened`)'
+        :label='t(`admin.users.createKeepOpened`)'
         size='sm'
       )
       q-space
       q-btn.acrylic-btn(
         flat
-        :label='$t(`common.actions.cancel`)'
+        :label='t(`common.actions.cancel`)'
         color='grey'
         padding='xs md'
-        @click='hide'
+        @click='onDialogCancel'
         )
       q-btn(
         unelevated
-        :label='$t(`common.actions.create`)'
+        :label='t(`common.actions.create`)'
         color='primary'
         padding='xs md'
         @click='create'
-        :loading='loading > 0'
+        :loading='state.loading > 0'
         )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
 import sampleSize from 'lodash/sampleSize'
 import zxcvbn from 'zxcvbn'
 import cloneDeep from 'lodash/cloneDeep'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, onMounted, reactive, ref } from 'vue'
 
-export default {
-  emits: ['ok', 'hide'],
-  data () {
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  userName: '',
+  userEmail: '',
+  userPassword: '',
+  userGroups: [],
+  userMustChangePassword: false,
+  userSendWelcomeEmail: false,
+  keepOpened: false,
+  groups: [],
+  loadingGroups: false,
+  loading: false
+})
+
+// REFS
+
+const createUserForm = ref(null)
+const iptName = ref(null)
+
+// COMPUTED
+
+const passwordStrength = computed(() => {
+  if (state.userPassword.length < 8) {
     return {
-      userName: '',
-      userEmail: '',
-      userPassword: '',
-      userGroups: [],
-      userMustChangePassword: false,
-      userSendWelcomeEmail: false,
-      keepOpened: false,
-      groups: [],
-      loadingGroups: false,
-      loading: false
+      color: 'negative',
+      label: t('admin.users.pwdStrengthWeak')
     }
-  },
-  computed: {
-    passwordStrength () {
-      if (this.userPassword.length < 8) {
+  } else {
+    switch (zxcvbn(state.userPassword).score) {
+      case 1:
         return {
-          color: 'negative',
-          label: this.$t('admin.users.pwdStrengthWeak')
+          color: 'deep-orange-7',
+          label: t('admin.users.pwdStrengthPoor')
         }
-      } else {
-        switch (zxcvbn(this.userPassword).score) {
-          case 1:
-            return {
-              color: 'deep-orange-7',
-              label: this.$t('admin.users.pwdStrengthPoor')
-            }
-          case 2:
-            return {
-              color: 'purple-7',
-              label: this.$t('admin.users.pwdStrengthMedium')
-            }
-          case 3:
-            return {
-              color: 'blue-7',
-              label: this.$t('admin.users.pwdStrengthGood')
-            }
-          case 4:
-            return {
-              color: 'green-7',
-              label: this.$t('admin.users.pwdStrengthStrong')
-            }
-          default:
-            return {
-              color: 'negative',
-              label: this.$t('admin.users.pwdStrengthWeak')
-            }
+      case 2:
+        return {
+          color: 'purple-7',
+          label: t('admin.users.pwdStrengthMedium')
+        }
+      case 3:
+        return {
+          color: 'blue-7',
+          label: t('admin.users.pwdStrengthGood')
+        }
+      case 4:
+        return {
+          color: 'green-7',
+          label: t('admin.users.pwdStrengthStrong')
+        }
+      default:
+        return {
+          color: 'negative',
+          label: t('admin.users.pwdStrengthWeak')
         }
-      }
-    },
-    selectedGroupName () {
-      return this.groups.filter(g => g.id === this.userGroups[0])[0]?.name
     }
-  },
-  methods: {
-    async show () {
-      this.$refs.dialog.show()
+  }
+})
+const selectedGroupName = computed(() => {
+  return state.groups.filter(g => g.id === state.userGroups[0])[0]?.name
+})
 
-      this.loading++
-      this.loadingGroups = true
-      const resp = await this.$apollo.query({
-        query: gql`
-          query getGroupsForCreateUser {
-            groups {
-              id
-              name
-            }
-          }
-        `,
-        fetchPolicy: 'network-only'
-      })
-      this.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? [])
-      this.loadingGroups = false
-      this.loading--
-    },
-    hide () {
-      this.$refs.dialog.hide()
-    },
-    onDialogHide () {
-      this.$emit('hide')
-    },
-    randomizePassword () {
-      const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
-      this.userPassword = sampleSize(pwdChars, 16).join('')
-    },
-    async create () {
-      this.loading++
-      try {
-        const isFormValid = await this.$refs.createUserForm.validate(true)
-        if (!isFormValid) {
-          throw new Error(this.$t('admin.users.createInvalidData'))
+// VALIDATION RULES
+
+const userNameValidation = [
+  val => val.length > 0 || t('admin.users.nameMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.users.nameInvalidChars')
+]
+
+const userEmailValidation = [
+  val => val.length > 0 || t('admin.users.emailMissing'),
+  val => /^.+@.+\..+$/.test(val) || t('admin.users.emailInvalid')
+]
+
+const userPasswordValidation = [
+  val => val.length > 0 || t('admin.users.passwordMissing'),
+  val => val.length >= 8 || t('admin.users.passwordTooShort')
+]
+
+const userGroupsValidation = [
+  val => val.length > 0 || t('admin.users.groupsMissing')
+]
+
+// METHODS
+
+async function loadGroups () {
+  state.loading++
+  state.loadingGroups = true
+  const resp = await APOLLO_CLIENT.query({
+    query: gql`
+      query getGroupsForCreateUser {
+        groups {
+          id
+          name
         }
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation createUser (
-              $name: String!
-              $email: String!
-              $password: String!
-              $groups: [UUID]!
-              $mustChangePassword: Boolean!
-              $sendWelcomeEmail: Boolean!
-              ) {
-              createUser (
-                name: $name
-                email: $email
-                password: $password
-                groups: $groups
-                mustChangePassword: $mustChangePassword
-                sendWelcomeEmail: $sendWelcomeEmail
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
+      }
+    `,
+    fetchPolicy: 'network-only'
+  })
+  state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [])
+  state.loadingGroups = false
+  state.loading--
+}
+
+function randomizePassword () {
+  const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
+  state.userPassword = sampleSize(pwdChars, 16).join('')
+}
+
+async function create () {
+  state.loading++
+  try {
+    const isFormValid = await createUserForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.users.createInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation createUser (
+          $name: String!
+          $email: String!
+          $password: String!
+          $groups: [UUID]!
+          $mustChangePassword: Boolean!
+          $sendWelcomeEmail: Boolean!
+          ) {
+          createUser (
+            name: $name
+            email: $email
+            password: $password
+            groups: $groups
+            mustChangePassword: $mustChangePassword
+            sendWelcomeEmail: $sendWelcomeEmail
+            ) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          variables: {
-            name: this.userName,
-            email: this.userEmail,
-            password: this.userPassword,
-            groups: this.userGroups,
-            mustChangePassword: this.userMustChangePassword,
-            sendWelcomeEmail: this.userSendWelcomeEmail
           }
-        })
-        if (resp?.data?.createUser?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.users.createSuccess')
-          })
-          if (this.keepOpened) {
-            this.userName = ''
-            this.userEmail = ''
-            this.userPassword = ''
-            this.$refs.iptName.focus()
-          } else {
-            this.$emit('ok')
-            this.hide()
-          }
-        } else {
-          throw new Error(resp?.data?.createUser?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        name: state.userName,
+        email: state.userEmail,
+        password: state.userPassword,
+        groups: state.userGroups,
+        mustChangePassword: state.userMustChangePassword,
+        sendWelcomeEmail: state.userSendWelcomeEmail
+      }
+    })
+    if (resp?.data?.createUser?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.users.createSuccess')
+      })
+      if (state.keepOpened) {
+        state.userName = ''
+        state.userEmail = ''
+        state.userPassword = ''
+        iptName.value.focus()
+      } else {
+        onDialogOK()
       }
-      this.loading--
+    } else {
+      throw new Error(resp?.data?.createUser?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.loading--
 }
+
+// MOUNTED
+
+onMounted(loadGroups)
 </script>

+ 444 - 390
ux/src/components/UserEditOverlay.vue

@@ -3,26 +3,26 @@ q-layout(view='hHh lpR fFf', container)
   q-header.card-header.q-px-md.q-py-sm
     q-icon(name='img:/_assets/icons/fluent-account.svg', left, size='md')
     div
-      span {{$t(`admin.users.edit`)}}
-      .text-caption {{user.name}}
+      span {{t(`admin.users.edit`)}}
+      .text-caption {{state.user.name}}
     q-space
     q-btn-group(push)
       q-btn(
         push
         color='grey-6'
         text-color='white'
-        :aria-label='$t(`common.actions.refresh`)'
+        :aria-label='t(`common.actions.refresh`)'
         icon='las la-redo-alt'
-        @click='load'
-        :loading='loading > 0'
+        @click='fetchUser'
+        :loading='state.loading > 0'
         )
-        q-tooltip(anchor='center left', self='center right') {{$t(`common.actions.refresh`)}}
+        q-tooltip(anchor='center left', self='center right') {{t(`common.actions.refresh`)}}
       q-btn(
         push
         color='white'
         text-color='grey-7'
-        :label='$t(`common.actions.close`)'
-        :aria-label='$t(`common.actions.close`)'
+        :label='t(`common.actions.close`)'
+        :aria-label='t(`common.actions.close`)'
         icon='las la-times'
         @click='close'
       )
@@ -30,14 +30,14 @@ q-layout(view='hHh lpR fFf', container)
         push
         color='positive'
         text-color='white'
-        :label='$t(`common.actions.save`)'
-        :aria-label='$t(`common.actions.save`)'
+        :label='t(`common.actions.save`)'
+        :aria-label='t(`common.actions.save`)'
         icon='las la-check'
         @click='save()'
-        :disabled='loading > 0'
+        :disabled='state.loading > 0'
       )
   q-drawer.bg-dark-6(:model-value='true', :width='250', dark)
-    q-list(padding, v-if='loading < 1')
+    q-list(padding, v-if='state.loading < 1')
       q-item(
         v-for='sc of sections'
         :key='`section-` + sc.key'
@@ -50,111 +50,111 @@ q-layout(view='hHh lpR fFf', container)
           q-icon(:name='sc.icon', color='white')
         q-item-section {{sc.text}}
   q-page-container
-    q-page(v-if='loading > 0')
+    q-page(v-if='state.loading > 0')
       .flex.q-pa-lg.items-center
         q-spinner-tail(color='primary', size='32px', :thickness='2')
-        .text-caption.text-primary.q-pl-md: strong {{$t('admin.users.loading')}}
-    q-page(v-else-if='$route.params.section === `overview`')
+        .text-caption.text-primary.q-pl-md: strong {{t('admin.users.loading')}}
+    q-page(v-else-if='route.params.section === `overview`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-8
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.profile')}}
+                .text-subtitle1 {{t('admin.users.profile')}}
               q-item
                 blueprint-icon(icon='contact')
                 q-item-section
-                  q-item-label {{$t(`admin.users.name`)}}
-                  q-item-label(caption) {{$t(`admin.users.nameHint`)}}
+                  q-item-label {{t(`admin.users.name`)}}
+                  q-item-label(caption) {{t(`admin.users.nameHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='user.name'
+                    v-model='state.user.name'
                     dense
                     :rules=`[
-                      val => invalidCharsRegex.test(val) || $t('admin.users.nameInvalidChars')
+                      val => invalidCharsRegex.test(val) || t('admin.users.nameInvalidChars')
                     ]`
                     hide-bottom-space
-                    :aria-label='$t(`admin.users.name`)'
+                    :aria-label='t(`admin.users.name`)'
                     )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='envelope')
                 q-item-section
-                  q-item-label {{$t(`admin.users.email`)}}
-                  q-item-label(caption) {{$t(`admin.users.emailHint`)}}
+                  q-item-label {{t(`admin.users.email`)}}
+                  q-item-label(caption) {{t(`admin.users.emailHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='user.email'
+                    v-model='state.user.email'
                     dense
-                    :aria-label='$t(`admin.users.email`)'
+                    :aria-label='t(`admin.users.email`)'
                     )
-              template(v-if='user.meta')
+              template(v-if='state.user.meta')
                 q-separator.q-my-sm(inset)
                 q-item
                   blueprint-icon(icon='address')
                   q-item-section
-                    q-item-label {{$t(`admin.users.location`)}}
-                    q-item-label(caption) {{$t(`admin.users.locationHint`)}}
+                    q-item-label {{t(`admin.users.location`)}}
+                    q-item-label(caption) {{t(`admin.users.locationHint`)}}
                   q-item-section
                     q-input(
                       outlined
-                      v-model='user.meta.location'
+                      v-model='state.user.meta.location'
                       dense
-                      :aria-label='$t(`admin.users.location`)'
+                      :aria-label='t(`admin.users.location`)'
                       )
                 q-separator.q-my-sm(inset)
                 q-item
                   blueprint-icon(icon='new-job')
                   q-item-section
-                    q-item-label {{$t(`admin.users.jobTitle`)}}
-                    q-item-label(caption) {{$t(`admin.users.jobTitleHint`)}}
+                    q-item-label {{t(`admin.users.jobTitle`)}}
+                    q-item-label(caption) {{t(`admin.users.jobTitleHint`)}}
                   q-item-section
                     q-input(
                       outlined
-                      v-model='user.meta.jobTitle'
+                      v-model='state.user.meta.jobTitle'
                       dense
-                      :aria-label='$t(`admin.users.jobTitle`)'
+                      :aria-label='t(`admin.users.jobTitle`)'
                       )
 
-            q-card.shadow-1.q-pb-sm.q-mt-md(v-if='user.meta')
+            q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
               q-card-section
-                .text-subtitle1 {{$t('admin.users.preferences')}}
+                .text-subtitle1 {{t('admin.users.preferences')}}
               q-item
                 blueprint-icon(icon='timezone')
                 q-item-section
-                  q-item-label {{$t(`admin.users.timezone`)}}
-                  q-item-label(caption) {{$t(`admin.users.timezoneHint`)}}
+                  q-item-label {{t(`admin.users.timezone`)}}
+                  q-item-label(caption) {{t(`admin.users.timezoneHint`)}}
                 q-item-section
                   q-select(
                     outlined
-                    v-model='user.prefs.timezone'
-                    :options='timezones'
+                    v-model='state.user.prefs.timezone'
+                    :options='dataStore.timezones'
                     option-value='value'
                     option-label='text'
                     emit-value
                     map-options
                     dense
                     options-dense
-                    :aria-label='$t(`admin.users.timezone`)'
+                    :aria-label='t(`admin.users.timezone`)'
                   )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='calendar')
                 q-item-section
-                  q-item-label {{$t(`admin.users.dateFormat`)}}
-                  q-item-label(caption) {{$t(`admin.users.dateFormatHint`)}}
+                  q-item-label {{t(`admin.users.dateFormat`)}}
+                  q-item-label(caption) {{t(`admin.users.dateFormatHint`)}}
                 q-item-section
                   q-select(
                     outlined
-                    v-model='user.prefs.dateFormat'
+                    v-model='state.user.prefs.dateFormat'
                     emit-value
                     map-options
                     dense
-                    :aria-label='$t(`admin.users.dateFormat`)'
+                    :aria-label='t(`admin.users.dateFormat`)'
                     :options=`[
-                      { label: $t('profile.localeDefault'), value: '' },
+                      { label: t('profile.localeDefault'), value: '' },
                       { label: 'DD/MM/YYYY', value: 'DD/MM/YYYY' },
                       { label: 'DD.MM.YYYY', value: 'DD.MM.YYYY' },
                       { label: 'MM/DD/YYYY', value: 'MM/DD/YYYY' },
@@ -166,168 +166,168 @@ q-layout(view='hHh lpR fFf', container)
               q-item
                 blueprint-icon(icon='clock')
                 q-item-section
-                  q-item-label {{$t(`admin.users.timeFormat`)}}
-                  q-item-label(caption) {{$t(`admin.users.timeFormatHint`)}}
+                  q-item-label {{t(`admin.users.timeFormat`)}}
+                  q-item-label(caption) {{t(`admin.users.timeFormatHint`)}}
                 q-item-section.col-auto
                   q-btn-toggle(
-                    v-model='user.prefs.timeFormat'
+                    v-model='state.user.prefs.timeFormat'
                     push
                     glossy
                     no-caps
                     toggle-color='primary'
                     :options=`[
-                      { label: $t('profile.timeFormat12h'), value: '12h' },
-                      { label: $t('profile.timeFormat24h'), value: '24h' }
+                      { label: t('profile.timeFormat12h'), value: '12h' },
+                      { label: t('profile.timeFormat24h'), value: '24h' }
                     ]`
                   )
               q-separator.q-my-sm(inset)
               q-item(tag='label', v-ripple)
                 blueprint-icon(icon='light-on')
                 q-item-section
-                  q-item-label {{$t(`admin.users.darkMode`)}}
-                  q-item-label(caption) {{$t(`admin.users.darkModeHint`)}}
+                  q-item-label {{t(`admin.users.darkMode`)}}
+                  q-item-label(caption) {{t(`admin.users.darkModeHint`)}}
                 q-item-section(avatar)
                   q-toggle(
-                    v-model='user.prefs.darkMode'
+                    v-model='state.user.prefs.darkMode'
                     color='primary'
                     checked-icon='las la-check'
                     unchecked-icon='las la-times'
-                    :aria-label='$t(`admin.users.darkMode`)'
+                    :aria-label='t(`admin.users.darkMode`)'
                   )
 
           .col-12.col-lg-4
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.info')}}
+                .text-subtitle1 {{t('admin.users.info')}}
               q-item
                 blueprint-icon(icon='person', :hue-rotate='-45')
                 q-item-section
-                  q-item-label {{$t(`common.field.id`)}}
-                  q-item-label: strong {{userId}}
+                  q-item-label {{t(`common.field.id`)}}
+                  q-item-label: strong {{state.user.id}}
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='calendar-plus', :hue-rotate='-45')
                 q-item-section
-                  q-item-label {{$t(`common.field.createdOn`)}}
-                  q-item-label: strong {{humanizeDate(user.createdAt)}}
+                  q-item-label {{t(`common.field.createdOn`)}}
+                  q-item-label: strong {{humanizeDate(state.user.createdAt)}}
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='summertime', :hue-rotate='-45')
                 q-item-section
-                  q-item-label {{$t(`common.field.lastUpdated`)}}
-                  q-item-label: strong {{humanizeDate(user.updatedAt)}}
+                  q-item-label {{t(`common.field.lastUpdated`)}}
+                  q-item-label: strong {{humanizeDate(state.user.updatedAt)}}
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='enter', :hue-rotate='-45')
                 q-item-section
-                  q-item-label {{$t(`admin.users.lastLoginAt`)}}
-                  q-item-label: strong {{humanizeDate(user.lastLoginAt)}}
+                  q-item-label {{t(`admin.users.lastLoginAt`)}}
+                  q-item-label: strong {{humanizeDate(state.user.lastLoginAt)}}
 
-            q-card.shadow-1.q-pb-sm.q-mt-md(v-if='user.meta')
+            q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.user.meta')
               q-card-section
-                .text-subtitle1 {{$t('admin.users.notes')}}
+                .text-subtitle1 {{t('admin.users.notes')}}
                 q-input.q-mt-sm(
                   outlined
-                  v-model='user.meta.notes'
+                  v-model='state.user.meta.notes'
                   type='textarea'
-                  :aria-label='$t(`admin.users.notes`)'
+                  :aria-label='t(`admin.users.notes`)'
                   input-style='min-height: 243px'
-                  :hint='$t(`admin.users.noteHint`)'
+                  :hint='t(`admin.users.noteHint`)'
                 )
 
-    q-page(v-else-if='$route.params.section === `activity`')
+    q-page(v-else-if='route.params.section === `activity`')
       span ---
 
-    q-page(v-else-if='$route.params.section === `auth`')
+    q-page(v-else-if='route.params.section === `auth`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-7
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.passAuth')}}
+                .text-subtitle1 {{t('admin.users.passAuth')}}
               q-item
                 blueprint-icon(icon='password', :hue-rotate='45')
                 q-item-section
-                  q-item-label {{$t(`admin.users.changePassword`)}}
-                  q-item-label(caption) {{$t(`admin.users.changePasswordHint`)}}
-                  q-item-label(caption): strong(:class='localAuth.password ? `text-positive` : `text-negative`') {{localAuth.password ? $t(`admin.users.pwdSet`) : $t(`admin.users.pwdNotSet`)}}
+                  q-item-label {{t(`admin.users.changePassword`)}}
+                  q-item-label(caption) {{t(`admin.users.changePasswordHint`)}}
+                  q-item-label(caption): strong(:class='localAuth.password ? `text-positive` : `text-negative`') {{localAuth.password ? t(`admin.users.pwdSet`) : t(`admin.users.pwdNotSet`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='primary'
                     @click='changePassword'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
               q-separator.q-my-sm(inset)
               q-item(tag='label', v-ripple)
                 blueprint-icon(icon='password-reset')
                 q-item-section
-                  q-item-label {{$t(`admin.users.mustChangePwd`)}}
-                  q-item-label(caption) {{$t(`admin.users.mustChangePwdHint`)}}
+                  q-item-label {{t(`admin.users.mustChangePwd`)}}
+                  q-item-label(caption) {{t(`admin.users.mustChangePwdHint`)}}
                 q-item-section(avatar)
                   q-toggle(
                     v-model='localAuth.mustChangePwd'
                     color='primary'
                     checked-icon='las la-check'
                     unchecked-icon='las la-times'
-                    :aria-label='$t(`admin.users.mustChangePwd`)'
+                    :aria-label='t(`admin.users.mustChangePwd`)'
                   )
               q-separator.q-my-sm(inset)
               q-item(tag='label', v-ripple)
                 blueprint-icon(icon='key')
                 q-item-section
-                  q-item-label {{$t(`admin.users.pwdAuthRestrict`)}}
-                  q-item-label(caption) {{$t(`admin.users.pwdAuthRestrictHint`)}}
+                  q-item-label {{t(`admin.users.pwdAuthRestrict`)}}
+                  q-item-label(caption) {{t(`admin.users.pwdAuthRestrictHint`)}}
                 q-item-section(avatar)
                   q-toggle(
                     v-model='localAuth.restrictLogin'
                     color='primary'
                     checked-icon='las la-check'
                     unchecked-icon='las la-times'
-                    :aria-label='$t(`admin.users.pwdAuthRestrict`)'
+                    :aria-label='t(`admin.users.pwdAuthRestrict`)'
                   )
 
             q-card.shadow-1.q-pb-sm.q-mt-md
               q-card-section
-                .text-subtitle1 {{$t('admin.users.tfa')}}
+                .text-subtitle1 {{t('admin.users.tfa')}}
               q-item(tag='label', v-ripple)
                 blueprint-icon(icon='key')
                 q-item-section
-                  q-item-label {{$t(`admin.users.tfaRequired`)}}
-                  q-item-label(caption) {{$t(`admin.users.tfaRequiredHint`)}}
+                  q-item-label {{t(`admin.users.tfaRequired`)}}
+                  q-item-label(caption) {{t(`admin.users.tfaRequiredHint`)}}
                 q-item-section(avatar)
                   q-toggle(
                     v-model='localAuth.tfaRequired'
                     color='primary'
                     checked-icon='las la-check'
                     unchecked-icon='las la-times'
-                    :aria-label='$t(`admin.users.tfaRequired`)'
+                    :aria-label='t(`admin.users.tfaRequired`)'
                   )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='password', :hue-rotate='45')
                 q-item-section
-                  q-item-label {{$t(`admin.users.tfaInvalidate`)}}
-                  q-item-label(caption) {{$t(`admin.users.tfaInvalidateHint`)}}
-                  q-item-label(caption): strong(:class='localAuth.tfaSecret ? `text-positive` : `text-negative`') {{localAuth.tfaSecret ? $t(`admin.users.tfaSet`) : $t(`admin.users.tfaNotSet`)}}
+                  q-item-label {{t(`admin.users.tfaInvalidate`)}}
+                  q-item-label(caption) {{t(`admin.users.tfaInvalidateHint`)}}
+                  q-item-label(caption): strong(:class='localAuth.tfaSecret ? `text-positive` : `text-negative`') {{localAuth.tfaSecret ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='primary'
                     @click='invalidateTFA'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
           .col-12.col-lg-5
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.linkedProviders')}}
+                .text-subtitle1 {{t('admin.users.linkedProviders')}}
                 q-banner.q-mt-md(
                   v-if='linkedAuthProviders.length < 1'
                   rounded
                   :class='$q.dark.isActive ? `bg-negative text-white` : `bg-grey-2 text-grey-7`'
-                  ) {{$t('admin.users.noLinkedProviders')}}
+                  ) {{t('admin.users.noLinkedProviders')}}
               template(
                 v-for='(prv, idx) in linkedAuthProviders'
                 :key='prv._id'
@@ -339,15 +339,15 @@ q-layout(view='hHh lpR fFf', container)
                     q-item-label {{prv._moduleName}}
                     q-item-label(caption) {{prv.key}}
 
-    q-page(v-else-if='$route.params.section === `groups`')
+    q-page(v-else-if='route.params.section === `groups`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-8
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.groups')}}
+                .text-subtitle1 {{t('admin.users.groups')}}
               template(
-                v-for='(grp, idx) of user.groups'
+                v-for='(grp, idx) of state.user.groups'
                 :key='grp.id'
                 )
                 q-separator.q-my-sm(inset, v-if='idx > 0')
@@ -361,17 +361,17 @@ q-layout(view='hHh lpR fFf', container)
                       icon='las la-times'
                       color='accent'
                       @click='unassignGroup(grp.id)'
-                      :aria-label='$t(`admin.users.unassignGroup`)'
+                      :aria-label='t(`admin.users.unassignGroup`)'
                       )
-                      q-tooltip(anchor='center left' self='center right') {{$t('admin.users.unassignGroup')}}
+                      q-tooltip(anchor='center left' self='center right') {{t('admin.users.unassignGroup')}}
             q-card.shadow-1.q-py-sm.q-mt-md
               q-item
                 blueprint-icon(icon='join')
                 q-item-section
                   q-select(
                     outlined
-                    :options='groups'
-                    v-model='groupToAdd'
+                    :options='state.groups'
+                    v-model='state.groupToAdd'
                     map-options
                     emit-value
                     option-value='id'
@@ -379,33 +379,33 @@ q-layout(view='hHh lpR fFf', container)
                     options-dense
                     dense
                     hide-bottom-space
-                    :label='$t(`admin.users.groups`)'
-                    :aria-label='$t(`admin.users.groups`)'
-                    :loading='loading > 0'
+                    :label='t(`admin.users.groups`)'
+                    :aria-label='t(`admin.users.groups`)'
+                    :loading='state.loading > 0'
                     )
                 q-item-section(side)
                   q-btn(
                     unelevated
                     icon='las la-plus'
-                    :label='$t(`admin.users.assignGroup`)'
+                    :label='t(`admin.users.assignGroup`)'
                     color='primary'
                     @click='assignGroup'
                   )
 
-    q-page(v-else-if='$route.params.section === `metadata`')
+    q-page(v-else-if='route.params.section === `metadata`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-8
             q-card.shadow-1.q-pb-sm
               q-card-section.flex.items-center
-                .text-subtitle1 {{$t('admin.users.metadata')}}
+                .text-subtitle1 {{t('admin.users.metadata')}}
                 q-space
                 q-badge(
-                  v-if='metadataInvalidJSON'
+                  v-if='state.metadataInvalidJSON'
                   color='negative'
                   )
                   q-icon.q-mr-xs(name='las la-exclamation-triangle', size='20px')
-                  span {{$t('admin.users.invalidJSON')}}
+                  span {{t('admin.users.invalidJSON')}}
                 q-badge.q-py-xs(
                   v-else
                   label='JSON'
@@ -413,80 +413,79 @@ q-layout(view='hHh lpR fFf', container)
                 )
               q-item
                 q-item-section
-                  q-no-ssr(:placeholder='$t(`common.loading`)')
-                    util-code-editor.admin-theme-cm(
+                  q-no-ssr(:placeholder='t(`common.loading`)')
+                    codemirror.metadata-codemirror(
                       v-model='metadata'
-                      language='json'
-                      :min-height='500'
+                      :extensions='[json()]'
                     )
 
-    q-page(v-else-if='$route.params.section === `operations`')
+    q-page(v-else-if='route.params.section === `operations`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-8
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.users.operations')}}
+                .text-subtitle1 {{t('admin.users.operations')}}
               q-item
                 blueprint-icon(icon='email-open', :hue-rotate='45')
                 q-item-section
-                  q-item-label {{$t(`admin.users.sendWelcomeEmail`)}}
-                  q-item-label(caption) {{$t(`admin.users.sendWelcomeEmailAltHint`)}}
+                  q-item-label {{t(`admin.users.sendWelcomeEmail`)}}
+                  q-item-label(caption) {{t(`admin.users.sendWelcomeEmailAltHint`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='primary'
                     @click='sendWelcomeEmail'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='apply', :hue-rotate='45')
                 q-item-section
-                  q-item-label {{user.isVerified ? $t(`admin.users.unverify`) : $t(`admin.users.verify`)}}
-                  q-item-label(caption) {{user.isVerified ? $t(`admin.users.unverifyHint`) : $t(`admin.users.verifyHint`)}}
-                  q-item-label(caption): strong(:class='user.isVerified ? `text-positive` : `text-negative`') {{user.isVerified ? $t(`admin.users.verified`) : $t(`admin.users.unverified`)}}
+                  q-item-label {{state.user.isVerified ? t(`admin.users.unverify`) : t(`admin.users.verify`)}}
+                  q-item-label(caption) {{state.user.isVerified ? t(`admin.users.unverifyHint`) : t(`admin.users.verifyHint`)}}
+                  q-item-label(caption): strong(:class='state.user.isVerified ? `text-positive` : `text-negative`') {{state.user.isVerified ? t(`admin.users.verified`) : t(`admin.users.unverified`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='primary'
                     @click='toggleVerified'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='unfriend', :hue-rotate='45')
                 q-item-section
-                  q-item-label {{user.isActive ? $t(`admin.users.ban`) : $t(`admin.users.unban`)}}
-                  q-item-label(caption) {{user.isActive ? $t(`admin.users.banHint`) : $t(`admin.users.unbanHint`)}}
-                  q-item-label(caption): strong(:class='user.isActive ? `text-positive` : `text-negative`') {{user.isActive ? $t(`admin.users.active`) : $t(`admin.users.banned`)}}
+                  q-item-label {{state.user.isActive ? t(`admin.users.ban`) : t(`admin.users.unban`)}}
+                  q-item-label(caption) {{state.user.isActive ? t(`admin.users.banHint`) : t(`admin.users.unbanHint`)}}
+                  q-item-label(caption): strong(:class='state.user.isActive ? `text-positive` : `text-negative`') {{state.user.isActive ? t(`admin.users.active`) : t(`admin.users.banned`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='primary'
                     @click='toggleBan'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
             q-card.shadow-1.q-py-sm.q-mt-md
               q-item
                 blueprint-icon(icon='denied', :hue-rotate='140')
                 q-item-section
-                  q-item-label {{$t(`admin.users.delete`)}}
-                  q-item-label(caption) {{$t(`admin.users.deleteHint`)}}
+                  q-item-label {{t(`admin.users.delete`)}}
+                  q-item-label(caption) {{t(`admin.users.deleteHint`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
                     icon='las la-arrow-circle-right'
                     color='negative'
                     @click='deleteUser'
-                    :label='$t(`common.actions.proceed`)'
+                    :label='t(`common.actions.proceed`)'
                   )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
 import cloneDeep from 'lodash/cloneDeep'
 import some from 'lodash/some'
@@ -494,284 +493,339 @@ import find from 'lodash/find'
 import findKey from 'lodash/findKey'
 import _get from 'lodash/get'
 import map from 'lodash/map'
-import { get } from 'vuex-pathify'
 import { DateTime } from 'luxon'
-import UtilCodeEditor from './UtilCodeEditor.vue'
+
+import { useI18n } from 'vue-i18n'
+import { useQuasar } from 'quasar'
+import { computed, onMounted, reactive, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import { useAdminStore } from 'src/stores/admin'
+import { useDataStore } from 'src/stores/data'
+
 import UserChangePwdDialog from './UserChangePwdDialog.vue'
+import { Codemirror } from 'vue-codemirror'
+import { json } from '@codemirror/lang-json'
+// import { oneDark } from '@codemirror/theme-one-dark'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+const dataStore = useDataStore()
 
-export default {
-  components: {
-    UtilCodeEditor
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  invalidCharsRegex: /^[^<>"]+$/,
+  user: {
+    meta: {},
+    prefs: {},
+    groups: []
   },
-  data () {
-    return {
-      invalidCharsRegex: /^[^<>"]+$/,
-      sections: [
-        { key: 'overview', text: this.$t('admin.users.overview'), icon: 'las la-user' },
-        { key: 'activity', text: this.$t('admin.users.activity'), icon: 'las la-chart-area' },
-        { key: 'auth', text: this.$t('admin.users.auth'), icon: 'las la-key' },
-        { key: 'groups', text: this.$t('admin.users.groups'), icon: 'las la-users' },
-        { key: 'metadata', text: this.$t('admin.users.metadata'), icon: 'las la-clipboard-list' },
-        { key: 'operations', text: this.$t('admin.users.operations'), icon: 'las la-tools' }
-      ],
-      user: {
-        meta: {},
-        prefs: {},
-        groups: []
-      },
-      groups: [],
-      groupToAdd: null,
-      loading: 0,
-      metadataInvalidJSON: false
+  groups: [],
+  groupToAdd: null,
+  loading: 0,
+  metadataInvalidJSON: false
+})
+
+const sections = [
+  { key: 'overview', text: t('admin.users.overview'), icon: 'las la-user' },
+  { key: 'activity', text: t('admin.users.activity'), icon: 'las la-chart-area' },
+  { key: 'auth', text: t('admin.users.auth'), icon: 'las la-key' },
+  { key: 'groups', text: t('admin.users.groups'), icon: 'las la-users' },
+  { key: 'metadata', text: t('admin.users.metadata'), icon: 'las la-clipboard-list' },
+  { key: 'operations', text: t('admin.users.operations'), icon: 'las la-tools' }
+]
+
+// COMPUTED
+
+const metadata = computed({
+  get () { return JSON.stringify(state.user.meta ?? {}, null, 2) },
+  set (val) {
+    try {
+      state.user.meta = JSON.parse(val)
+      state.metadataInvalidJSON = false
+    } catch (err) {
+      state.metadataInvalidJSON = true
     }
+  }
+})
+
+const localAuthId = computed(() => {
+  return findKey(state.user.auth, ['module', 'local'])
+})
+
+const localAuth = computed({
+  get () {
+    return localAuthId.value ? _get(state.user.auth, localAuthId.value, {}) : {}
   },
-  computed: {
-    timezones: get('data/timezones', false),
-    userId: get('admin/overlayOpts@id', false),
-    metadata: {
-      get () { return JSON.stringify(this.user.meta ?? {}, null, 2) },
-      set (val) {
-        try {
-          this.user.meta = JSON.parse(val)
-          this.metadataInvalidJSON = false
-        } catch (err) {
-          this.metadataInvalidJSON = true
-        }
-      }
-    },
-    localAuthId () {
-      return findKey(this.user.auth, ['module', 'local'])
-    },
-    localAuth: {
-      get () {
-        return this.localAuthId ? _get(this.user.auth, this.localAuthId, {}) : {}
-      },
-      set (val) {
-        if (this.localAuthId) {
-          this.user.auth[this.localAuthId] = val
-        }
-      }
-    },
-    linkedAuthProviders () {
-      if (!this.user?.auth) { return [] }
-
-      return map(this.user.auth, (obj, key) => {
-        return {
-          ...obj,
-          _id: key
-        }
-      }).filter(prv => prv.module !== 'local')
+  set (val) {
+    if (localAuthId.value) {
+      state.user.auth[localAuthId.value] = val
     }
-  },
-  watch: {
-    $route: 'checkRoute'
-  },
-  mounted () {
-    this.checkRoute()
-    this.load()
-  },
-  methods: {
-    async load () {
-      this.loading++
-      this.$q.loading.show()
-      try {
-        const resp = await this.$apollo.query({
-          query: gql`
-            query adminFetchUser (
-              $id: UUID!
-              ) {
-              groups {
-                id
-                name
-              }
-              userById(
-                id: $id
-              ) {
-                id
-                email
-                name
-                isSystem
-                isVerified
-                isActive
-                auth
-                meta
-                prefs
-                lastLoginAt
-                createdAt
-                updatedAt
-                groups {
-                  id
-                  name
-                }
-              }
+  }
+})
+
+const linkedAuthProviders = computed(() => {
+  if (!state.user?.auth) { return [] }
+
+  return map(state.user.auth, (obj, key) => {
+    return {
+      ...obj,
+      _id: key
+    }
+  }).filter(prv => prv.module !== 'local')
+})
+
+// WATCHERS
+
+watch(() => route.params.section, checkRoute)
+
+// METHODS
+
+async function fetchUser () {
+  state.loading++
+  $q.loading.show()
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query adminFetchUser (
+          $id: UUID!
+          ) {
+          groups {
+            id
+            name
+          }
+          userById(
+            id: $id
+          ) {
+            id
+            email
+            name
+            isSystem
+            isVerified
+            isActive
+            auth
+            meta
+            prefs
+            lastLoginAt
+            createdAt
+            updatedAt
+            groups {
+              id
+              name
             }
-          `,
-          variables: {
-            id: this.userId
-          },
-          fetchPolicy: 'network-only'
-        })
-        this.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []
-        if (resp?.data?.userById) {
-          this.user = cloneDeep(resp.data.userById)
-        } else {
-          throw new Error('An unexpected error occured while fetching user details.')
-        }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
-      }
-      this.$q.loading.hide()
-      this.loading--
-    },
-    close () {
-      this.$store.set('admin/overlay', '')
-    },
-    checkRoute () {
-      if (!this.$route.params.section) {
-        this.$router.replace({ params: { section: 'overview' } })
-      }
-      if (this.$route.params.section === 'metadata') {
-        this.metadataInvalidJSON = false
-      }
-    },
-    humanizeDate (val) {
-      if (!val) { return '---' }
-      return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
-    },
-    assignGroup () {
-      if (!this.groupToAdd) {
-        this.$q.notify({
-          type: 'negative',
-          message: this.$t('admin.users.noGroupSelected')
-        })
-      } else if (some(this.user.groups, gr => gr.id === this.groupToAdd)) {
-        this.$q.notify({
-          type: 'warning',
-          message: this.$t('admin.users.groupAlreadyAssigned')
-        })
-      } else {
-        const newGroup = find(this.groups, ['id', this.groupToAdd])
-        this.user.groups = [...this.user.groups, newGroup]
-      }
-    },
-    unassignGroup (id) {
-      if (this.user.groups.length <= 1) {
-        this.$q.notify({
-          type: 'negative',
-          message: this.$t('admin.users.minimumGroupRequired')
-        })
-      } else {
-        this.user.groups = this.user.groups.filter(gr => gr.id === id)
-      }
-    },
-    async save (patch, { silent, keepOpen } = { silent: false, keepOpen: false }) {
-      this.$q.loading.show()
-      if (!patch) {
-        patch = {
-          name: this.user.name,
-          email: this.user.email,
-          isVerified: this.user.isVerified,
-          isActive: this.user.isActive,
-          meta: this.user.meta,
-          prefs: this.user.prefs,
-          groups: this.user.groups.map(gr => gr.id)
+          }
         }
-      }
-      try {
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation adminSaveUser (
-              $id: UUID!
-              $patch: UserUpdateInput!
-              ) {
-              updateUser (
-                id: $id
-                patch: $patch
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
+      `,
+      variables: {
+        id: adminStore.overlayOpts.id
+      },
+      fetchPolicy: 'network-only'
+    })
+    state.groups = resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-0000-000000000001') ?? []
+    if (resp?.data?.userById) {
+      state.user = cloneDeep(resp.data.userById)
+    } else {
+      throw new Error('An unexpected error occured while fetching user details.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  $q.loading.hide()
+  state.loading--
+}
+
+function close () {
+  adminStore.$patch({ overlay: '' })
+}
+
+function checkRoute () {
+  if (!route.params.section) {
+    router.replace({ params: { section: 'overview' } })
+  }
+  if (route.params.section === 'metadata') {
+    state.metadataInvalidJSON = false
+  }
+}
+
+function humanizeDate (val) {
+  if (!val) { return '---' }
+  return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
+}
+
+function assignGroup () {
+  if (!state.groupToAdd) {
+    $q.notify({
+      type: 'negative',
+      message: t('admin.users.noGroupSelected')
+    })
+  } else if (some(state.user.groups, gr => gr.id === state.groupToAdd)) {
+    $q.notify({
+      type: 'warning',
+      message: t('admin.users.groupAlreadyAssigned')
+    })
+  } else {
+    const newGroup = find(state.groups, ['id', state.groupToAdd])
+    state.user.groups = [...state.user.groups, newGroup]
+  }
+}
+
+function unassignGroup (id) {
+  if (state.user.groups.length <= 1) {
+    $q.notify({
+      type: 'negative',
+      message: t('admin.users.minimumGroupRequired')
+    })
+  } else {
+    state.user.groups = state.user.groups.filter(gr => gr.id === id)
+  }
+}
+
+async function save (patch, { silent, keepOpen } = { silent: false, keepOpen: false }) {
+  $q.loading.show()
+  if (!patch) {
+    patch = {
+      name: state.user.name,
+      email: state.user.email,
+      isVerified: state.user.isVerified,
+      isActive: state.user.isActive,
+      meta: state.user.meta,
+      prefs: state.user.prefs,
+      groups: state.user.groups.map(gr => gr.id)
+    }
+  }
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation adminSaveUser (
+          $id: UUID!
+          $patch: UserUpdateInput!
+          ) {
+          updateUser (
+            id: $id
+            patch: $patch
+            ) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          variables: {
-            id: this.userId,
-            patch
           }
-        })
-        if (resp?.data?.updateUser?.status?.succeeded) {
-          if (!silent) {
-            this.$q.notify({
-              type: 'positive',
-              message: this.$t('admin.users.saveSuccess')
-            })
-          }
-          if (!keepOpen) {
-            this.close()
-          }
-        } else {
-          throw new Error(resp?.data?.updateUser?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: adminStore.overlayOpts.id,
+        patch
       }
-      this.$q.loading.hide()
-    },
-    changePassword () {
-      this.$q.dialog({
-        component: UserChangePwdDialog,
-        componentProps: {
-          userId: this.userId
-        }
-      }).onOk(({ mustChangePassword }) => {
-        this.localAuth = {
-          ...this.localAuth,
-          mustChangePwd: mustChangePassword
-        }
-      })
-    },
-    invalidateTFA () {
-      this.$q.dialog({
-        title: this.$t('admin.users.tfaInvalidate'),
-        message: this.$t('admin.users.tfaInvalidateConfirm'),
-        cancel: true,
-        persistent: true,
-        ok: {
-          label: this.$t('common.actions.confirm')
-        }
-      }).onOk(() => {
-        this.localAuth.tfaSecret = ''
-        this.$q.notify({
+    })
+    if (resp?.data?.updateUser?.operation?.succeeded) {
+      if (!silent) {
+        $q.notify({
           type: 'positive',
-          message: this.$t('admin.users.tfaInvalidateSuccess')
+          message: t('admin.users.saveSuccess')
         })
-      })
-    },
-    async sendWelcomeEmail () {
-
-    },
-    toggleVerified () {
-      this.user.isVerified = !this.user.isVerified
-      this.save({
-        isVerified: this.user.isVerified
-      }, { silent: true, keepOpen: true })
-    },
-    toggleBan () {
-      this.user.isActive = !this.user.isActive
-      this.save({
-        isActive: this.user.isActive
-      }, { silent: true, keepOpen: true })
-    },
-    async deleteUser () {
-
+      }
+      if (!keepOpen) {
+        close()
+      }
+    } else {
+      throw new Error(resp?.data?.updateUser?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  $q.loading.hide()
+}
+
+function changePassword () {
+  $q.dialog({
+    component: UserChangePwdDialog,
+    componentProps: {
+      userId: adminStore.overlayOpts.id
+    }
+  }).onOk(({ mustChangePassword }) => {
+    localAuth.value = {
+      ...localAuth.value,
+      mustChangePwd: mustChangePassword
+    }
+  })
+}
+
+function invalidateTFA () {
+  $q.dialog({
+    title: t('admin.users.tfaInvalidate'),
+    message: t('admin.users.tfaInvalidateConfirm'),
+    cancel: true,
+    persistent: true,
+    ok: {
+      label: t('common.actions.confirm')
+    }
+  }).onOk(() => {
+    localAuth.value.tfaSecret = ''
+    $q.notify({
+      type: 'positive',
+      message: t('admin.users.tfaInvalidateSuccess')
+    })
+  })
+}
+
+async function sendWelcomeEmail () {
+
 }
+
+function toggleVerified () {
+  state.user.isVerified = !state.user.isVerified
+  save({
+    isVerified: state.user.isVerified
+  }, { silent: true, keepOpen: true })
+}
+
+function toggleBan () {
+  state.user.isActive = !state.user.isActive
+  save({
+    isActive: state.user.isActive
+  }, { silent: true, keepOpen: true })
+}
+
+async function deleteUser () {
+
+}
+
+// MOUNTED
+
+onMounted(() => {
+  checkRoute()
+  fetchUser()
+})
+
 </script>
+
+<style lang="scss" scoped>
+.metadata-codemirror {
+  &:deep(.cm-editor) {
+    height: 150px;
+    min-height: 100px;
+    border-radius: 5px;
+    border: 1px solid #CCC;
+  }
+}
+</style>

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

@@ -168,6 +168,8 @@ const headers = [
   }
 ]
 
+// WATCHERS
+
 watch(() => adminStore.overlay, (newValue, oldValue) => {
   if (newValue === '' && oldValue === 'GroupEditOverlay') {
     router.push('/_admin/groups')
@@ -175,9 +177,7 @@ watch(() => adminStore.overlay, (newValue, oldValue) => {
   }
 })
 
-watch(() => route.params.id, () => {
-  checkOverlay()
-})
+watch(() => route.params.id, checkOverlay)
 
 // METHODS
 

+ 165 - 133
ux/src/pages/AdminUsers.vue

@@ -4,12 +4,12 @@ q-page.admin-groups
     .col-auto
       img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-account.svg')
     .col.q-pl-md
-      .text-h5.text-primary.animated.fadeInLeft {{ $t('admin.users.title') }}
-      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ $t('admin.users.subtitle') }}
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.users.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.users.subtitle') }}
     .col-auto.flex.items-center
       q-input.denser.q-mr-sm(
         outlined
-        v-model='search'
+        v-model='state.search'
         dense
         :class='$q.dark.isActive ? `bg-dark` : `bg-white`'
         )
@@ -28,29 +28,29 @@ q-page.admin-groups
         flat
         color='secondary'
         @click='load'
-        :loading='loading > 0'
+        :loading='state.loading > 0'
         )
       q-btn(
         unelevated
         icon='las la-plus'
-        :label='$t(`admin.users.create`)'
+        :label='t(`admin.users.create`)'
         color='primary'
         @click='createUser'
-        :disabled='loading > 0'
+        :disabled='state.loading > 0'
         )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
       q-card.shadow-1
         q-table(
-          :rows='users'
+          :rows='state.users'
           :columns='headers'
           row-key='id'
           flat
           hide-header
           hide-bottom
           :rows-per-page-options='[0]'
-          :loading='loading > 0'
+          :loading='state.loading > 0'
           )
           template(v-slot:body-cell-id='props')
             q-td(:props='props')
@@ -92,7 +92,7 @@ q-page.admin-groups
                 :to='`/_admin/users/` + props.row.id'
                 icon='las la-pen'
                 color='indigo'
-                :label='$t(`common.actions.edit`)'
+                :label='t(`common.actions.edit`)'
                 no-caps
                 )
               q-btn.acrylic-btn(
@@ -100,146 +100,178 @@ q-page.admin-groups
                 flat
                 icon='las la-trash'
                 color='accent'
-                @click='deleteGroup(props.row)'
+                @click='deleteUser(props.row)'
                 )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
 import cloneDeep from 'lodash/cloneDeep'
 import { DateTime } from 'luxon'
-import { sync } from 'vuex-pathify'
-import { createMetaMixin } from 'quasar'
+import { useI18n } from 'vue-i18n'
+import { useMeta, useQuasar } from 'quasar'
+import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
+
+import { useAdminStore } from 'src/stores/admin'
 
 import UserCreateDialog from '../components/UserCreateDialog.vue'
 
-export default {
-  mixins: [
-    createMetaMixin(function () {
-      return {
-        title: this.$t('admin.users.title')
-      }
-    })
-  ],
-  data () {
-    return {
-      users: [],
-      loading: 0,
-      search: ''
-    }
-  },
-  computed: {
-    overlay: sync('admin/overlay', false),
-    headers () {
-      return [
-        {
-          align: 'center',
-          field: 'id',
-          name: 'id',
-          sortable: false,
-          style: 'width: 20px'
-        },
-        {
-          label: this.$t('common.field.name'),
-          align: 'left',
-          field: 'name',
-          name: 'name',
-          sortable: true
-        },
-        {
-          label: this.$t('admin.users.email'),
-          align: 'left',
-          field: 'email',
-          name: 'email',
-          sortable: false
-        },
-        {
-          align: 'left',
-          field: 'createdAt',
-          name: 'date',
-          sortable: false
-        },
-        {
-          label: '',
-          align: 'right',
-          field: 'edit',
-          name: 'edit',
-          sortable: false,
-          style: 'width: 250px'
-        }
-      ]
-    }
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.users.title')
+})
+
+// DATA
+
+const state = reactive({
+  users: [],
+  loading: 0,
+  search: ''
+})
+
+const headers = [
+  {
+    align: 'center',
+    field: 'id',
+    name: 'id',
+    sortable: false,
+    style: 'width: 20px'
   },
-  watch: {
-    overlay (newValue, oldValue) {
-      if (newValue === '' && oldValue === 'UserEditOverlay') {
-        this.$router.push('/_admin/users')
-        this.load()
-      }
-    },
-    $route: 'checkOverlay'
+  {
+    label: t('common.field.name'),
+    align: 'left',
+    field: 'name',
+    name: 'name',
+    sortable: true
   },
-  mounted () {
-    this.checkOverlay()
-    this.load()
+  {
+    label: t('admin.users.email'),
+    align: 'left',
+    field: 'email',
+    name: 'email',
+    sortable: false
   },
-  beforeUnmount () {
-    this.overlay = ''
+  {
+    align: 'left',
+    field: 'createdAt',
+    name: 'date',
+    sortable: false
   },
-  methods: {
-    async load () {
-      this.loading++
-      this.$q.loading.show()
-      const resp = await this.$apollo.query({
-        query: gql`
-          query getUsers {
-            users {
-              id
-              name
-              email
-              isSystem
-              isActive
-              createdAt
-              lastLoginAt
-            }
-          }
-        `,
-        fetchPolicy: 'network-only'
-      })
-      this.users = cloneDeep(resp?.data?.users)
-      this.$q.loading.hide()
-      this.loading--
-    },
-    humanizeDate (val) {
-      return DateTime.fromISO(val).toRelative()
-    },
-    checkOverlay () {
-      if (this.$route.params && this.$route.params.id) {
-        this.$store.set('admin/overlayOpts', { id: this.$route.params.id })
-        this.$store.set('admin/overlay', 'UserEditOverlay')
-      } else {
-        this.$store.set('admin/overlay', '')
-      }
-    },
-    createUser () {
-      this.$q.dialog({
-        component: UserCreateDialog
-      }).onOk(() => {
-        this.load()
-      })
-    },
-    deleteUser (gr) {
-      this.$q.dialog({
-        // component: UserDeleteDialog,
-        componentProps: {
-          group: gr
+  {
+    label: '',
+    align: 'right',
+    field: 'edit',
+    name: 'edit',
+    sortable: false,
+    style: 'width: 250px'
+  }
+]
+
+// WATCHERS
+
+watch(() => adminStore.overlay, (newValue, oldValue) => {
+  if (newValue === '' && oldValue === 'UserEditOverlay') {
+    router.push('/_admin/users')
+    load()
+  }
+})
+
+watch(() => route.params.id, checkOverlay)
+
+// METHODS
+
+async function load () {
+  state.loading++
+  $q.loading.show()
+  const resp = await APOLLO_CLIENT.query({
+    query: gql`
+      query getUsers {
+        users {
+          id
+          name
+          email
+          isSystem
+          isActive
+          createdAt
+          lastLoginAt
         }
-      }).onOk(() => {
-        this.load()
-      })
-    }
+      }
+    `,
+    fetchPolicy: 'network-only'
+  })
+  state.users = cloneDeep(resp?.data?.users)
+  $q.loading.hide()
+  state.loading--
+}
+
+function humanizeDate (val) {
+  return DateTime.fromISO(val).toRelative()
+}
+
+function checkOverlay () {
+  if (route.params?.id) {
+    adminStore.$patch({
+      overlayOpts: { id: route.params.id },
+      overlay: 'UserEditOverlay'
+    })
+  } else {
+    adminStore.$patch({
+      overlay: ''
+    })
   }
 }
+
+function createUser () {
+  $q.dialog({
+    component: UserCreateDialog
+  }).onOk(() => {
+    this.load()
+  })
+}
+
+function deleteUser (usr) {
+  $q.dialog({
+    // component: UserDeleteDialog,
+    componentProps: {
+      user: usr
+    }
+  }).onOk(load)
+}
+
+// MOUNTED
+
+onMounted(() => {
+  checkOverlay()
+  load()
+})
+
+// BEFORE UNMOUNT
+
+onBeforeUnmount(() => {
+  adminStore.$patch({
+    overlay: ''
+  })
+})
+
 </script>
 
 <style lang='scss'>

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

@@ -42,7 +42,7 @@ const routes = [
       // -> Users
       // { 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') },
+      { path: 'users/:id?/:section?', component: () => import('../pages/AdminUsers.vue') },
       // -> System
       // { path: 'api', component: () => import('../pages/AdminApi.vue') },
       { path: 'extensions', component: () => import('../pages/AdminExtensions.vue') },

+ 18 - 1
ux/yarn.lock

@@ -97,7 +97,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@codemirror/basic-setup@npm:0.20.0":
+"@codemirror/basic-setup@npm:0.20.0, @codemirror/basic-setup@npm:^0.20.0":
   version: 0.20.0
   resolution: "@codemirror/basic-setup@npm:0.20.0"
   dependencies:
@@ -6717,6 +6717,7 @@ __metadata:
     uuid: 8.3.2
     v-network-graph: 0.5.16
     vue: 3.2.31
+    vue-codemirror: 5.0.1
     vue-i18n: 9.1.10
     vue-router: 4.0.15
     vuedraggable: 4.1.0
@@ -6816,6 +6817,22 @@ __metadata:
   languageName: node
   linkType: hard
 
+"vue-codemirror@npm:5.0.1":
+  version: 5.0.1
+  resolution: "vue-codemirror@npm:5.0.1"
+  dependencies:
+    "@codemirror/basic-setup": ^0.20.0
+    "@codemirror/commands": ^0.20.0
+    "@codemirror/language": ^0.20.0
+    "@codemirror/state": ^0.20.0
+    "@codemirror/view": ^0.20.0
+    csstype: ^2.6.8
+  peerDependencies:
+    vue: 3.x
+  checksum: 5d96312123d109e619ecec56e8ddb1b2bdf294738a1ac796d6d35deefba9bfa25e1a311a29aa79315ba2ef48c0a6df33597a8e062f64b93166801dbed632f599
+  languageName: node
+  linkType: hard
+
 "vue-demi@npm:*":
   version: 0.12.5
   resolution: "vue-demi@npm:0.12.5"