浏览代码

feat(admin): migrate groups dialogs to vue 3 composable

Nicolas Giard 3 年之前
父节点
当前提交
6e303ac648

+ 4 - 3
server/app/data.yml

@@ -131,9 +131,9 @@ groups:
     - 'read:assets'
     - 'read:comments'
     - 'write:comments'
-  defaultPageRules:
-    - id: default
-      deny: false
+  defaultRules:
+    - name: Default Rule
+      mode: ALLOW
       match: START
       roles:
         - 'read:pages'
@@ -142,6 +142,7 @@ groups:
         - 'write:comments'
       path: ''
       locales: []
+      sites: []
 reservedPaths:
   - login
   - logout

+ 23 - 20
server/graph/resolvers/group.js

@@ -1,7 +1,7 @@
 const graphHelper = require('../../helpers/graph')
 const safeRegex = require('safe-regex')
 const _ = require('lodash')
-const gql = require('graphql')
+const { v4: uuid } = require('uuid')
 
 /* global WIKI */
 
@@ -30,13 +30,13 @@ module.exports = {
     async assignUserToGroup (obj, args, { req }) {
       // Check for guest user
       if (args.userId === 2) {
-        throw new gql.GraphQLError('Cannot assign the Guest user to a group.')
+        throw new Error('Cannot assign the Guest user to a group.')
       }
 
       // Check for valid group
       const grp = await WIKI.models.groups.query().findById(args.groupId)
       if (!grp) {
-        throw new gql.GraphQLError('Invalid Group ID')
+        throw new Error('Invalid Group ID')
       }
 
       // Check assigned permissions for write:groups
@@ -47,13 +47,13 @@ module.exports = {
           return ['users', 'groups', 'navigation', 'theme', 'api', 'system'].includes(resType)
         })
       ) {
-        throw new gql.GraphQLError('You are not authorized to assign a user to this elevated group.')
+        throw new Error('You are not authorized to assign a user to this elevated group.')
       }
 
       // Check for valid user
       const usr = await WIKI.models.users.query().findById(args.userId)
       if (!usr) {
-        throw new gql.GraphQLError('Invalid User ID')
+        throw new Error('Invalid User ID')
       }
 
       // Check for existing relation
@@ -62,7 +62,7 @@ module.exports = {
         groupId: args.groupId
       }).first()
       if (relExist) {
-        throw new gql.GraphQLError('User is already assigned to group.')
+        throw new Error('User is already assigned to group.')
       }
 
       // Assign user to group
@@ -73,7 +73,7 @@ module.exports = {
       WIKI.events.outbound.emit('addAuthRevoke', { id: usr.id, kind: 'u' })
 
       return {
-        responseResult: graphHelper.generateSuccess('User has been assigned to group.')
+        operation: graphHelper.generateSuccess('User has been assigned to group.')
       }
     },
     /**
@@ -83,13 +83,16 @@ module.exports = {
       const group = await WIKI.models.groups.query().insertAndFetch({
         name: args.name,
         permissions: JSON.stringify(WIKI.data.groups.defaultPermissions),
-        pageRules: JSON.stringify(WIKI.data.groups.defaultPageRules),
+        rules: JSON.stringify(WIKI.data.groups.defaultRules.map(r => ({
+          id: uuid(),
+          ...r
+        }))),
         isSystem: false
       })
       await WIKI.auth.reloadGroups()
       WIKI.events.outbound.emit('reloadGroups')
       return {
-        responseResult: graphHelper.generateSuccess('Group created successfully.'),
+        operation: graphHelper.generateSuccess('Group created successfully.'),
         group
       }
     },
@@ -98,7 +101,7 @@ module.exports = {
      */
     async deleteGroup (obj, args) {
       if (args.id === 1 || args.id === 2) {
-        throw new gql.GraphQLError('Cannot delete this group.')
+        throw new Error('Cannot delete this group.')
       }
 
       await WIKI.models.groups.query().deleteById(args.id)
@@ -110,7 +113,7 @@ module.exports = {
       WIKI.events.outbound.emit('reloadGroups')
 
       return {
-        responseResult: graphHelper.generateSuccess('Group has been deleted.')
+        operation: graphHelper.generateSuccess('Group has been deleted.')
       }
     },
     /**
@@ -118,18 +121,18 @@ module.exports = {
      */
     async unassignUserFromGroup (obj, args) {
       if (args.userId === 2) {
-        throw new gql.GraphQLError('Cannot unassign Guest user')
+        throw new Error('Cannot unassign Guest user')
       }
       if (args.userId === 1 && args.groupId === 1) {
-        throw new gql.GraphQLError('Cannot unassign Administrator user from Administrators group.')
+        throw new Error('Cannot unassign Administrator user from Administrators group.')
       }
       const grp = await WIKI.models.groups.query().findById(args.groupId)
       if (!grp) {
-        throw new gql.GraphQLError('Invalid Group ID')
+        throw new Error('Invalid Group ID')
       }
       const usr = await WIKI.models.users.query().findById(args.userId)
       if (!usr) {
-        throw new gql.GraphQLError('Invalid User ID')
+        throw new Error('Invalid User ID')
       }
       await grp.$relatedQuery('users').unrelate().where('userId', usr.id)
 
@@ -137,7 +140,7 @@ module.exports = {
       WIKI.events.outbound.emit('addAuthRevoke', { id: usr.id, kind: 'u' })
 
       return {
-        responseResult: graphHelper.generateSuccess('User has been unassigned from group.')
+        operation: graphHelper.generateSuccess('User has been unassigned from group.')
       }
     },
     /**
@@ -148,7 +151,7 @@ module.exports = {
       if (_.some(args.pageRules, pr => {
         return pr.match === 'REGEX' && !safeRegex(pr.path)
       })) {
-        throw new gql.GraphQLError('Some Page Rules contains unsafe or exponential time regex.')
+        throw new Error('Some Page Rules contains unsafe or exponential time regex.')
       }
 
       // Set default redirect on login value
@@ -164,7 +167,7 @@ module.exports = {
           return ['users', 'groups', 'navigation', 'theme', 'api', 'system'].includes(resType)
         })
       ) {
-        throw new gql.GraphQLError('You are not authorized to manage this group or assign these permissions.')
+        throw new Error('You are not authorized to manage this group or assign these permissions.')
       }
 
       // Check assigned permissions for manage:groups
@@ -172,7 +175,7 @@ module.exports = {
         WIKI.auth.checkExclusiveAccess(req.user, ['manage:groups'], ['manage:system']) &&
         args.permissions.some(p => _.last(p.split(':')) === 'system')
       ) {
-        throw new gql.GraphQLError('You are not authorized to manage this group or assign the manage:system permissions.')
+        throw new Error('You are not authorized to manage this group or assign the manage:system permissions.')
       }
 
       // Update group
@@ -192,7 +195,7 @@ module.exports = {
       WIKI.events.outbound.emit('reloadGroups')
 
       return {
-        responseResult: graphHelper.generateSuccess('Group has been updated.')
+        operation: graphHelper.generateSuccess('Group has been updated.')
       }
     }
   },

+ 2 - 1
ux/jsconfig.json

@@ -1,6 +1,7 @@
 {
   "compilerOptions": {
     "baseUrl": ".",
+    "jsx": "preserve",
     "paths": {
       "src/*": [
         "src/*"
@@ -36,4 +37,4 @@
     ".quasar",
     "node_modules"
   ]
-}
+}

+ 84 - 70
ux/src/components/GroupCreateDialog.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: 450px;')
     q-card-section.card-header
       q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm')
-      span {{$t(`admin.groups.create`)}}
+      span {{t(`admin.groups.create`)}}
     q-form.q-py-sm(ref='createGroupForm', @submit='create')
       q-item
         blueprint-icon(icon='team')
         q-item-section
           q-input(
             outlined
-            v-model='groupName'
+            v-model='state.groupName'
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.groups.nameMissing'),
-              val => /^[^<>"]+$/.test(val) || $t('admin.groups.nameInvalidChars')
-            ]`
+            :rules='groupNameValidation'
             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
             )
@@ -26,86 +23,103 @@ q-dialog(ref='dialog', @hide='onDialogHide')
       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='isLoading'
+        :loading='state.isLoading'
         )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive, ref } from 'vue'
 
-export default {
-  emits: ['ok', 'hide'],
-  data () {
-    return {
-      groupName: '',
-      isLoading: false
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  groupName: '',
+  isLoading: false
+})
+
+// REFS
+
+const createGroupForm = ref(null)
+
+// VALIDATION RULES
+
+const groupNameValidation = [
+  val => val.length > 0 || t('admin.groups.nameMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.groups.nameInvalidChars')
+]
+
+// METHODS
+
+async function create () {
+  state.isLoading = true
+  try {
+    const isFormValid = await createGroupForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.groups.createInvalidData'))
     }
-  },
-  methods: {
-    show () {
-      this.$refs.dialog.show()
-    },
-    hide () {
-      this.$refs.dialog.hide()
-    },
-    onDialogHide () {
-      this.$emit('hide')
-    },
-    async create () {
-      this.isLoading = true
-      try {
-        const isFormValid = await this.$refs.createGroupForm.validate(true)
-        if (!isFormValid) {
-          throw new Error(this.$t('admin.groups.createInvalidData'))
-        }
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation createGroup (
-              $name: String!
-              ) {
-              createGroup(
-                name: $name
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation createGroup (
+          $name: String!
+          ) {
+          createGroup(
+            name: $name
+            ) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          variables: {
-            name: this.groupName
           }
-        })
-        if (resp?.data?.createGroup?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.groups.createSuccess')
-          })
-          this.$emit('ok')
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.createGroup?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        name: state.groupName
       }
-      this.isLoading = false
+    })
+    if (resp?.data?.createGroup?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.groups.createSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.createGroup?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.isLoading = false
 }
 </script>

+ 62 - 59
ux/src/components/GroupDeleteDialog.vue

@@ -1,93 +1,96 @@
 <template lang="pug">
-q-dialog(ref='dialog', @hide='onDialogHide')
+q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card(style='min-width: 350px; max-width: 450px;')
     q-card-section.card-header
       q-icon(name='img:/_assets/icons/fluent-delete-bin.svg', left, size='sm')
-      span {{$t(`admin.groups.delete`)}}
+      span {{t(`admin.groups.delete`)}}
     q-card-section
       .text-body2
         i18n-t(keypath='admin.groups.deleteConfirm')
           template(#groupName)
-            strong {{group.name}}
+            strong {{props.group.name}}
       .text-body2.q-mt-md
-        strong.text-negative {{$t(`admin.groups.deleteConfirmWarn`)}}
+        strong.text-negative {{t(`admin.groups.deleteConfirmWarn`)}}
     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.delete`)'
+        :label='t(`common.actions.delete`)'
         color='negative'
         padding='xs md'
         @click='confirm'
         )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
 
-export default {
-  props: {
-    group: {
-      type: Object,
-      required: true
-    }
-  },
-  emits: ['ok', 'hide'],
-  data () {
-    return {
-    }
-  },
-  methods: {
-    show () {
-      this.$refs.dialog.show()
-    },
-    hide () {
-      this.$refs.dialog.hide()
-    },
-    onDialogHide () {
-      this.$emit('hide')
-    },
-    async confirm () {
-      try {
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation deleteGroup ($id: UUID!) {
-              deleteGroup(id: $id) {
-                status {
-                  succeeded
-                  message
-                }
-              }
+// PROPS
+
+const props = defineProps({
+  group: {
+    type: Object,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// METHODS
+
+async function confirm () {
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation deleteGroup ($id: UUID!) {
+          deleteGroup(id: $id) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          variables: {
-            id: this.group.id
           }
-        })
-        if (resp?.data?.deleteGroup?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.groups.deleteSuccess')
-          })
-          this.$emit('ok')
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.deleteGroup?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: props.group.id
       }
+    })
+    if (resp?.data?.deleteGroup?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.groups.deleteSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.deleteGroup?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
 }
 </script>

+ 616 - 565
ux/src/components/GroupEditOverlay.vue

@@ -3,24 +3,24 @@ q-layout(view='hHh lpR fFf', container)
   q-header.card-header.q-px-md.q-py-sm
     q-icon(name='img:/_assets/icons/fluent-people.svg', left, size='md')
     div
-      span {{$t(`admin.groups.edit`)}}
-      .text-caption {{group.name}}
+      span {{t(`admin.groups.edit`)}}
+      .text-caption {{state.group.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='refresh'
         )
-        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`)'
+        :label='t(`common.actions.close`)'
         icon='las la-times'
         @click='close'
       )
@@ -28,11 +28,11 @@ q-layout(view='hHh lpR fFf', container)
         push
         color='positive'
         text-color='white'
-        :label='$t(`common.actions.save`)'
+        :label='t(`common.actions.save`)'
         icon='las la-check'
       )
   q-drawer.bg-dark-6(:model-value='true', :width='250', dark)
-    q-list(padding, v-show='!isLoading')
+    q-list(padding, v-show='!state.isLoading')
       q-item(
         v-for='sc of sections'
         :key='`section-` + sc.key'
@@ -45,109 +45,107 @@ q-layout(view='hHh lpR fFf', container)
           q-icon(:name='sc.icon', color='white')
         q-item-section {{sc.text}}
         q-item-section(side, v-if='sc.usersTotal')
-          q-badge(color='dark-3', :label='usersTotal')
-        q-item-section(side, v-if='sc.rulesTotal && group.rules')
-          q-badge(color='dark-3', :label='group.rules.length')
+          q-badge(color='dark-3', :label='state.usersTotal')
+        q-item-section(side, v-if='sc.rulesTotal && state.group.rules')
+          q-badge(color='dark-3', :label='state.group.rules.length')
   q-page-container
-    q-page(v-if='isLoading')
+    q-page(v-if='state.isLoading')
     //- -----------------------------------------------------------------------
     //- OVERVIEW
     //- -----------------------------------------------------------------------
-    q-page(v-else-if='$route.params.section === `overview`')
+    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.groups.general')}}
+                .text-subtitle1 {{t('admin.groups.general')}}
               q-item
                 blueprint-icon(icon='team')
                 q-item-section
-                  q-item-label {{$t(`admin.groups.name`)}}
-                  q-item-label(caption) {{$t(`admin.groups.nameHint`)}}
+                  q-item-label {{t(`admin.groups.name`)}}
+                  q-item-label(caption) {{t(`admin.groups.nameHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='group.name'
+                    v-model='state.group.name'
                     dense
-                    :rules=`[
-                      val => /^[^<>"]+$/.test(val) || $t('admin.groups.nameInvalidChars')
-                    ]`
+                    :rules='groupNameValidation'
                     hide-bottom-space
-                    :aria-label='$t(`admin.groups.name`)'
+                    :aria-label='t(`admin.groups.name`)'
                     )
 
             q-card.shadow-1.q-pb-sm.q-mt-md
               q-card-section
-                .text-subtitle1 {{$t('admin.groups.authBehaviors')}}
+                .text-subtitle1 {{t('admin.groups.authBehaviors')}}
               q-item
                 blueprint-icon(icon='double-right')
                 q-item-section
-                  q-item-label {{$t(`admin.groups.redirectOnLogin`)}}
-                  q-item-label(caption) {{$t(`admin.groups.redirectOnLoginHint`)}}
+                  q-item-label {{t(`admin.groups.redirectOnLogin`)}}
+                  q-item-label(caption) {{t(`admin.groups.redirectOnLoginHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='group.redirectOnLogin'
+                    v-model='state.group.redirectOnLogin'
                     dense
-                    :aria-label='$t(`admin.groups.redirectOnLogin`)'
+                    :aria-label='t(`admin.groups.redirectOnLogin`)'
                     )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='chevron-right')
                 q-item-section
-                  q-item-label {{$t(`admin.groups.redirectOnFirstLogin`)}}
-                  q-item-label(caption) {{$t(`admin.groups.redirectOnFirstLoginHint`)}}
+                  q-item-label {{t(`admin.groups.redirectOnFirstLogin`)}}
+                  q-item-label(caption) {{t(`admin.groups.redirectOnFirstLoginHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='group.redirectOnFirstLogin'
+                    v-model='state.group.redirectOnFirstLogin'
                     dense
-                    :aria-label='$t(`admin.groups.redirectOnLogin`)'
+                    :aria-label='t(`admin.groups.redirectOnLogin`)'
                     )
               q-separator.q-my-sm(inset)
               q-item
                 blueprint-icon(icon='exit')
                 q-item-section
-                  q-item-label {{$t(`admin.groups.redirectOnLogout`)}}
-                  q-item-label(caption) {{$t(`admin.groups.redirectOnLogoutHint`)}}
+                  q-item-label {{t(`admin.groups.redirectOnLogout`)}}
+                  q-item-label(caption) {{t(`admin.groups.redirectOnLogoutHint`)}}
                 q-item-section
                   q-input(
                     outlined
-                    v-model='group.redirectOnLogout'
+                    v-model='state.group.redirectOnLogout'
                     dense
-                    :aria-label='$t(`admin.groups.redirectOnLogout`)'
+                    :aria-label='t(`admin.groups.redirectOnLogout`)'
                     )
 
           .col-12.col-lg-4
             q-card.shadow-1.q-pb-sm
               q-card-section
-                .text-subtitle1 {{$t('admin.groups.info')}}
+                .text-subtitle1 {{t('admin.groups.info')}}
               q-item
                 blueprint-icon(icon='team', :hue-rotate='-45')
                 q-item-section
-                  q-item-label {{$t(`common.field.id`)}}
-                  q-item-label: strong {{groupId}}
+                  q-item-label {{t(`common.field.id`)}}
+                  q-item-label: strong {{state.group.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(group.createdAt)}}
+                  q-item-label {{t(`common.field.createdOn`)}}
+                  q-item-label: strong {{humanizeDate(state.group.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(group.updatedAt)}}
+                  q-item-label {{t(`common.field.lastUpdated`)}}
+                  q-item-label: strong {{humanizeDate(state.group.updatedAt)}}
     //- -----------------------------------------------------------------------
     //- RULES
     //- -----------------------------------------------------------------------
-    q-page(v-else-if='$route.params.section === `rules`')
+    q-page(v-else-if='route.params.section === `rules`')
       q-toolbar.q-pl-md(
         :class='$q.dark.isActive ? `bg-dark-3` : `bg-white`'
         )
-        .text-subtitle1 {{$t('admin.groups.rules')}}
+        .text-subtitle1 {{t('admin.groups.rules')}}
         q-space
         q-btn.acrylic-btn.q-mr-sm(
           icon='las la-question-circle'
@@ -163,14 +161,14 @@ q-layout(view='hHh lpR fFf', container)
           icon='las la-file-export'
           @click='exportRules'
           )
-          q-tooltip {{$t('admin.groups.exportRules')}}
+          q-tooltip {{t('admin.groups.exportRules')}}
         q-btn.acrylic-btn.q-mr-sm(
           flat
           color='indigo'
           icon='las la-file-import'
           @click='importRules'
           )
-          q-tooltip {{$t('admin.groups.importRules')}}
+          q-tooltip {{t('admin.groups.importRules')}}
         q-btn(
           unelevated
           color='primary'
@@ -181,14 +179,14 @@ q-layout(view='hHh lpR fFf', container)
       q-separator
       .q-pa-md
         q-banner(
-          v-if='!group.rules || group.rules.length < 1'
+          v-if='!state.group.rules || state.group.rules.length < 1'
           rounded
           :class='$q.dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`'
-          ) {{$t('admin.groups.rulesNone')}}
+          ) {{t('admin.groups.rulesNone')}}
         q-card.shadow-1.q-pb-sm(v-else)
           q-card-section
             .admin-groups-rule(
-              v-for='(rule, idx) of group.rules'
+              v-for='(rule, idx) of state.group.rules'
               :key='rule.id'
               )
               .admin-groups-rule-icon(:class='getRuleModeColor(rule.mode)')
@@ -213,7 +211,7 @@ q-layout(view='hHh lpR fFf', container)
                     emit-value
                     map-options
                     dense
-                    :aria-label='$t(`admin.groups.ruleSites`)'
+                    :aria-label='t(`admin.groups.ruleSites`)'
                     :options='rules'
                     placeholder='Select permissions...'
                     option-value='permission'
@@ -261,13 +259,13 @@ q-layout(view='hHh lpR fFf', container)
                       emit-value
                       map-options
                       dense
-                      :aria-label='$t(`admin.groups.ruleSites`)'
-                      :options='sites'
+                      :aria-label='t(`admin.groups.ruleSites`)'
+                      :options='adminStore.sites'
                       option-value='id'
                       option-label='title'
                       multiple
                       behavior='dialog'
-                      :display-value='$tc(`admin.groups.selectedSites`, rule.sites.length, { count: rule.sites.length })'
+                      :display-value='t(`admin.groups.selectedSites`, rule.sites.length, { count: rule.sites.length })'
                       )
                       template(v-slot:option='{ itemProps, itemEvents, opt, selected, toggleOption }')
                         q-item(v-bind='itemProps', v-on='itemEvents')
@@ -288,13 +286,13 @@ q-layout(view='hHh lpR fFf', container)
                       emit-value
                       map-options
                       dense
-                      :aria-label='$t(`admin.groups.ruleLocales`)'
-                      :options='locales'
+                      :aria-label='t(`admin.groups.ruleLocales`)'
+                      :options='adminStore.locales'
                       option-value='code'
                       option-label='name'
                       multiple
                       behavior='dialog'
-                      :display-value='$tc(`admin.groups.selectedLocales`, rule.locales.length, { count: rule.locales.length, locale: rule.locales.length === 1 ? rule.locales[0].toUpperCase() : `` })'
+                      :display-value='t(`admin.groups.selectedLocales`, rule.locales.length, { count: rule.locales.length, locale: rule.locales.length === 1 ? rule.locales[0].toUpperCase() : `` })'
                       )
                       template(v-slot:option='{ itemProps, opt, selected, toggleOption }')
                         q-item(v-bind='itemProps')
@@ -317,14 +315,14 @@ q-layout(view='hHh lpR fFf', container)
                       emit-value
                       map-options
                       dense
-                      :aria-label='$t(`admin.groups.ruleMatch`)'
+                      :aria-label='t(`admin.groups.ruleMatch`)'
                       :options=`[
-                        { label: $t('admin.groups.ruleMatchStart'), value: 'START' },
-                        { label: $t('admin.groups.ruleMatchEnd'), value: 'END' },
-                        { label: $t('admin.groups.ruleMatchRegex'), value: 'REGEX' },
-                        { label: $t('admin.groups.ruleMatchTag'), value: 'TAG' },
-                        { label: $t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' },
-                        { label: $t('admin.groups.ruleMatchExact'), value: 'EXACT' }
+                        { label: t('admin.groups.ruleMatchStart'), value: 'START' },
+                        { label: t('admin.groups.ruleMatchEnd'), value: 'END' },
+                        { label: t('admin.groups.ruleMatchRegex'), value: 'REGEX' },
+                        { label: t('admin.groups.ruleMatchTag'), value: 'TAG' },
+                        { label: t('admin.groups.ruleMatchTagAll'), value: 'TAGALL' },
+                        { label: t('admin.groups.ruleMatchExact'), value: 'EXACT' }
                       ]`
                     )
                     q-input.q-mt-sm(
@@ -333,19 +331,19 @@ q-layout(view='hHh lpR fFf', container)
                       dense
                       :prefix='[`START`, `REGEX`, `EXACT`].includes(rule.match) ? `/` : null'
                       :suffix='rule.match === `REGEX` ? `/` : null'
-                      :aria-label='$t(`admin.groups.rulePath`)'
+                      :aria-label='t(`admin.groups.rulePath`)'
                     )
     //- -----------------------------------------------------------------------
     //- PERMISSIONS
     //- -----------------------------------------------------------------------
-    q-page(v-else-if='$route.params.section === `permissions`')
+    q-page(v-else-if='route.params.section === `permissions`')
       .q-pa-md
         .row.q-col-gutter-md
           .col-12.col-lg-6
             q-card.shadow-1.q-pb-sm
               .flex.justify-between
                 q-card-section
-                  .text-subtitle1 {{$t(`admin.groups.permissions`)}}
+                  .text-subtitle1 {{t(`admin.groups.permissions`)}}
                 q-card-section
                   q-btn.acrylic-btn(
                     icon='las la-question-circle'
@@ -368,22 +366,22 @@ q-layout(view='hHh lpR fFf', container)
                     q-item-label(caption) {{perm.hint}}
                   q-item-section(avatar)
                     q-toggle(
-                      v-model='group.permissions'
+                      v-model='state.group.permissions'
                       :val='perm.permission'
                       color='primary'
                       checked-icon='las la-check'
                       unchecked-icon='las la-times'
-                      :aria-label='$t(`admin.general.allowComments`)'
+                      :aria-label='t(`admin.general.allowComments`)'
                       )
                 q-separator.q-my-sm(inset, v-if='idx < permissions.length - 1')
     //- -----------------------------------------------------------------------
     //- USERS
     //- -----------------------------------------------------------------------
-    q-page(v-else-if='$route.params.section === `users`')
+    q-page(v-else-if='route.params.section === `users`')
       q-toolbar(
         :class='$q.dark.isActive ? `bg-dark-3` : `bg-white`'
         )
-        .text-subtitle1 {{$t('admin.groups.users')}}
+        .text-subtitle1 {{t('admin.groups.users')}}
         q-space
         q-btn.acrylic-btn.q-mr-sm(
           icon='las la-question-circle'
@@ -395,8 +393,8 @@ q-layout(view='hHh lpR fFf', container)
           )
         q-input.denser.fill-outline.q-mr-sm(
           outlined
-          v-model='usersFilter'
-          :placeholder='$t(`admin.groups.filterUsers`)'
+          v-model='state.usersFilter'
+          :placeholder='t(`admin.groups.filterUsers`)'
           dense
           )
           template(#prepend)
@@ -410,22 +408,27 @@ q-layout(view='hHh lpR fFf', container)
         q-btn.q-mr-xs(
           unelevated
           icon='las la-user-plus'
-          :label='$t(`admin.groups.assignUser`)'
+          :label='t(`admin.groups.assignUser`)'
           color='primary'
           @click='assignUser'
           )
       q-separator
       .q-pa-md
+        q-banner(
+          v-if='!state.users || state.users.length < 1'
+          rounded
+          :class='$q.dark.isActive ? `bg-negative text-white` : `bg-grey-4 text-grey-9`'
+          ) {{t('admin.groups.usersNone')}}
         q-card.shadow-1
           q-table(
-            :rows='users'
+            :rows='state.users'
             :columns='usersHeaders'
             row-key='id'
             flat
             hide-header
             hide-bottom
             :rows-per-page-options='[0]'
-            :loading='isLoadingUsers'
+            :loading='state.isLoadingUsers'
             )
             template(v-slot:body-cell-id='props')
               q-td(:props='props')
@@ -467,7 +470,7 @@ q-layout(view='hHh lpR fFf', container)
                   :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(
@@ -480,7 +483,7 @@ q-layout(view='hHh lpR fFf', container)
 
         .flex.flex-center.q-mt-md(v-if='usersTotalPages > 1')
           q-pagination(
-            v-model='usersPage'
+            v-model='state.usersPage'
             :max='usersTotalPages'
             :max-pages='9'
             boundary-numbers
@@ -488,517 +491,565 @@ q-layout(view='hHh lpR fFf', container)
           )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
-import { get } from 'vuex-pathify'
 import { DateTime } from 'luxon'
 import cloneDeep from 'lodash/cloneDeep'
 import some from 'lodash/some'
 import { v4 as uuid } from 'uuid'
-import { exportFile } from 'quasar'
 import { fileOpen } from 'browser-fs-access'
 
-export default {
-  data () {
-    return {
-      sections: [
-        { key: 'overview', text: this.$t('admin.groups.overview'), icon: 'las la-users' },
-        { key: 'rules', text: this.$t('admin.groups.rules'), icon: 'las la-file-invoice', rulesTotal: true },
-        { key: 'permissions', text: this.$t('admin.groups.permissions'), icon: 'las la-list-alt' },
-        { key: 'users', text: this.$t('admin.groups.users'), icon: 'las la-user', usersTotal: true }
-      ],
-      group: {
-        rules: []
-      },
-      isLoading: false,
-      // RULES
-      rules: [
-        {
-          permission: 'read:pages',
-          title: 'Read Pages',
-          hint: 'Can view and search pages.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'write:pages',
-          title: 'Write Pages',
-          hint: 'Can create and edit pages.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'review:pages',
-          title: 'Review Pages',
-          hint: 'Can review and approve edits submitted by users.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:pages',
-          title: 'Manage Pages',
-          hint: 'Can move existing pages to other locations the user has write access to.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'delete:pages',
-          title: 'Delete Pages',
-          hint: 'Can delete existing pages.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'write:styles',
-          title: 'Use CSS',
-          hint: 'Can insert CSS styles in pages.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'write:scripts',
-          title: 'Use JavaScript',
-          hint: 'Can insert JavaScript in pages.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'read:source',
-          title: 'View Pages Source',
-          hint: 'Can view pages source.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'read:history',
-          title: 'View Page History',
-          hint: 'Can view previous versions of pages.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'read:assets',
-          title: 'View Assets',
-          hint: 'Can view / use assets (such as images and files) in pages.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'write:assets',
-          title: 'Upload Assets',
-          hint: 'Can upload new assets (such as images and files).',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:assets',
-          title: 'Manage Assets',
-          hint: 'Can edit and delete existing assets (such as images and files).',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'read:comments',
-          title: 'Read Comments',
-          hint: 'Can view page comments.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'write:comments',
-          title: 'Write Comments',
-          hint: 'Can post new comments on pages.',
-          warning: false,
-          restrictedForSystem: false,
-          disabled: false
-        },
-        {
-          permission: 'manage:comments',
-          title: 'Manage Comments',
-          hint: 'Can edit and delete existing page comments.',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        }
-      ],
-      // PERMISSIONS
-      permissions: [
-        {
-          permission: 'write:users',
-          hint: 'Can create or authorize new users, but not modify existing ones',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:users',
-          hint: 'Can manage all users (but not users with administrative permissions)',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'write:groups',
-          hint: 'Can manage groups and assign CONTENT permissions / page rules',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:groups',
-          hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
-          warning: true,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:navigation',
-          hint: 'Can manage the site navigation',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:theme',
-          hint: 'Can manage and modify themes',
-          warning: false,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:api',
-          hint: 'Can generate and revoke API keys',
-          warning: true,
-          restrictedForSystem: true,
-          disabled: false
-        },
-        {
-          permission: 'manage:system',
-          hint: 'Can manage and access everything. Root administrator.',
-          warning: true,
-          restrictedForSystem: true,
-          disabled: true
+import { useI18n } from 'vue-i18n'
+import { exportFile, useQuasar } from 'quasar'
+import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
+import { useRouter, useRoute } from 'vue-router'
 
-        }
-      ],
-      // USERS
-      users: [],
-      isLoadingUsers: false,
-      usersFilter: '',
-      usersPage: 1,
-      usersPageSize: 15,
-      usersTotal: 0,
-      usersHeaders: [
-        {
-          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'
-        }
-      ]
-    }
+import { useAdminStore } from 'src/stores/admin'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// STORES
+
+const adminStore = useAdminStore()
+
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  group: {
+    rules: []
   },
-  computed: {
-    groupId: get('admin/overlayOpts@id', false),
-    sites: get('admin/sites', false),
-    locales: get('admin/locales', false),
-    usersTotalPages () {
-      if (this.usersTotal < 1) { return 0 }
-      return Math.ceil(this.usersTotal / this.usersPageSize)
-    }
+  isLoading: false,
+  users: [],
+  isLoadingUsers: false,
+  usersFilter: '',
+  usersPage: 1,
+  usersPageSize: 15,
+  usersTotal: 0
+})
+
+const sections = [
+  { key: 'overview', text: t('admin.groups.overview'), icon: 'las la-users' },
+  { key: 'rules', text: t('admin.groups.rules'), icon: 'las la-file-invoice', rulesTotal: true },
+  { key: 'permissions', text: t('admin.groups.permissions'), icon: 'las la-list-alt' },
+  { key: 'users', text: t('admin.groups.users'), icon: 'las la-user', usersTotal: true }
+]
+
+const usersHeaders = [
+  {
+    align: 'center',
+    field: 'id',
+    name: 'id',
+    sortable: false,
+    style: 'width: 20px'
   },
-  watch: {
-    $route: 'checkRoute',
-    usersPage () {
-      this.refreshUsers()
-    },
-    usersFilter () {
-      this.refreshUsers()
-    }
+  {
+    label: t('common.field.name'),
+    align: 'left',
+    field: 'name',
+    name: 'name',
+    sortable: true
   },
-  mounted () {
-    this.checkRoute()
-    this.fetchGroup()
+  {
+    label: t('admin.users.email'),
+    align: 'left',
+    field: 'email',
+    name: 'email',
+    sortable: false
   },
-  methods: {
-    close () {
-      this.$store.set('admin/overlay', '')
-    },
-    checkRoute () {
-      if (!this.$route.params.section) {
-        this.$router.replace({ params: { section: 'overview' } })
-      } else if (this.$route.params.section === 'users') {
-        this.refreshUsers()
-      }
-    },
-    humanizeDate (val) {
-      if (!val) { return '---' }
-      return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
-    },
-    getRuleModeColor: (mode) => ({
-      DENY: 'text-negative',
-      ALLOW: 'text-positive',
-      FORCEALLOW: 'text-blue'
-    })[mode],
-    getRuleModeClass (mode) {
-      return 'is-' + mode.toLowerCase()
-    },
-    getRuleModeIcon: (mode) => ({
-      DENY: 'las la-ban',
-      ALLOW: 'las la-check',
-      FORCEALLOW: 'las la-check-double'
-    })[mode] || 'las la-frog',
-    getNextRuleMode: (mode) => ({
-      DENY: 'FORCEALLOW',
-      ALLOW: 'DENY',
-      FORCEALLOW: 'ALLOW'
-    })[mode] || 'ALLOW',
-    getRuleModeName (mode) {
-      switch (mode) {
-        case 'ALLOW': return this.$t('admin.groups.ruleAllow')
-        case 'DENY': return this.$t('admin.groups.ruleDeny')
-        case 'FORCEALLOW': return this.$t('admin.groups.ruleForceAllow')
-        default: return '???'
-      }
-    },
-    refresh () {
-      this.fetchGroup()
-    },
-    async fetchGroup () {
-      this.isLoading = true
-      try {
-        const resp = await this.$apollo.query({
-          query: gql`
-            query adminFetchGroup (
-              $id: UUID!
-              ) {
-              groupById(
-                id: $id
-              ) {
-                id
-                name
-                redirectOnLogin
-                redirectOnFirstLogin
-                redirectOnLogout
-                isSystem
-                permissions
-                rules {
-                  id
-                  name
-                  path
-                  roles
-                  match
-                  mode
-                  locales
-                  sites
-                }
-                userCount
-                createdAt
-                updatedAt
-              }
+  {
+    align: 'left',
+    field: 'createdAt',
+    name: 'date',
+    sortable: false
+  },
+  {
+    label: '',
+    align: 'right',
+    field: 'edit',
+    name: 'edit',
+    sortable: false,
+    style: 'width: 250px'
+  }
+]
+
+const permissions = [
+  {
+    permission: 'write:users',
+    hint: 'Can create or authorize new users, but not modify existing ones',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:users',
+    hint: 'Can manage all users (but not users with administrative permissions)',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'write:groups',
+    hint: 'Can manage groups and assign CONTENT permissions / page rules',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:groups',
+    hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
+    warning: true,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:navigation',
+    hint: 'Can manage the site navigation',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:theme',
+    hint: 'Can manage and modify themes',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:api',
+    hint: 'Can generate and revoke API keys',
+    warning: true,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:system',
+    hint: 'Can manage and access everything. Root administrator.',
+    warning: true,
+    restrictedForSystem: true,
+    disabled: true
+
+  }
+]
+
+const rules = [
+  {
+    permission: 'read:pages',
+    title: 'Read Pages',
+    hint: 'Can view and search pages.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'write:pages',
+    title: 'Write Pages',
+    hint: 'Can create and edit pages.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'review:pages',
+    title: 'Review Pages',
+    hint: 'Can review and approve edits submitted by users.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:pages',
+    title: 'Manage Pages',
+    hint: 'Can move existing pages to other locations the user has write access to.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'delete:pages',
+    title: 'Delete Pages',
+    hint: 'Can delete existing pages.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'write:styles',
+    title: 'Use CSS',
+    hint: 'Can insert CSS styles in pages.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'write:scripts',
+    title: 'Use JavaScript',
+    hint: 'Can insert JavaScript in pages.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'read:source',
+    title: 'View Pages Source',
+    hint: 'Can view pages source.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'read:history',
+    title: 'View Page History',
+    hint: 'Can view previous versions of pages.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'read:assets',
+    title: 'View Assets',
+    hint: 'Can view / use assets (such as images and files) in pages.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'write:assets',
+    title: 'Upload Assets',
+    hint: 'Can upload new assets (such as images and files).',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'manage:assets',
+    title: 'Manage Assets',
+    hint: 'Can edit and delete existing assets (such as images and files).',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  },
+  {
+    permission: 'read:comments',
+    title: 'Read Comments',
+    hint: 'Can view page comments.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'write:comments',
+    title: 'Write Comments',
+    hint: 'Can post new comments on pages.',
+    warning: false,
+    restrictedForSystem: false,
+    disabled: false
+  },
+  {
+    permission: 'manage:comments',
+    title: 'Manage Comments',
+    hint: 'Can edit and delete existing page comments.',
+    warning: false,
+    restrictedForSystem: true,
+    disabled: false
+  }
+]
+
+// VALIDATION RULES
+
+const groupNameValidation = [
+  val => /^[^<>"]+$/.test(val) || t('admin.groups.nameInvalidChars')
+]
+
+// COMPUTED
+
+const usersTotalPages = computed(() => {
+  if (state.usersTotal < 1) { return 0 }
+  return Math.ceil(state.usersTotal / state.usersPageSize)
+})
+
+// WATCHERS
+
+watch(() => route.params.section, checkRoute)
+watch([() => state.usersPage, () => state.usersFilter], refreshUsers)
+
+// METHODS
+
+function close () {
+  adminStore.$patch({ overlay: '' })
+}
+
+function checkRoute () {
+  if (!route.params.section) {
+    router.replace({ params: { section: 'overview' } })
+  } else if (route.params.section === 'users') {
+    refreshUsers()
+  }
+}
+
+function humanizeDate (val) {
+  if (!val) { return '---' }
+  return DateTime.fromISO(val).toLocaleString(DateTime.DATETIME_FULL)
+}
+
+function getRuleModeColor (mode) {
+  return ({
+    DENY: 'text-negative',
+    ALLOW: 'text-positive',
+    FORCEALLOW: 'text-blue'
+  })[mode]
+}
+
+function getRuleModeClass (mode) {
+  return 'is-' + mode.toLowerCase()
+}
+
+function getRuleModeIcon (mode) {
+  return ({
+    DENY: 'las la-ban',
+    ALLOW: 'las la-check',
+    FORCEALLOW: 'las la-check-double'
+  })[mode] || 'las la-frog'
+}
+
+function getNextRuleMode (mode) {
+  return ({
+    DENY: 'FORCEALLOW',
+    ALLOW: 'DENY',
+    FORCEALLOW: 'ALLOW'
+  })[mode] || 'ALLOW'
+}
+
+function getRuleModeName (mode) {
+  switch (mode) {
+    case 'ALLOW': return t('admin.groups.ruleAllow')
+    case 'DENY': return t('admin.groups.ruleDeny')
+    case 'FORCEALLOW': return t('admin.groups.ruleForceAllow')
+    default: return '???'
+  }
+}
+
+function refresh () {
+  fetchGroup()
+}
+
+async function fetchGroup () {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query adminFetchGroup (
+          $id: UUID!
+          ) {
+          groupById(
+            id: $id
+          ) {
+            id
+            name
+            redirectOnLogin
+            redirectOnFirstLogin
+            redirectOnLogout
+            isSystem
+            permissions
+            rules {
+              id
+              name
+              path
+              roles
+              match
+              mode
+              locales
+              sites
             }
-          `,
-          variables: {
-            id: this.groupId
-          },
-          fetchPolicy: 'network-only'
-        })
-        if (resp?.data?.groupById) {
-          this.group = cloneDeep(resp.data.groupById)
-          this.usersTotal = this.group.userCount ?? 0
-        } else {
-          throw new Error('An unexpected error occured while fetching group details.')
+            userCount
+            createdAt
+            updatedAt
+          }
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: adminStore.overlayOpts.id
+      },
+      fetchPolicy: 'network-only'
+    })
+    if (resp?.data?.groupById) {
+      state.group = cloneDeep(resp.data.groupById)
+      state.usersTotal = state.group.userCount ?? 0
+    } else {
+      throw new Error('An unexpected error occured while fetching group details.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+
+function newRule () {
+  state.group.rules.push({
+    id: uuid(),
+    name: t('admin.groups.ruleUntitled'),
+    mode: 'ALLOW',
+    match: 'START',
+    roles: [],
+    path: '',
+    locales: [],
+    sites: []
+  })
+}
+
+function deleteRule (id) {
+  state.group.rules = state.group.rules.filter(r => r.id !== id)
+}
+
+function exportRules () {
+  if (state.group.rules.length < 1) {
+    return $q.notify({
+      type: 'negative',
+      message: t('admin.groups.exportRulesNoneError')
+    })
+  }
+  exportFile('rules.json', JSON.stringify(state.group.rules, null, 2), { mimeType: 'application/json;charset=UTF-8' })
+}
+
+async function importRules () {
+  try {
+    const blob = await fileOpen({
+      mimeTypes: ['application/json'],
+      extensions: ['.json'],
+      startIn: 'downloads',
+      excludeAcceptAllOption: true
+    })
+    const rulesRaw = await blob.text()
+    const rules = JSON.parse(rulesRaw)
+    if (!Array.isArray(rules) || rules.length < 1) {
+      throw new Error('Invalid Rules Format')
+    }
+    $q.dialog({
+      title: t('admin.groups.importModeTitle'),
+      message: t('admin.groups.importModeText'),
+      options: {
+        model: 'replace',
+        type: 'radio',
+        items: [
+          { label: t('admin.groups.importModeReplace'), value: 'replace' },
+          { label: t('admin.groups.importModeAdd'), value: 'add' }
+        ]
+      },
+      persistent: true
+    }).onOk(choice => {
+      if (choice === 'replace') {
+        state.group.rules = []
       }
-      this.isLoading = false
-    },
-    newRule () {
-      this.group.rules.push({
-        id: uuid(),
-        name: this.$t('admin.groups.ruleUntitled'),
-        mode: 'ALLOW',
-        match: 'START',
-        roles: [],
-        path: '',
-        locales: [],
-        sites: []
+      state.group.rules = [
+        ...state.group.rules,
+        ...rules.map(r => ({
+          id: uuid(),
+          name: r.name || t('admin.groups.ruleUntitled'),
+          mode: ['ALLOW', 'DENY', 'FORCEALLOW'].includes(r.mode) ? r.mode : 'DENY',
+          match: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) ? r.match : 'START',
+          roles: r.roles || [],
+          path: r.path || '',
+          locales: r.locales.filter(l => some(adminStore.locales, ['code', l])),
+          sites: r.sites.filter(s => some(adminStore.sites, ['id', s]))
+        }))
+      ]
+      $q.notify({
+        type: 'positive',
+        message: t('admin.groups.importSuccess')
       })
-    },
-    deleteRule (id) {
-      this.group.rules = this.group.rules.filter(r => r.id !== id)
-    },
-    exportRules () {
-      if (this.group.rules.length < 1) {
-        return this.$q.notify({
-          type: 'negative',
-          message: this.$t('admin.groups.exportRulesNoneError')
-        })
-      }
-      exportFile('rules.json', JSON.stringify(this.group.rules, null, 2), { mimeType: 'application/json;charset=UTF-8' })
-    },
-    async importRules () {
-      try {
-        const blob = await fileOpen({
-          mimeTypes: ['application/json'],
-          extensions: ['.json'],
-          startIn: 'downloads',
-          excludeAcceptAllOption: true
-        })
-        const rulesRaw = await blob.text()
-        const rules = JSON.parse(rulesRaw)
-        if (!Array.isArray(rules) || rules.length < 1) {
-          throw new Error('Invalid Rules Format')
-        }
-        this.$q.dialog({
-          title: this.$t('admin.groups.importModeTitle'),
-          message: this.$t('admin.groups.importModeText'),
-          options: {
-            model: 'replace',
-            type: 'radio',
-            items: [
-              { label: this.$t('admin.groups.importModeReplace'), value: 'replace' },
-              { label: this.$t('admin.groups.importModeAdd'), value: 'add' }
-            ]
-          },
-          persistent: true
-        }).onOk(choice => {
-          if (choice === 'replace') {
-            this.group.rules = []
-          }
-          this.group.rules = [
-            ...this.group.rules,
-            ...rules.map(r => ({
-              id: uuid(),
-              name: r.name || this.$t('admin.groups.ruleUntitled'),
-              mode: ['ALLOW', 'DENY', 'FORCEALLOW'].includes(r.mode) ? r.mode : 'DENY',
-              match: ['START', 'END', 'REGEX', 'TAG', 'TAGALL', 'EXACT'].includes(r.match) ? r.match : 'START',
-              roles: r.roles || [],
-              path: r.path || '',
-              locales: r.locales.filter(l => some(this.locales, ['code', l])),
-              sites: r.sites.filter(s => some(this.sites, ['id', s]))
-            }))
-          ]
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.groups.importSuccess')
-          })
-        })
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: this.$t('admin.groups.importFailed') + ` [${err.message}]`
-        })
-      }
-    },
-    async refreshUsers () {
-      this.isLoadingUsers = true
-      try {
-        const resp = await this.$apollo.query({
-          query: gql`
-            query adminFetchGroupUsers (
-              $filter: String
-              $page: Int
-              $pageSize: Int
-              $groupId: UUID!
-              ) {
-              groupById (
-                id: $groupId
-              ) {
-                id
-                userCount
-                users (
-                  filter: $filter
-                  page: $page
-                  pageSize: $pageSize
-                ) {
-                  id
-                  name
-                  email
-                  isSystem
-                  isActive
-                  createdAt
-                  lastLoginAt
-                }
-              }
+    })
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: t('admin.groups.importFailed') + ` [${err.message}]`
+    })
+  }
+}
+
+async function refreshUsers () {
+  state.isLoadingUsers = true
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query adminFetchGroupUsers (
+          $filter: String
+          $page: Int
+          $pageSize: Int
+          $groupId: UUID!
+          ) {
+          groupById (
+            id: $groupId
+          ) {
+            id
+            userCount
+            users (
+              filter: $filter
+              page: $page
+              pageSize: $pageSize
+            ) {
+              id
+              name
+              email
+              isSystem
+              isActive
+              createdAt
+              lastLoginAt
             }
-          `,
-          variables: {
-            filter: this.usersFilter,
-            page: this.usersPage,
-            pageSize: this.usersPageSize,
-            groupId: this.groupId
-          },
-          fetchPolicy: 'network-only'
-        })
-        if (resp?.data?.groupById?.users) {
-          this.usersTotal = resp.data.groupById.userCount ?? 0
-          this.users = cloneDeep(resp.data.groupById.users)
-        } else {
-          throw new Error('An unexpected error occured while fetching group users.')
+          }
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
-      }
-      this.isLoadingUsers = false
-    },
-    assignUser () {
-
-    },
-    unassignUser () {
-
+      `,
+      variables: {
+        filter: state.usersFilter,
+        page: state.usersPage,
+        pageSize: state.usersPageSize,
+        groupId: adminStore.overlayOpts.id
+      },
+      fetchPolicy: 'network-only'
+    })
+    if (resp?.data?.groupById?.users) {
+      state.usersTotal = resp.data.groupById.userCount ?? 0
+      state.users = cloneDeep(resp.data.groupById.users)
+    } else {
+      throw new Error('An unexpected error occured while fetching group users.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.isLoadingUsers = false
+}
+
+function assignUser () {
+
+}
+
+function unassignUser () {
+
 }
+
+// MOUNTED
+
+onMounted(() => {
+  checkRoute()
+  fetchGroup()
+})
+
 </script>
 
 <style lang="scss">

+ 13 - 8
ux/src/components/SiteCreateDialog.vue

@@ -12,10 +12,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             outlined
             v-model='state.siteName'
             dense
-            :rules=`[
-              val => val.length > 0 || t('admin.sites.nameMissing'),
-              val => /^[^<>"]+$/.test(val) || t('admin.sites.nameInvalidChars')
-            ]`
+            :rules='siteNameValidation'
             hide-bottom-space
             :label='t(`common.field.name`)'
             :aria-label='t(`common.field.name`)'
@@ -29,10 +26,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             outlined
             v-model='state.siteHostname'
             dense
-            :rules=`[
-              val => val.length > 0 || t('admin.sites.hostnameMissing'),
-              val => /^(\\*)|([a-z0-9\-.:]+)$/.test(val) || t('admin.sites.hostnameInvalidChars')
-            ]`
+            :rules='siteHostnameValidation'
             :hint='t(`admin.sites.hostnameHint`)'
             hide-bottom-space
             :label='t(`admin.sites.hostname`)'
@@ -97,6 +91,17 @@ const state = reactive({
 
 const createSiteForm = ref(null)
 
+// VALIDATION RULES
+
+const siteNameValidation = [
+  val => val.length > 0 || t('admin.sites.nameMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.sites.nameInvalidChars')
+]
+const siteHostnameValidation = [
+  val => val.length > 0 || t('admin.sites.hostnameMissing'),
+  val => /^(\\*)|([a-z0-9\-.:]+)$/.test(val) || t('admin.sites.hostnameInvalidChars')
+]
+
 // METHODS
 
 async function create () {

+ 3 - 3
ux/src/components/SiteDeleteDialog.vue

@@ -81,7 +81,7 @@ async function confirm () {
       mutation: gql`
         mutation deleteSite ($id: UUID!) {
           deleteSite(id: $id) {
-            status {
+            operation {
               succeeded
               message
             }
@@ -92,7 +92,7 @@ async function confirm () {
         id: props.site.id
       }
     })
-    if (resp?.data?.deleteSite?.status?.succeeded) {
+    if (resp?.data?.deleteSite?.operation?.succeeded) {
       $q.notify({
         type: 'positive',
         message: t('admin.sites.deleteSuccess')
@@ -102,7 +102,7 @@ async function confirm () {
       })
       onDialogOK()
     } else {
-      throw new Error(resp?.data?.deleteSite?.status?.message || 'An unexpected error occured.')
+      throw new Error(resp?.data?.deleteSite?.operation?.message || 'An unexpected error occured.')
     }
   } catch (err) {
     $q.notify({

+ 2 - 1
ux/src/i18n/locales/en.json

@@ -1421,5 +1421,6 @@
   "tags.searchWithinResultsPlaceholder": "Search within results...",
   "tags.selectOneMoreTags": "Select one or more tags",
   "tags.selectOneMoreTagsHint": "Select one or more tags on the left.",
-  "admin.general.sitemapHint": "Make a sitemap.xml available to search engines with all pages accessible to guests."
+  "admin.general.sitemapHint": "Make a sitemap.xml available to search engines with all pages accessible to guests.",
+  "admin.groups.usersNone": "This group doesn't have any user yet."
 }

+ 5 - 3
ux/src/layouts/AdminLayout.vue

@@ -177,7 +177,7 @@ q-layout.admin(view='hHh Lpr lff')
     transition-show='jump-up'
     transition-hide='jump-down'
     )
-    component(:is='adminStore.overlay')
+    component(:is='overlays[adminStore.overlay]')
   q-footer.admin-footer
     q-bar.justify-center(dense)
       span(style='font-size: 11px;') Powered by #[a(href='https://js.wiki', target='_blank'): strong Wiki.js], an open source project.
@@ -195,8 +195,10 @@ import { useSiteStore } from '../stores/site'
 // COMPONENTS
 
 import AccountMenu from '../components/AccountMenu.vue'
-const GroupEditOverlay = defineAsyncComponent(() => import('../components/GroupEditOverlay.vue'))
-const UserEditOverlay = defineAsyncComponent(() => import('../components/UserEditOverlay.vue'))
+const overlays = {
+  GroupEditOverlay: defineAsyncComponent(() => import('../components/GroupEditOverlay.vue')),
+  UserEditOverlay: defineAsyncComponent(() => import('../components/UserEditOverlay.vue'))
+}
 
 // STORES
 

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

@@ -175,7 +175,7 @@ watch(() => adminStore.overlay, (newValue, oldValue) => {
   }
 })
 
-watch(() => route, () => {
+watch(() => route.params.id, () => {
   checkOverlay()
 })
 
@@ -213,7 +213,7 @@ async function load () {
 }
 
 function checkOverlay () {
-  if (route.params && route.params.id) {
+  if (route.params?.id) {
     adminStore.$patch({
       overlayOpts: { id: route.params.id },
       overlay: 'GroupEditOverlay'

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

@@ -737,7 +737,7 @@ watch(() => state.targets, (newValue) => {
     handleSetupCallback()
   }
 })
-watch(() => route, (to, from) => {
+watch(() => route.params.id, (to, from) => {
   if (!to.params.id) {
     return
   }