Browse Source

feat(admin): migrate webhooks to vue 3 composable

Nicolas Giard 3 years ago
parent
commit
5a4a9df43a

+ 85 - 0
server/graph/resolvers/hooks.js

@@ -0,0 +1,85 @@
+const graphHelper = require('../../helpers/graph')
+const _ = require('lodash')
+
+/* global WIKI */
+
+module.exports = {
+  Query: {
+    async hooks () {
+      return WIKI.models.hooks.query().orderBy('name')
+    },
+    async hookById (obj, args) {
+      return WIKI.models.hooks.query().findById(args.id)
+    }
+  },
+  Mutation: {
+    /**
+     * CREATE HOOK
+     */
+    async createHook (obj, args) {
+      try {
+        // -> Validate inputs
+        if (!args.name || args.name.length < 1) {
+          throw WIKI.ERROR(new Error('Invalid Hook Name'), 'HookCreateInvalidName')
+        }
+        if (!args.events || args.events.length < 1) {
+          throw WIKI.ERROR(new Error('Invalid Hook Events'), 'HookCreateInvalidEvents')
+        }
+        if (!args.url || args.url.length < 8 || !args.url.startsWith('http')) {
+          throw WIKI.ERROR(new Error('Invalid Hook URL'), 'HookCreateInvalidURL')
+        }
+        // -> Create hook
+        const newHook = await WIKI.models.hooks.createHook(args)
+        return {
+          operation: graphHelper.generateSuccess('Hook created successfully'),
+          hook: newHook
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
+    /**
+     * UPDATE HOOK
+     */
+    async updateHook (obj, args) {
+      try {
+        // -> Load hook
+        const hook = await WIKI.models.hooks.query().findById(args.id)
+        if (!hook) {
+          throw WIKI.ERROR(new Error('Invalid Hook ID'), 'HookInvalidId')
+        }
+        // -> Check for bad input
+        if (_.has(args.patch, 'name') && args.patch.name.length < 1) {
+          throw WIKI.ERROR(new Error('Invalid Hook Name'), 'HookCreateInvalidName')
+        }
+        if (_.has(args.patch, 'events') && args.patch.events.length < 1) {
+          throw WIKI.ERROR(new Error('Invalid Hook Events'), 'HookCreateInvalidEvents')
+        }
+        if (_.has(args.patch, 'url') && (_.trim(args.patch.url).length < 8 || !args.patch.url.startsWith('http'))) {
+          throw WIKI.ERROR(new Error('URL is invalid.'), 'HookInvalidURL')
+        }
+        // -> Update hook
+        await WIKI.models.hooks.query().findById(args.id).patch(args.patch)
+
+        return {
+          operation: graphHelper.generateSuccess('Hook updated successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    },
+    /**
+     * DELETE HOOK
+     */
+    async deleteHook (obj, args) {
+      try {
+        await WIKI.models.hooks.deleteHook(args.id)
+        return {
+          operation: graphHelper.generateSuccess('Hook deleted successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
+    }
+  }
+}

+ 70 - 0
server/graph/schemas/hooks.graphql

@@ -0,0 +1,70 @@
+# ===============================================
+# WEBHOOKS
+# ===============================================
+
+extend type Query {
+  hooks: [Hook]
+
+  hookById(
+    id: UUID!
+  ): Hook
+}
+
+extend type Mutation {
+  createHook(
+    name: String!
+    events: [String]!
+    url: String!
+    includeMetadata: Boolean!
+    includeContent: Boolean!
+    acceptUntrusted: Boolean!
+    authHeader: String
+  ): HookCreateResponse
+
+  updateHook(
+    id: UUID!
+    patch: HookUpdateInput!
+  ): DefaultResponse
+
+  deleteHook (
+    id: UUID!
+  ): DefaultResponse
+}
+
+# -----------------------------------------------
+# TYPES
+# -----------------------------------------------
+
+type Hook {
+  id: UUID
+  name: String
+  events: [String]
+  url: String
+  includeMetadata: Boolean
+  includeContent: Boolean
+  acceptUntrusted: Boolean
+  authHeader: String
+  state: HookState
+  lastErrorMessage: String
+}
+
+input HookUpdateInput {
+  name: String
+  events: [String]
+  url: String
+  includeMetadata: Boolean
+  includeContent: Boolean
+  acceptUntrusted: Boolean
+  authHeader: String
+}
+
+enum HookState {
+  pending
+  error
+  success
+}
+
+type HookCreateResponse {
+  operation: Operation
+  hook: Hook
+}

+ 44 - 0
server/models/hooks.js

@@ -0,0 +1,44 @@
+const Model = require('objection').Model
+
+/* global WIKI */
+
+/**
+ * Hook model
+ */
+module.exports = class Hook extends Model {
+  static get tableName () { return 'hooks' }
+
+  static get jsonAttributes () {
+    return ['events']
+  }
+
+  $beforeUpdate () {
+    this.updatedAt = new Date()
+  }
+
+  static async createHook (data) {
+    return WIKI.models.hooks.query().insertAndFetch({
+      name: data.name,
+      events: data.events,
+      url: data.url,
+      includeMetadata: data.includeMetadata,
+      includeContent: data.includeContent,
+      acceptUntrusted: data.acceptUntrusted,
+      authHeader: data.authHeader,
+      state: 'pending',
+      lastErrorMessage: null
+    })
+  }
+
+  static async updateHook (id, patch) {
+    return WIKI.models.hooks.query().findById(id).patch({
+      ...patch,
+      state: 'pending',
+      lastErrorMessage: null
+    })
+  }
+
+  static async deleteHook (id) {
+    return WIKI.models.hooks.query().deleteById(id)
+  }
+}

+ 71 - 57
ux/src/components/WebhookDeleteDialog.vue

@@ -1,92 +1,106 @@
 <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.webhooks.delete`)}}
+      span {{t(`admin.webhooks.delete`)}}
     q-card-section
       .text-body2
         i18n-t(keypath='admin.webhooks.deleteConfirm')
           template(v-slot:name)
             strong {{hook.name}}
       .text-body2.q-mt-md
-        strong.text-negative {{$t(`admin.webhooks.deleteConfirmWarn`)}}
+        strong.text-negative {{t(`admin.webhooks.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'
+        :loading='state.isLoading'
         )
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
 
-export default {
-  props: {
-    hook: {
-      type: Object
-    }
-  },
-  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 deleteHook ($id: UUID!) {
-              deleteHook(id: $id) {
-                status {
-                  succeeded
-                  message
-                }
-              }
+// PROPS
+
+const props = defineProps({
+  hook: {
+    type: Object,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  isLoading: false
+})
+
+// METHODS
+
+async function confirm () {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation deleteHook ($id: UUID!) {
+          deleteHook(id: $id) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          variables: {
-            id: this.hook.id
           }
-        })
-        if (resp?.data?.deleteHook?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.webhooks.deleteSuccess')
-          })
-          this.$emit('ok')
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.deleteHook?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: props.hook.id
       }
+    })
+    if (resp?.data?.deleteHook?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.webhooks.deleteSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.deleteHook?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.isLoading = false
 }
 </script>

+ 281 - 263
ux/src/components/WebhookEditDialog.vue

@@ -1,35 +1,35 @@
 <template lang="pug">
-q-dialog(ref='dialog', @hide='onDialogHide')
+q-dialog(ref='dialogRef', @hide='onDialogHide')
   q-card(style='min-width: 850px;')
     q-card-section.card-header
-      template(v-if='hookId')
+      template(v-if='props.hookId')
         q-icon(name='img:/_assets/icons/fluent-pencil-drawing.svg', left, size='sm')
-        span {{$t(`admin.webhooks.edit`)}}
+        span {{t(`admin.webhooks.edit`)}}
       template(v-else)
         q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm')
-        span {{$t(`admin.webhooks.new`)}}
+        span {{t(`admin.webhooks.new`)}}
     //- STATE INFO BAR
-    q-card-section.flex.items-center.bg-indigo.text-white(v-if='hookId && hook.state === `pending`')
+    q-card-section.flex.items-center.bg-indigo.text-white(v-if='props.hookId && state.hook.state === `pending`')
       q-spinner-clock.q-mr-sm(
         color='white'
         size='xs'
       )
-      .text-caption {{$t('admin.webhooks.statePendingHint')}}
-    q-card-section.flex.items-center.bg-positive.text-white(v-if='hookId && hook.state === `success`')
+      .text-caption {{t('admin.webhooks.statePendingHint')}}
+    q-card-section.flex.items-center.bg-positive.text-white(v-if='props.hookId && state.hook.state === `success`')
       q-spinner-infinity.q-mr-sm(
         color='white'
         size='xs'
       )
-      .text-caption {{$t('admin.webhooks.stateSuccessHint')}}
-    q-card-section.bg-negative.text-white(v-if='hookId && hook.state === `error`')
+      .text-caption {{t('admin.webhooks.stateSuccessHint')}}
+    q-card-section.bg-negative.text-white(v-if='props.hookId && state.hook.state === `error`')
       .flex.items-center
         q-icon.q-mr-sm(
           color='white'
           size='xs'
           name='las la-exclamation-triangle'
         )
-        .text-caption {{$t('admin.webhooks.stateErrorExplain')}}
-      .text-caption.q-pl-lg.q-ml-xs.text-red-2 {{hook.lastErrorMessage}}
+        .text-caption {{t('admin.webhooks.stateErrorExplain')}}
+      .text-caption.q-pl-lg.q-ml-xs.text-red-2 {{state.hook.lastErrorMessage}}
     //- FORM
     q-form.q-py-sm(ref='editWebhookForm')
       q-item
@@ -37,15 +37,12 @@ q-dialog(ref='dialog', @hide='onDialogHide')
         q-item-section
           q-input(
             outlined
-            v-model='hook.name'
+            v-model='state.hook.name'
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.webhooks.nameMissing'),
-              val => /^[^<>"]+$/.test(val) || $t('admin.webhooks.nameInvalidChars')
-            ]`
+            :rules='hookNameValidation'
             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
             )
@@ -55,7 +52,7 @@ q-dialog(ref='dialog', @hide='onDialogHide')
           q-select(
             outlined
             :options='events'
-            v-model='hook.events'
+            v-model='state.hook.events'
             multiple
             map-options
             emit-value
@@ -63,21 +60,18 @@ q-dialog(ref='dialog', @hide='onDialogHide')
             option-label='name'
             options-dense
             dense
-            :rules=`[
-              val => val.length > 0 || $t('admin.webhooks.eventsMissing')
-            ]`
+            :rules='hookEventsValidation'
             hide-bottom-space
-            :label='$t(`admin.webhooks.events`)'
-            :aria-label='$t(`admin.webhooks.events`)'
+            :label='t(`admin.webhooks.events`)'
+            :aria-label='t(`admin.webhooks.events`)'
             lazy-rules='ondemand'
             )
             template(v-slot:selected)
-              .text-caption(v-if='hook.events.length > 0') {{$tc(`admin.webhooks.eventsSelected`, hook.events.length, { count: hook.events.length })}}
-              span(v-else)
-            template(v-slot:option='{ itemProps, itemEvents, opt, selected, toggleOption }')
+              .text-caption(v-if='state.hook.events.length > 0') {{t(`admin.webhooks.eventsSelected`, state.hook.events.length, { count: state.hook.events.length })}}
+              span(v-else) &nbsp;
+            template(v-slot:option='{ itemProps, opt, selected, toggleOption }')
               q-item(
                 v-bind='itemProps'
-                v-on='itemEvents'
                 )
                 q-item-section(side)
                   q-checkbox(
@@ -97,19 +91,16 @@ q-dialog(ref='dialog', @hide='onDialogHide')
       q-item
         blueprint-icon.self-start(icon='unknown-status')
         q-item-section
-          q-item-label {{$t(`admin.webhooks.url`)}}
-          q-item-label(caption) {{$t(`admin.webhooks.urlHint`)}}
+          q-item-label {{t(`admin.webhooks.url`)}}
+          q-item-label(caption) {{t(`admin.webhooks.urlHint`)}}
           q-input.q-mt-sm(
             outlined
-            v-model='hook.url'
+            v-model='state.hook.url'
             dense
-            :rules=`[
-              val => (val.length > 0 && val.startsWith('http')) || $t('admin.webhooks.urlMissing'),
-              val => /^[^<>"]+$/.test(val) || $t('admin.webhooks.urlInvalidChars')
-            ]`
+            :rules='hookUrlValidation'
             hide-bottom-space
             placeholder='https://'
-            :aria-label='$t(`admin.webhooks.url`)'
+            :aria-label='t(`admin.webhooks.url`)'
             lazy-rules='ondemand'
             )
             template(v-slot:prepend)
@@ -122,301 +113,328 @@ q-dialog(ref='dialog', @hide='onDialogHide')
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='rescan-document')
         q-item-section
-          q-item-label {{$t(`admin.webhooks.includeMetadata`)}}
-          q-item-label(caption) {{$t(`admin.webhooks.includeMetadataHint`)}}
+          q-item-label {{t(`admin.webhooks.includeMetadata`)}}
+          q-item-label(caption) {{t(`admin.webhooks.includeMetadataHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='hook.includeMetadata'
+            v-model='state.hook.includeMetadata'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.webhooks.includeMetadata`)'
+            :aria-label='t(`admin.webhooks.includeMetadata`)'
             )
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='select-all')
         q-item-section
-          q-item-label {{$t(`admin.webhooks.includeContent`)}}
-          q-item-label(caption) {{$t(`admin.webhooks.includeContentHint`)}}
+          q-item-label {{t(`admin.webhooks.includeContent`)}}
+          q-item-label(caption) {{t(`admin.webhooks.includeContentHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='hook.includeContent'
+            v-model='state.hook.includeContent'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.webhooks.includeContent`)'
+            :aria-label='t(`admin.webhooks.includeContent`)'
             )
       q-item(tag='label', v-ripple)
         blueprint-icon(icon='security-ssl')
         q-item-section
-          q-item-label {{$t(`admin.webhooks.acceptUntrusted`)}}
-          q-item-label(caption) {{$t(`admin.webhooks.acceptUntrustedHint`)}}
+          q-item-label {{t(`admin.webhooks.acceptUntrusted`)}}
+          q-item-label(caption) {{t(`admin.webhooks.acceptUntrustedHint`)}}
         q-item-section(avatar)
           q-toggle(
-            v-model='hook.acceptUntrusted'
+            v-model='state.hook.acceptUntrusted'
             color='primary'
             checked-icon='las la-check'
             unchecked-icon='las la-times'
-            :aria-label='$t(`admin.webhooks.acceptUntrusted`)'
+            :aria-label='t(`admin.webhooks.acceptUntrusted`)'
             )
       q-item
         blueprint-icon.self-start(icon='fingerprint-scan')
         q-item-section
-          q-item-label {{$t(`admin.webhooks.authHeader`)}}
-          q-item-label(caption) {{$t(`admin.webhooks.authHeaderHint`)}}
+          q-item-label {{t(`admin.webhooks.authHeader`)}}
+          q-item-label(caption) {{t(`admin.webhooks.authHeaderHint`)}}
           q-input.q-mt-sm(
             outlined
-            v-model='hook.authHeader'
+            v-model='state.hook.authHeader'
             dense
-            :aria-label='$t(`admin.webhooks.authHeader`)'
+            :aria-label='t(`admin.webhooks.authHeader`)'
             )
     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(
-        v-if='hookId'
+        v-if='props.hookId'
         unelevated
-        :label='$t(`common.actions.save`)'
+        :label='t(`common.actions.save`)'
         color='primary'
         padding='xs md'
         @click='save'
-        :loading='loading'
+        :loading='state.isLoading'
         )
       q-btn(
         v-else
         unelevated
-        :label='$t(`common.actions.create`)'
+        :label='t(`common.actions.create`)'
         color='primary'
         padding='xs md'
         @click='create'
-        :loading='loading'
+        :loading='state.isLoading'
         )
 
-    q-inner-loading(:showing='loading')
+    q-inner-loading(:showing='state.isLoading')
       q-spinner(color='accent', size='lg')
 </template>
 
-<script>
+<script setup>
 import gql from 'graphql-tag'
 import cloneDeep from 'lodash/cloneDeep'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, onMounted, reactive, ref } from 'vue'
 
-import { QSpinnerClock, QSpinnerInfinity } from 'quasar'
+// PROPS
 
-export default {
-  components: {
-    QSpinnerClock,
-    QSpinnerInfinity
-  },
-  props: {
-    hookId: {
-      type: String,
-      default: null
-    }
-  },
-  emits: ['ok', 'hide'],
-  data () {
-    return {
-      hook: {
-        name: '',
-        events: [],
-        url: '',
-        acceptUntrusted: false,
-        authHeader: '',
-        includeMetadata: true,
-        includeContent: false,
-        state: 'pending',
-        lastErrorMessage: ''
-      },
-      loading: false
+const props = defineProps({
+  hookId: {
+    type: String,
+    default: null
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  isLoading: false,
+  hook: {
+    name: '',
+    events: [],
+    url: '',
+    acceptUntrusted: false,
+    authHeader: '',
+    includeMetadata: true,
+    includeContent: false,
+    state: 'pending',
+    lastErrorMessage: ''
+  }
+})
+
+// COMPUTED
+
+const events = computed(() => ([
+  { key: 'page:create', name: t('admin.webhooks.eventCreatePage'), type: t('admin.webhooks.typePage') },
+  { key: 'page:edit', name: t('admin.webhooks.eventEditPage'), type: t('admin.webhooks.typePage') },
+  { key: 'page:rename', name: t('admin.webhooks.eventRenamePage'), type: t('admin.webhooks.typePage') },
+  { key: 'page:delete', name: t('admin.webhooks.eventDeletePage'), type: t('admin.webhooks.typePage') },
+  { key: 'asset:upload', name: t('admin.webhooks.eventUploadAsset'), type: t('admin.webhooks.typeAsset') },
+  { key: 'asset:edit', name: t('admin.webhooks.eventEditAsset'), type: t('admin.webhooks.typeAsset') },
+  { key: 'asset:rename', name: t('admin.webhooks.eventRenameAsset'), type: t('admin.webhooks.typeAsset') },
+  { key: 'asset:delete', name: t('admin.webhooks.eventDeleteAsset'), type: t('admin.webhooks.typeAsset') },
+  { key: 'comment:new', name: t('admin.webhooks.eventNewComment'), type: t('admin.webhooks.typeComment') },
+  { key: 'comment:edit', name: t('admin.webhooks.eventEditComment'), type: t('admin.webhooks.typeComment') },
+  { key: 'comment:delete', name: t('admin.webhooks.eventDeleteComment'), type: t('admin.webhooks.typeComment') },
+  { key: 'user:join', name: t('admin.webhooks.eventUserJoin'), type: t('admin.webhooks.typeUser') },
+  { key: 'user:login', name: t('admin.webhooks.eventUserLogin'), type: t('admin.webhooks.typeUser') },
+  { key: 'user:logout', name: t('admin.webhooks.eventUserLogout'), type: t('admin.webhooks.typeUser') }
+]))
+
+// REFS
+
+const editWebhookForm = ref(null)
+
+// VALIDATION RULES
+
+const hookNameValidation = [
+  val => val.length > 0 || t('admin.webhooks.nameMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.webhooks.nameInvalidChars')
+]
+const hookEventsValidation = [
+  val => val.length > 0 || t('admin.webhooks.eventsMissing')
+]
+const hookUrlValidation = [
+  val => (val.length > 0 && val.startsWith('http')) || t('admin.webhooks.urlMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.webhooks.urlInvalidChars')
+]
+
+// METHODS
+
+async function fetchHook (id) {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.query({
+      query: gql`
+        query getHook (
+          $id: UUID!
+          ) {
+          hookById (
+            id: $id
+            ) {
+            name
+            events
+            url
+            includeMetadata
+            includeContent
+            acceptUntrusted
+            authHeader
+            state
+            lastErrorMessage
+          }
+        }
+      `,
+      fetchPolicy: 'no-cache',
+      variables: { id }
+    })
+    if (resp?.data?.hookById) {
+      state.hook = cloneDeep(resp.data.hookById)
+    } else {
+      throw new Error('Failed to fetch webhook configuration.')
     }
-  },
-  computed: {
-    events () {
-      return [
-        { key: 'page:create', name: this.$t('admin.webhooks.eventCreatePage'), type: this.$t('admin.webhooks.typePage') },
-        { key: 'page:edit', name: this.$t('admin.webhooks.eventEditPage'), type: this.$t('admin.webhooks.typePage') },
-        { key: 'page:rename', name: this.$t('admin.webhooks.eventRenamePage'), type: this.$t('admin.webhooks.typePage') },
-        { key: 'page:delete', name: this.$t('admin.webhooks.eventDeletePage'), type: this.$t('admin.webhooks.typePage') },
-        { key: 'asset:upload', name: this.$t('admin.webhooks.eventUploadAsset'), type: this.$t('admin.webhooks.typeAsset') },
-        { key: 'asset:edit', name: this.$t('admin.webhooks.eventEditAsset'), type: this.$t('admin.webhooks.typeAsset') },
-        { key: 'asset:rename', name: this.$t('admin.webhooks.eventRenameAsset'), type: this.$t('admin.webhooks.typeAsset') },
-        { key: 'asset:delete', name: this.$t('admin.webhooks.eventDeleteAsset'), type: this.$t('admin.webhooks.typeAsset') },
-        { key: 'comment:new', name: this.$t('admin.webhooks.eventNewComment'), type: this.$t('admin.webhooks.typeComment') },
-        { key: 'comment:edit', name: this.$t('admin.webhooks.eventEditComment'), type: this.$t('admin.webhooks.typeComment') },
-        { key: 'comment:delete', name: this.$t('admin.webhooks.eventDeleteComment'), type: this.$t('admin.webhooks.typeComment') },
-        { key: 'user:join', name: this.$t('admin.webhooks.eventUserJoin'), type: this.$t('admin.webhooks.typeUser') },
-        { key: 'user:login', name: this.$t('admin.webhooks.eventUserLogin'), type: this.$t('admin.webhooks.typeUser') },
-        { key: 'user:logout', name: this.$t('admin.webhooks.eventUserLogout'), type: this.$t('admin.webhooks.typeUser') }
-      ]
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+    onDialogHide()
+  }
+  state.isLoading = false
+}
+
+async function create () {
+  state.isLoading = true
+  try {
+    const isFormValid = await editWebhookForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.webhooks.createInvalidData'))
     }
-  },
-  methods: {
-    show () {
-      this.$refs.dialog.show()
-      if (this.hookId) {
-        this.fetchHook(this.hookId)
-      }
-    },
-    hide () {
-      this.$refs.dialog.hide()
-    },
-    onDialogHide () {
-      this.$emit('hide')
-    },
-    async fetchHook (id) {
-      this.loading = true
-      try {
-        const resp = await this.$apollo.query({
-          query: gql`
-            query getHook (
-              $id: UUID!
-              ) {
-              hookById (
-                id: $id
-                ) {
-                name
-                events
-                url
-                includeMetadata
-                includeContent
-                acceptUntrusted
-                authHeader
-                state
-                lastErrorMessage
-              }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation createHook (
+          $name: String!
+          $events: [String]!
+          $url: String!
+          $includeMetadata: Boolean!
+          $includeContent: Boolean!
+          $acceptUntrusted: Boolean!
+          $authHeader: String
+          ) {
+          createHook (
+            name: $name
+            events: $events
+            url: $url
+            includeMetadata: $includeMetadata
+            includeContent: $includeContent
+            acceptUntrusted: $acceptUntrusted
+            authHeader: $authHeader
+            ) {
+            operation {
+              succeeded
+              message
             }
-          `,
-          fetchPolicy: 'no-cache',
-          variables: { id }
-        })
-        if (resp?.data?.hookById) {
-          this.hook = cloneDeep(resp.data.hookById)
-        } else {
-          throw new Error('Failed to fetch webhook configuration.')
-        }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
-        this.hide()
-      }
-      this.loading = false
-    },
-    async create () {
-      this.loading = true
-      try {
-        const isFormValid = await this.$refs.editWebhookForm.validate(true)
-        if (!isFormValid) {
-          throw new Error(this.$t('admin.webhooks.createInvalidData'))
-        }
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation createHook (
-              $name: String!
-              $events: [String]!
-              $url: String!
-              $includeMetadata: Boolean!
-              $includeContent: Boolean!
-              $acceptUntrusted: Boolean!
-              $authHeader: String
-              ) {
-              createHook (
-                name: $name
-                events: $events
-                url: $url
-                includeMetadata: $includeMetadata
-                includeContent: $includeContent
-                acceptUntrusted: $acceptUntrusted
-                authHeader: $authHeader
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
-            }
-          `,
-          variables: this.hook
-        })
-        if (resp?.data?.createHook?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.webhooks.createSuccess')
-          })
-          this.$emit('ok')
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.createHook?.status?.message || 'An unexpected error occured.')
-        }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
-      }
-      this.loading = false
-    },
-    async save () {
-      this.loading = true
-      try {
-        const isFormValid = await this.$refs.editWebhookForm.validate(true)
-        if (!isFormValid) {
-          throw new Error(this.$t('admin.webhooks.createInvalidData'))
+          }
         }
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation saveHook (
-              $id: UUID!
-              $patch: HookUpdateInput!
-              ) {
-              updateHook (
-                id: $id
-                patch: $patch
-                ) {
-                status {
-                  succeeded
-                  message
-                }
-              }
-            }
-          `,
-          variables: {
-            id: this.hookId,
-            patch: {
-              name: this.hook.name,
-              events: this.hook.events,
-              url: this.hook.url,
-              acceptUntrusted: this.hook.acceptUntrusted,
-              authHeader: this.hook.authHeader,
-              includeMetadata: this.hook.includeMetadata,
-              includeContent: this.hook.includeContent
+      `,
+      variables: state.hook
+    })
+    if (resp?.data?.createHook?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.webhooks.createSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.createHook?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+
+async function save () {
+  state.isLoading = true
+  try {
+    const isFormValid = await editWebhookForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.webhooks.createInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation saveHook (
+          $id: UUID!
+          $patch: HookUpdateInput!
+          ) {
+          updateHook (
+            id: $id
+            patch: $patch
+            ) {
+            operation {
+              succeeded
+              message
             }
           }
-        })
-        if (resp?.data?.updateHook?.status?.succeeded) {
-          this.$q.notify({
-            type: 'positive',
-            message: this.$t('admin.webhooks.updateSuccess')
-          })
-          this.$emit('ok')
-          this.hide()
-        } else {
-          throw new Error(resp?.data?.updateHook?.status?.message || 'An unexpected error occured.')
         }
-      } catch (err) {
-        this.$q.notify({
-          type: 'negative',
-          message: err.message
-        })
+      `,
+      variables: {
+        id: props.hookId,
+        patch: {
+          name: state.hook.name,
+          events: state.hook.events,
+          url: state.hook.url,
+          acceptUntrusted: state.hook.acceptUntrusted,
+          authHeader: state.hook.authHeader,
+          includeMetadata: state.hook.includeMetadata,
+          includeContent: state.hook.includeContent
+        }
       }
-      this.loading = false
+    })
+    if (resp?.data?.updateHook?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.webhooks.updateSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.updateHook?.operation?.message || 'An unexpected error occured.')
     }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
   }
+  state.isLoading = false
 }
+
+// MOUNTED
+
+onMounted(() => {
+  if (props.hookId) {
+    fetchHook(props.hookId)
+  }
+})
+
 </script>

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

@@ -41,7 +41,7 @@ q-page.admin-locale
       )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
-    .col-7
+    .col-12.col-lg-7
       //- -----------------------
       //- Locale Options
       //- -----------------------
@@ -89,7 +89,7 @@ q-page.admin-locale
                   span {{ t('admin.locale.namespacingPrefixWarning.title', { langCode: state.selectedLocale }) }}
                   .text-caption.text-yellow-1 {{ t('admin.locale.namespacingPrefixWarning.subtitle') }}
 
-    .col-5
+    .col-12.col-lg-5
       //- -----------------------
       //- Namespacing
       //- -----------------------

+ 100 - 93
ux/src/pages/AdminWebhooks.vue

@@ -4,14 +4,9 @@ q-page.admin-webhooks
     .col-auto
       img.admin-icon.animated.fadeInLeft(src='/_assets/icons/fluent-lightning-bolt.svg')
     .col.q-pl-md
-      .text-h5.text-primary.animated.fadeInLeft {{ $t('admin.webhooks.title') }}
-      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ $t('admin.webhooks.subtitle') }}
+      .text-h5.text-primary.animated.fadeInLeft {{ t('admin.webhooks.title') }}
+      .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.webhooks.subtitle') }}
     .col-auto
-      q-spinner-tail.q-mr-md(
-        v-show='loading'
-        color='accent'
-        size='sm'
-      )
       q-btn.q-mr-sm.acrylic-btn(
         icon='las la-question-circle'
         flat
@@ -24,19 +19,19 @@ q-page.admin-webhooks
         icon='las la-redo-alt'
         flat
         color='secondary'
-        :loading='loading > 0'
+        :loading='state.loading > 0'
         @click='load'
         )
       q-btn(
         unelevated
         icon='las la-plus'
-        :label='$t(`admin.webhooks.new`)'
+        :label='t(`admin.webhooks.new`)'
         color='primary'
         @click='createHook'
         )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
-    .col-12(v-if='hooks.length < 1')
+    .col-12(v-if='state.hooks.length < 1')
       q-card.rounded-borders(
         flat
         :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
@@ -44,11 +39,11 @@ q-page.admin-webhooks
         q-card-section.items-center(horizontal)
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-info-circle', size='sm')
-          q-card-section.text-caption {{ $t('admin.webhooks.none') }}
+          q-card-section.text-caption {{ t('admin.webhooks.none') }}
     .col-12(v-else)
       q-card
         q-list(separator)
-          q-item(v-for='hook of hooks', :key='hook.id')
+          q-item(v-for='hook of state.hooks', :key='hook.id')
             q-item-section(side)
               q-icon(name='las la-bolt', color='primary')
             q-item-section
@@ -60,23 +55,23 @@ q-page.admin-webhooks
                   color='indigo'
                   size='xs'
                 )
-                .text-caption.text-indigo {{$t('admin.webhooks.statePending')}}
-                q-tooltip(anchor='center left', self='center right') {{$t('admin.webhooks.statePendingHint')}}
+                .text-caption.text-indigo {{t('admin.webhooks.statePending')}}
+                q-tooltip(anchor='center left', self='center right') {{t('admin.webhooks.statePendingHint')}}
               template(v-else-if='hook.state === `success`')
                 q-spinner-infinity.q-mr-sm(
                   color='positive'
                   size='xs'
                 )
-                .text-caption.text-positive {{$t('admin.webhooks.stateSuccess')}}
-                q-tooltip(anchor='center left', self='center right') {{$t('admin.webhooks.stateSuccessHint')}}
+                .text-caption.text-positive {{t('admin.webhooks.stateSuccess')}}
+                q-tooltip(anchor='center left', self='center right') {{t('admin.webhooks.stateSuccessHint')}}
               template(v-else-if='hook.state === `error`')
                 q-icon.q-mr-sm(
                   color='negative'
                   size='xs'
                   name='las la-exclamation-triangle'
                 )
-                .text-caption.text-negative {{$t('admin.webhooks.stateError')}}
-                q-tooltip(anchor='center left', self='center right') {{$t('admin.webhooks.stateErrorHint')}}
+                .text-caption.text-negative {{t('admin.webhooks.stateError')}}
+                q-tooltip(anchor='center left', self='center right') {{t('admin.webhooks.stateErrorHint')}}
             q-separator.q-ml-md(vertical)
             q-item-section(side, style='flex-direction: row; align-items: center;')
               q-btn.acrylic-btn.q-mr-sm(
@@ -96,88 +91,100 @@ q-page.admin-webhooks
 
 </template>
 
-<script>
+<script setup>
 import cloneDeep from 'lodash/cloneDeep'
 import gql from 'graphql-tag'
 
-import { createMetaMixin, QSpinnerClock, QSpinnerInfinity } from 'quasar'
-import WebhookDeleteDialog from '../components/WebhookDeleteDialog.vue'
-import WebhookEditDialog from '../components/WebhookEditDialog.vue'
-
-export default {
-  components: {
-    QSpinnerClock,
-    QSpinnerInfinity
-  },
-  mixins: [
-    createMetaMixin(function () {
-      return {
-        title: this.$t('admin.webhooks.title')
+import { useI18n } from 'vue-i18n'
+import { useMeta, useQuasar } from 'quasar'
+import { onMounted, reactive } from 'vue'
+
+import WebhookEditDialog from 'src/components/WebhookEditDialog.vue'
+import WebhookDeleteDialog from 'src/components/WebhookDeleteDialog.vue'
+
+// QUASAR
+
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('admin.webhooks.title')
+})
+
+// DATA
+
+const state = reactive({
+  hooks: [],
+  loading: 0
+})
+
+// METHODS
+
+async function load () {
+  state.loading++
+  $q.loading.show()
+  const resp = await APOLLO_CLIENT.query({
+    query: gql`
+      query getHooks {
+        hooks {
+          id
+          name
+          url
+          state
+        }
       }
-    })
-  ],
-  data () {
-    return {
-      hooks: [],
-      loading: 0
+    `,
+    fetchPolicy: 'network-only'
+  })
+  state.hooks = cloneDeep(resp?.data?.hooks) ?? []
+  $q.loading.hide()
+  state.loading--
+}
+
+function createHook () {
+  $q.dialog({
+    component: WebhookEditDialog,
+    componentProps: {
+      hookId: null
     }
-  },
-  mounted () {
-    this.load()
-  },
-  methods: {
-    async load () {
-      this.loading++
-      this.$q.loading.show()
-      const resp = await this.$apollo.query({
-        query: gql`
-          query getHooks {
-            hooks {
-              id
-              name
-              url
-              state
-            }
-          }
-        `,
-        fetchPolicy: 'network-only'
-      })
-      this.config = cloneDeep(resp?.data?.hooks) ?? []
-      this.$q.loading.hide()
-      this.loading--
-    },
-    createHook () {
-      this.$q.dialog({
-        component: WebhookEditDialog,
-        componentProps: {
-          hookId: null
-        }
-      }).onOk(() => {
-        this.load()
-      })
-    },
-    editHook (id) {
-      this.$q.dialog({
-        component: WebhookEditDialog,
-        componentProps: {
-          hookId: id
-        }
-      }).onOk(() => {
-        this.load()
-      })
-    },
-    deleteHook (hook) {
-      this.$q.dialog({
-        component: WebhookDeleteDialog,
-        componentProps: {
-          hook
-        }
-      }).onOk(() => {
-        this.load()
-      })
+  }).onOk(() => {
+    load()
+  })
+}
+
+function editHook (id) {
+  $q.dialog({
+    component: WebhookEditDialog,
+    componentProps: {
+      hookId: id
     }
-  }
+  }).onOk(() => {
+    load()
+  })
 }
+
+function deleteHook (hook) {
+  $q.dialog({
+    component: WebhookDeleteDialog,
+    componentProps: {
+      hook
+    }
+  }).onOk(() => {
+    load()
+  })
+}
+
+// MOUNTED
+
+onMounted(() => {
+  load()
+})
+
 </script>
 
 <style lang='scss'>

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

@@ -50,7 +50,7 @@ const routes = [
       { path: 'security', component: () => import('../pages/AdminSecurity.vue') },
       { path: 'system', component: () => import('../pages/AdminSystem.vue') },
       // { path: 'utilities', component: () => import('../pages/AdminUtilities.vue') },
-      // { path: 'webhooks', component: () => import('../pages/AdminWebhooks.vue') },
+      { path: 'webhooks', component: () => import('../pages/AdminWebhooks.vue') },
       { path: 'flags', component: () => import('../pages/AdminFlags.vue') }
     ]
   },