Browse Source

feat: admin api keys

Nicolas Giard 2 years ago
parent
commit
7fe7fbb371

+ 1 - 1
server/db/migrations/3.0.0.js

@@ -29,7 +29,7 @@ exports.up = async knex => {
       table.uuid('id').notNullable().primary().defaultTo(knex.raw('gen_random_uuid()'))
       table.string('name').notNullable()
       table.text('key').notNullable()
-      table.string('expiration').notNullable()
+      table.timestamp('expiration').notNullable().defaultTo(knex.fn.now())
       table.boolean('isRevoked').notNullable().defaultTo(false)
       table.timestamp('createdAt').notNullable().defaultTo(knex.fn.now())
       table.timestamp('updatedAt').notNullable().defaultTo(knex.fn.now())

+ 5 - 3
server/graph/resolvers/authentication.js

@@ -12,6 +12,7 @@ module.exports = {
      */
     async apiKeys (obj, args, context) {
       const keys = await WIKI.models.apiKeys.query().orderBy(['isRevoked', 'name'])
+      console.info(keys)
       return keys.map(k => ({
         id: k.id,
         name: k.name,
@@ -78,9 +79,10 @@ module.exports = {
         WIKI.events.outbound.emit('reloadApiKeys')
         return {
           key,
-          responseResult: graphHelper.generateSuccess('API Key created successfully')
+          operation: graphHelper.generateSuccess('API Key created successfully')
         }
       } catch (err) {
+        WIKI.logger.warn(err)
         return graphHelper.generateError(err)
       }
     },
@@ -165,7 +167,7 @@ module.exports = {
         WIKI.config.api.isEnabled = args.enabled
         await WIKI.configSvc.saveToDb(['api'])
         return {
-          responseResult: graphHelper.generateSuccess('API State changed successfully')
+          operation: graphHelper.generateSuccess('API State changed successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)
@@ -182,7 +184,7 @@ module.exports = {
         await WIKI.auth.reloadApiKeys()
         WIKI.events.outbound.emit('reloadApiKeys')
         return {
-          responseResult: graphHelper.generateSuccess('API Key revoked successfully')
+          operation: graphHelper.generateSuccess('API Key revoked successfully')
         }
       } catch (err) {
         return graphHelper.generateError(err)

+ 9 - 10
server/graph/schemas/authentication.graphql

@@ -21,8 +21,7 @@ extend type Mutation {
   createApiKey(
     name: String!
     expiration: String!
-    fullAccess: Boolean!
-    group: Int
+    groups: [UUID]!
   ): AuthenticationCreateApiKeyResponse
 
   login(
@@ -53,7 +52,7 @@ extend type Mutation {
   ): AuthenticationRegisterResponse
 
   revokeApiKey(
-    id: Int!
+    id: UUID!
   ): DefaultResponse
 
   setApiState(
@@ -135,13 +134,13 @@ input AuthenticationStrategyInput {
 }
 
 type AuthenticationApiKey {
-  id: Int!
-  name: String!
-  keyShort: String!
-  expiration: Date!
-  createdAt: Date!
-  updatedAt: Date!
-  isRevoked: Boolean!
+  id: UUID
+  name: String
+  keyShort: String
+  expiration: Date
+  createdAt: Date
+  updatedAt: Date
+  isRevoked: Boolean
 }
 
 type AuthenticationCreateApiKeyResponse {

+ 14 - 10
server/models/apiKeys.js

@@ -1,7 +1,7 @@
 /* global WIKI */
 
 const Model = require('objection').Model
-const moment = require('moment')
+const { DateTime } = require('luxon')
 const ms = require('ms')
 const jwt = require('jsonwebtoken')
 
@@ -17,7 +17,7 @@ module.exports = class ApiKey extends Model {
       required: ['name', 'key'],
 
       properties: {
-        id: {type: 'integer'},
+        id: {type: 'string'},
         name: {type: 'string'},
         key: {type: 'string'},
         expiration: {type: 'string'},
@@ -31,29 +31,33 @@ module.exports = class ApiKey extends Model {
   async $beforeUpdate(opt, context) {
     await super.$beforeUpdate(opt, context)
 
-    this.updatedAt = moment.utc().toISOString()
+    this.updatedAt = new Date().toISOString()
   }
   async $beforeInsert(context) {
     await super.$beforeInsert(context)
 
-    this.createdAt = moment.utc().toISOString()
-    this.updatedAt = moment.utc().toISOString()
+    this.createdAt = new Date().toISOString()
+    this.updatedAt = new Date().toISOString()
   }
 
-  static async createNewKey ({ name, expiration, fullAccess, group }) {
+  static async createNewKey ({ name, expiration, groups }) {
+    console.info(DateTime.utc().plus(ms(expiration)).toISO())
+
     const entry = await WIKI.models.apiKeys.query().insert({
       name,
       key: 'pending',
-      expiration: moment.utc().add(ms(expiration), 'ms').toISOString(),
+      expiration: DateTime.utc().plus(ms(expiration)).toISO(),
       isRevoked: true
     })
 
+    console.info(entry)
+
     const key = jwt.sign({
       api: entry.id,
-      grp: fullAccess ? 1 : group
+      grp: groups
     }, {
-      key: WIKI.config.certs.private,
-      passphrase: WIKI.config.sessionSecret
+      key: WIKI.config.auth.certs.private,
+      passphrase: WIKI.config.auth.secret
     }, {
       algorithm: 'RS256',
       expiresIn: expiration,

+ 1 - 1
server/models/authentication.js

@@ -35,7 +35,7 @@ module.exports = class Authentication extends Model {
   }
 
   static async getStrategies() {
-    const strategies = await WIKI.models.authentication.query().orderBy('order')
+    const strategies = await WIKI.models.authentication.query()
     return strategies.map(str => ({
       ...str,
       domainWhitelist: _.get(str.domainWhitelist, 'v', []),

+ 10 - 11
ux/package.json

@@ -32,7 +32,7 @@
     "@codemirror/tooltip": "0.19.16",
     "@codemirror/view": "6.0.2",
     "@lezer/common": "1.0.0",
-    "@quasar/extras": "1.14.2",
+    "@quasar/extras": "1.15.0",
     "@tiptap/core": "2.0.0-beta.176",
     "@tiptap/extension-code-block": "2.0.0-beta.37",
     "@tiptap/extension-code-block-lowlight": "2.0.0-beta.68",
@@ -57,9 +57,8 @@
     "@tiptap/extension-typography": "2.0.0-beta.20",
     "@tiptap/starter-kit": "2.0.0-beta.185",
     "@tiptap/vue-3": "2.0.0-beta.91",
-    "@vue/apollo-option": "4.0.0-alpha.17",
     "apollo-upload-client": "17.0.0",
-    "browser-fs-access": "0.30.2",
+    "browser-fs-access": "0.31.0",
     "clipboard": "2.0.11",
     "codemirror": "6.0.1",
     "filesize": "9.0.11",
@@ -69,31 +68,31 @@
     "js-cookie": "3.0.1",
     "jwt-decode": "3.1.2",
     "lodash-es": "4.17.21",
-    "luxon": "2.5.0",
-    "pinia": "2.0.14",
+    "luxon": "3.0.1",
+    "pinia": "2.0.17",
     "pug": "3.0.2",
     "quasar": "2.7.5",
     "tippy.js": "6.3.7",
     "uuid": "8.3.2",
-    "v-network-graph": "0.6.3",
+    "v-network-graph": "0.6.5",
     "vue": "3.2.37",
-    "vue-codemirror": "6.0.0",
+    "vue-codemirror": "6.0.2",
     "vue-i18n": "9.1.10",
-    "vue-router": "4.1.1",
+    "vue-router": "4.1.3",
     "vuedraggable": "4.1.0",
     "zxcvbn": "4.4.2"
   },
   "devDependencies": {
-    "@intlify/vite-plugin-vue-i18n": "3.4.0",
+    "@intlify/vite-plugin-vue-i18n": "5.0.1",
     "@quasar/app-vite": "1.0.5",
     "@types/lodash": "4.14.182",
     "browserlist": "latest",
-    "eslint": "8.19.0",
+    "eslint": "8.20.0",
     "eslint-config-standard": "17.0.0",
     "eslint-plugin-import": "2.26.0",
     "eslint-plugin-n": "15.2.4",
     "eslint-plugin-promise": "6.0.0",
-    "eslint-plugin-vue": "9.2.0"
+    "eslint-plugin-vue": "9.3.0"
   },
   "engines": {
     "node": "^18 || ^16",

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/icons/fluent-downloading-updates.svg


+ 1 - 0
ux/public/_assets/icons/fluent-key-2.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="XED_QU6xiox5XDw0HU_eba" x1="20.958" x2="5.741" y1="26.758" y2="42.622" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#e5a505"/><stop offset=".01" stop-color="#e9a804"/><stop offset=".06" stop-color="#f4b102"/><stop offset=".129" stop-color="#fbb600"/><stop offset=".323" stop-color="#fdb700"/></linearGradient><path fill="url(#XED_QU6xiox5XDw0HU_eba)" d="M12,41.5c0-1.381,1.119-2.5,2.5-2.5c0.156,0,0.307,0.019,0.454,0.046l1.186-1.186	C16.058,37.586,16,37.301,16,37c0-1.657,1.343-3,3-3c0.301,0,0.586,0.058,0.86,0.14L24,30l-6-6L4.586,37.414	C4.211,37.789,4,38.298,4,38.828v1.343c0,0.53,0.211,1.039,0.586,1.414l1.828,1.828C6.789,43.789,7.298,44,7.828,44h1.343	c0.53,0,1.039-0.211,1.414-0.586l1.46-1.46C12.019,41.807,12,41.656,12,41.5z"/><linearGradient id="XED_QU6xiox5XDw0HU_ebb" x1="21.64" x2="36.971" y1="7.073" y2="29.362" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#fede00"/><stop offset="1" stop-color="#ffd000"/></linearGradient><path fill="url(#XED_QU6xiox5XDw0HU_ebb)" d="M29.5,5C22.044,5,16,11.044,16,18.5S22.044,32,29.5,32S43,25.956,43,18.5S36.956,5,29.5,5z M33,19c-2.209,0-4-1.791-4-4s1.791-4,4-4s4,1.791,4,4S35.209,19,33,19z"/></svg>

+ 1 - 0
ux/public/_assets/icons/fluent-unavailable.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><linearGradient id="GCWVriy4rQhfclYQVzRmda" x1="9.812" x2="38.361" y1="9.812" y2="38.361" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient><path fill="url(#GCWVriy4rQhfclYQVzRmda)" d="M24,4C12.955,4,4,12.955,4,24s8.955,20,20,20s20-8.955,20-20C44,12.955,35.045,4,24,4z M24,38	c-7.732,0-14-6.268-14-14s6.268-14,14-14s14,6.268,14,14S31.732,38,24,38z"/><linearGradient id="GCWVriy4rQhfclYQVzRmdb" x1="6.821" x2="41.08" y1="6.321" y2="40.58" gradientTransform="translate(-.146 .354)" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#f44f5a"/><stop offset=".443" stop-color="#ee3d4a"/><stop offset="1" stop-color="#e52030"/></linearGradient><polygon fill="url(#GCWVriy4rQhfclYQVzRmdb)" points="13.371,38.871 9.129,34.629 34.629,9.129 38.871,13.371"/></svg>

+ 1 - 0
ux/public/_assets/icons/ultraviolet-binary-file.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M6.5 37.5L6.5 2.5 24.793 2.5 33.5 11.207 33.5 37.5z"/><path fill="#4788c7" d="M24.586,3L33,11.414V37H7V3H24.586 M25,2H6v36h28V11L25,2L25,2z"/><path fill="#dff0fe" d="M24.5 11.5L24.5 2.5 24.793 2.5 33.5 11.207 33.5 11.5z"/><path fill="#4788c7" d="M25,3.414L32.586,11H25V3.414 M25,2h-1v10h10v-1L25,2L25,2z"/><path fill="none" stroke="#4788c7" stroke-miterlimit="10" d="M13.5 18.5L14.5 18.5 14.5 23M19.5 22.5L19.5 22.5c-.552 0-1-.448-1-1v-2c0-.552.448-1 1-1h0c.552 0 1 .448 1 1v2C20.5 22.052 20.052 22.5 19.5 22.5zM25.5 22.5L25.5 22.5c-.552 0-1-.448-1-1v-2c0-.552.448-1 1-1h0c.552 0 1 .448 1 1v2C26.5 22.052 26.052 22.5 25.5 22.5zM25.5 25.5L26.5 25.5 26.5 30M20.5 29.5L20.5 29.5c-.552 0-1-.448-1-1v-2c0-.552.448-1 1-1h0c.552 0 1 .448 1 1v2C21.5 29.052 21.052 29.5 20.5 29.5zM14.5 29.5L14.5 29.5c-.552 0-1-.448-1-1v-2c0-.552.448-1 1-1h0c.552 0 1 .448 1 1v2C15.5 29.052 15.052 29.5 14.5 29.5z"/></svg>

+ 1 - 0
ux/public/_assets/icons/ultraviolet-schedule.svg

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 40 40" width="80px" height="80px"><path fill="#fff" d="M2.5,37.5V8.985C2.5,5.409,5.409,2.5,8.985,2.5H37.5v35H2.5z"/><path fill="#4788c7" d="M37,3v34H3V8.985C3,5.685,5.685,3,8.985,3H37 M38,2H8.985C5.127,2,2,5.127,2,8.985V38h36V2L38,2z"/><path fill="#b6dcfe" d="M2.522,7.5c0.253-2.8,2.613-5,5.478-5h29.5v5H2.522z"/><path fill="#4788c7" d="M37,3v4H3.1C3.565,4.721,5.585,3,8,3H37 M38,2H8C4.686,2,2,4.686,2,8h36V2L38,2z"/><path fill="#b6dcfe" d="M13 14H15V16H13zM21 14H23V16H21zM25 14H27V16H25zM29 14H31V16H29zM9 18H11V20H9zM13 18H15V20H13zM21 18H23V20H21zM25 18H27V20H25zM29 18H31V20H29zM9 26H11V28H9zM13 26H15V28H13zM21 26H23V28H21zM17 14H19V16H17zM17 18H19V20H17zM9 22H11V24H9zM13 22H15V24H13zM21 22H23V24H21zM25 22H27V24H25zM29 22H31V24H29zM17 22H19V24H17zM17 26H19V28H17zM25 26H27V28H25zM3 34H37V37H3z"/><path fill="#98ccfd" d="M12,23.5C5.659,23.5,0.5,18.341,0.5,12S5.659,0.5,12,0.5S23.5,5.659,23.5,12S18.341,23.5,12,23.5z"/><path fill="#4788c7" d="M12,1c6.065,0,11,4.935,11,11s-4.935,11-11,11S1,18.065,1,12S5.935,1,12,1 M12,0 C5.373,0,0,5.373,0,12s5.373,12,12,12s12-5.373,12-12S18.627,0,12,0L12,0z"/><g><path fill="#fff" d="M12 3A9 9 0 1 0 12 21A9 9 0 1 0 12 3Z"/></g><path fill="none" stroke="#4788c7" stroke-linecap="round" stroke-miterlimit="10" d="M14.5 5.5L12 12 15.5 15.5"/><g><path fill="#4788c7" d="M12 10.667A1.333 1.333 0 1 0 12 13.333A1.333 1.333 0 1 0 12 10.667Z"/></g></svg>

File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/illustrations/undraw_going_up.svg


File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/illustrations/undraw_icon_design.svg


File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/illustrations/undraw_settings.svg


File diff suppressed because it is too large
+ 0 - 0
ux/public/_assets/illustrations/undraw_world.svg


+ 0 - 8
ux/src/boot/apollo.js

@@ -1,5 +1,4 @@
 import { boot } from 'quasar/wrappers'
-import { createApolloProvider } from '@vue/apollo-option'
 import { ApolloClient, InMemoryCache } from '@apollo/client/core'
 import { setContext } from '@apollo/client/link/context'
 import { createUploadLink } from 'apollo-upload-client'
@@ -41,16 +40,9 @@ export default boot(({ app }) => {
     ssrForceFetchDelay: 100
   })
 
-  // Init Vue Apollo
-  const apolloProvider = createApolloProvider({
-    defaultClient: client
-  })
-
   if (import.meta.env.SSR) {
     global.APOLLO_CLIENT = client
   } else {
     window.APOLLO_CLIENT = client
   }
-
-  app.use(apolloProvider)
 })

+ 63 - 0
ux/src/components/ApiKeyCopyDialog.vue

@@ -0,0 +1,63 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide', persistent)
+  q-card(style='min-width: 600px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-key-2.svg', left, size='sm')
+      span {{t(`admin.api.copyKeyTitle`)}}
+    q-card-section.card-negative
+      i18n-t(tag='span', keypath='admin.api.newKeyCopyWarn')
+        template(#bold)
+          strong {{t('admin.api.newKeyCopyWarnBold')}}
+    q-form.q-py-sm
+      q-item
+        blueprint-icon.self-start(icon='binary-file')
+        q-item-section
+          q-input(
+            type='textarea'
+            outlined
+            v-model='props.keyValue'
+            dense
+            hide-bottom-space
+            :label='t(`admin.api.key`)'
+            :aria-label='t(`admin.api.key`)'
+            autofocus
+            )
+    q-card-actions.card-actions
+      q-space
+      q-btn(
+        unelevated
+        :label='t(`common.actions.close`)'
+        color='primary'
+        padding='xs md'
+        @click='onDialogOK'
+        )
+</template>
+
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+
+// PROPS
+
+const props = defineProps({
+  keyValue: {
+    type: String,
+    required: true
+  }
+})
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+</script>

+ 253 - 0
ux/src/components/ApiKeyCreateDialog.vue

@@ -0,0 +1,253 @@
+<template lang="pug">
+q-dialog(ref='dialogRef', @hide='onDialogHide')
+  q-card(style='min-width: 650px;')
+    q-card-section.card-header
+      q-icon(name='img:/_assets/icons/fluent-plus-plus.svg', left, size='sm')
+      span {{t(`admin.api.newKeyTitle`)}}
+    q-form.q-py-sm(ref='createKeyForm', @submit='create')
+      q-item
+        blueprint-icon.self-start(icon='grand-master-key')
+        q-item-section
+          q-input(
+            outlined
+            v-model='state.keyName'
+            dense
+            :rules='keyNameValidation'
+            hide-bottom-space
+            :label='t(`admin.api.newKeyName`)'
+            :aria-label='t(`admin.api.newKeyName`)'
+            :hint='t(`admin.api.newKeyNameHint`)'
+            lazy-rules='ondemand'
+            autofocus
+            ref='iptName'
+            )
+      q-item
+        blueprint-icon.self-start(icon='schedule')
+        q-item-section
+          q-select(
+            outlined
+            :options='expirations'
+            v-model='state.keyExpiration'
+            multiple
+            map-options
+            option-value='value'
+            option-label='text'
+            emit-value
+            options-dense
+            dense
+            hide-bottom-space
+            :label='t(`admin.api.newKeyExpiration`)'
+            :aria-label='t(`admin.api.newKeyExpiration`)'
+            :hint='t(`admin.api.newKeyExpirationHint`)'
+            )
+      q-item
+        blueprint-icon.self-start(icon='access')
+        q-item-section
+          q-select(
+            outlined
+            :options='state.groups'
+            v-model='state.keyGroups'
+            multiple
+            map-options
+            emit-value
+            option-value='id'
+            option-label='name'
+            options-dense
+            dense
+            :rules='keyGroupsValidation'
+            hide-bottom-space
+            :label='t(`admin.api.permissionGroups`)'
+            :aria-label='t(`admin.api.permissionGroups`)'
+            :hint='t(`admin.api.newKeyGroupHint`)'
+            lazy-rules='ondemand'
+            :loading='state.loadingGroups'
+            )
+            template(v-slot:selected)
+              .text-caption(v-if='state.keyGroups.length > 1')
+                i18n-t(keypath='admin.api.groupsSelected')
+                  template(#count)
+                    strong {{ state.keyGroups.length }}
+              .text-caption(v-else-if='state.keyGroups.length === 1')
+                i18n-t(keypath='admin.api.groupSelected')
+                  template(#group)
+                    strong {{ selectedGroupName }}
+              span(v-else)
+            template(v-slot:option='{ itemProps, opt, selected, toggleOption }')
+              q-item(
+                v-bind='itemProps'
+                )
+                q-item-section(side)
+                  q-checkbox(
+                    size='sm'
+                    :model-value='selected'
+                    @update:model-value='toggleOption(opt)'
+                    )
+                q-item-section
+                  q-item-label {{opt.name}}
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`common.actions.create`)'
+        color='primary'
+        padding='xs md'
+        @click='create'
+        :loading='state.loading > 0'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { cloneDeep, sampleSize } from 'lodash-es'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { computed, onMounted, reactive, ref } from 'vue'
+
+import ApiKeyCopyDialog from './ApiKeyCopyDialog.vue'
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  keyName: '',
+  keyExpiration: '90d',
+  keyGroups: [],
+  groups: [],
+  loadingGroups: false,
+  loading: false
+})
+
+const expirations = [
+  { value: '30d', text: t('admin.api.expiration30d') },
+  { value: '90d', text: t('admin.api.expiration90d') },
+  { value: '180d', text: t('admin.api.expiration180d') },
+  { value: '1y', text: t('admin.api.expiration1y') },
+  { value: '3y', text: t('admin.api.expiration3y') }
+]
+
+// REFS
+
+const createKeyForm = ref(null)
+const iptName = ref(null)
+
+// COMPUTED
+
+const selectedGroupName = computed(() => {
+  return state.groups.filter(g => g.id === state.keyGroups[0])[0]?.name
+})
+
+// VALIDATION RULES
+
+const keyNameValidation = [
+  val => val.length > 0 || t('admin.api.nameMissing'),
+  val => /^[^<>"]+$/.test(val) || t('admin.api.nameInvalidChars')
+]
+
+const keyGroupsValidation = [
+  val => val.length > 0 || t('admin.api.groupsMissing')
+]
+
+// METHODS
+
+async function loadGroups () {
+  state.loading++
+  state.loadingGroups = true
+  const resp = await APOLLO_CLIENT.query({
+    query: gql`
+      query getGroupsForCreateApiKey {
+        groups {
+          id
+          name
+        }
+      }
+    `,
+    fetchPolicy: 'network-only'
+  })
+  state.groups = cloneDeep(resp?.data?.groups?.filter(g => g.id !== '10000000-0000-4000-8000-000000000001') ?? [])
+  state.loadingGroups = false
+  state.loading--
+}
+
+async function create () {
+  state.loading++
+  try {
+    const isFormValid = await createKeyForm.value.validate(true)
+    if (!isFormValid) {
+      throw new Error(t('admin.api.createInvalidData'))
+    }
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation createApiKey (
+          $name: String!
+          $expiration: String!
+          $groups: [UUID]!
+          ) {
+          createApiKey (
+            name: $name
+            expiration: $expiration
+            groups: $groups
+            ) {
+            operation {
+              succeeded
+              message
+            }
+            key
+          }
+        }
+      `,
+      variables: {
+        name: state.keyName,
+        expiration: state.keyExpiration,
+        groups: state.keyGroups
+      }
+    })
+    if (resp?.data?.createApiKey?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.api.createSuccess')
+      })
+      $q.dialog({
+        component: ApiKeyCopyDialog,
+        componentProps: {
+          keyValue: resp?.data?.createApiKey?.key || 'ERROR'
+        }
+      }).onDismiss(() => {
+        onDialogOK()
+      })
+    } else {
+      throw new Error(resp?.data?.createApiKey?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.loading--
+}
+
+// MOUNTED
+
+onMounted(loadGroups)
+</script>

+ 104 - 0
ux/src/components/ApiKeyRevokeDialog.vue

@@ -0,0 +1,104 @@
+<template lang="pug">
+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-unavailable.svg', left, size='sm')
+      span {{t(`admin.api.revokeConfirm`)}}
+    q-card-section
+      .text-body2
+        i18n-t(keypath='admin.api.revokeConfirmText')
+          template(#name)
+            strong {{apiKey.name}}
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        unelevated
+        :label='t(`admin.api.revoke`)'
+        color='negative'
+        padding='xs md'
+        @click='confirm'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
+
+// PROPS
+
+const props = defineProps({
+  apiKey: {
+    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 revokeApiKey ($id: UUID!) {
+          revokeApiKey (id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: props.apiKey.id
+      }
+    })
+    if (resp?.data?.revokeApiKey?.operation?.succeeded) {
+      $q.notify({
+        type: 'positive',
+        message: t('admin.api.revokeSuccess')
+      })
+      onDialogOK()
+    } else {
+      throw new Error(resp?.data?.revokeApiKey?.operation?.message || 'An unexpected error occured.')
+    }
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: err.message
+    })
+  }
+  state.isLoading = false
+}
+</script>

+ 101 - 0
ux/src/components/CheckUpdateDialog.vue

@@ -0,0 +1,101 @@
+<template lang="pug">
+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-downloading-updates.svg', left, size='sm')
+      span {{t(`admin.system.checkingForUpdates`)}}
+    q-card-section
+      .q-pa-md.text-center
+        img(src='/_assets/illustrations/undraw_going_up.svg', style='width: 150px;')
+      q-linear-progress(
+        indeterminate
+        size='lg'
+        rounded
+        )
+      .q-mt-sm.text-center.text-caption Fetching latest version info...
+    q-card-actions.card-actions
+      q-space
+      q-btn.acrylic-btn(
+        flat
+        :label='t(`common.actions.cancel`)'
+        color='grey'
+        padding='xs md'
+        @click='onDialogCancel'
+        )
+      q-btn(
+        v-if='state.canUpgrade'
+        unelevated
+        :label='t(`admin.system.upgrade`)'
+        color='primary'
+        padding='xs md'
+        @click='upgrade'
+        :loading='state.isLoading'
+        )
+</template>
+
+<script setup>
+import gql from 'graphql-tag'
+import { useI18n } from 'vue-i18n'
+import { useDialogPluginComponent, useQuasar } from 'quasar'
+import { reactive } from 'vue'
+
+// EMITS
+
+defineEmits([
+  ...useDialogPluginComponent.emits
+])
+
+// QUASAR
+
+const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent()
+const $q = useQuasar()
+
+// I18N
+
+const { t } = useI18n()
+
+// DATA
+
+const state = reactive({
+  isLoading: false,
+  canUpgrade: false
+})
+
+// METHODS
+
+async function upgrade () {
+  state.isLoading = true
+  try {
+    const resp = await APOLLO_CLIENT.mutate({
+      mutation: gql`
+        mutation deleteHook ($id: UUID!) {
+          deleteHook(id: $id) {
+            operation {
+              succeeded
+              message
+            }
+          }
+        }
+      `,
+      variables: {
+        id: 0
+      }
+    })
+    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>

+ 21 - 0
ux/src/css/app.scss

@@ -152,6 +152,27 @@ body::-webkit-scrollbar-thumb {
   }
 }
 
+.card-negative {
+  display: flex;
+  align-items: center;
+  font-size: .9rem;
+
+  @at-root .body--light & {
+    background-color: $red-1;
+    background-image: radial-gradient(at bottom center, lighten($red-1, 2%), lighten($red-2, 2%));
+    border-bottom: 1px solid $red-3;
+    text-shadow: 0 0 4px #FFF;
+    color: $red-9;
+  }
+  @at-root .body--dark & {
+    background-color: $red-9;
+    background-image: radial-gradient(at bottom center, $red-7, $red-9);
+    border-bottom: 1px solid $red-7;
+    text-shadow: 0 0 4px darken($red-9, 10%);
+    color: #FFF;
+  }
+}
+
 .card-actions {
   @at-root .body--light & {
     background-color: #FAFAFA;

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

@@ -24,7 +24,7 @@
   "admin.api.headerRevoke": "Revoke",
   "admin.api.newKeyButton": "New API Key",
   "admin.api.newKeyCopyWarn": "Copy the key shown below as {bold}",
-  "admin.api.newKeyCopyWarnBold": "it will NOT be shown again",
+  "admin.api.newKeyCopyWarnBold": "it will NOT be shown again.",
   "admin.api.newKeyExpiration": "Expiration",
   "admin.api.newKeyExpirationHint": "You can still revoke a key anytime regardless of the expiration.",
   "admin.api.newKeyFullAccess": "Full Access",
@@ -1449,5 +1449,22 @@
   "admin.auth.enabledHint": "Should this strategy be available to sites for login.",
   "admin.auth.vendor": "Vendor",
   "admin.auth.vendorWebsite": "Website",
-  "admin.auth.status": "Status"
+  "admin.auth.status": "Status",
+  "admin.system.checkingForUpdates": "Checking for Updates...",
+  "admin.system.upgrade": "Upgrade",
+  "admin.system.checkForUpdates": "Check",
+  "admin.system.checkUpdate": "Check / Upgrade",
+  "admin.api.none": "There are no API keys yet.",
+  "admin.api.groupsMissing": "You must select at least 1 group for this key.",
+  "admin.api.nameInvalidChars": "Key name has invalid characters.",
+  "admin.api.nameMissing": "Key name is missing.",
+  "admin.api.permissionGroups": "Group Permissions",
+  "admin.api.groupSelected": "Use {group} group permissions",
+  "admin.api.groupsSelected": "Use permissions from {count} groups",
+  "admin.api.createInvalidData": "Some fields are missing or have invalid data.",
+  "admin.api.copyKeyTitle": "Copy API Key",
+  "admin.api.key": "API Key",
+  "admin.api.createSuccess": "API Key created successfully.",
+  "admin.api.revoked": "Revoked",
+  "admin.api.revokedHint": "This key has been revoked and can no longer be used."
 }

+ 77 - 125
ux/src/pages/AdminApi.vue

@@ -9,7 +9,7 @@ q-page.admin-api
     .col
       .flex.items-center
         template(v-if='state.enabled')
-          q-spinner-rings.q-mr-sm(color='green')
+          q-spinner-rings.q-mr-sm(color='green', size='md')
           .text-caption.text-green {{t('admin.api.enabled')}}
         template(v-else)
           q-spinner-rings.q-mr-sm(color='red', size='md')
@@ -28,7 +28,7 @@ q-page.admin-api
         flat
         color='secondary'
         :loading='state.loading > 0'
-        @click='load'
+        @click='refresh'
         )
       q-btn.q-mr-sm(
         unelevated
@@ -36,6 +36,7 @@ q-page.admin-api
         :label='!state.enabled ? t(`admin.api.enableButton`) : t(`admin.api.disableButton`)'
         :color='!state.enabled ? `positive` : `negative`'
         @click='globalSwitch'
+        :loading='state.isToggleLoading'
         :disabled='state.loading > 0'
       )
       q-btn(
@@ -48,70 +49,47 @@ q-page.admin-api
       )
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
-    .col-12.col-lg-7
-      q-card.shadow-1
-
-//- v-container(fluid, grid-list-lg)
-//-   v-layout(row, wrap)
-//-     v-flex(xs12)
-//-       .admin-header
-//-         img.animated.fadeInUp(src='/_assets/svg/icon-rest-api.svg', alt='API', style='width: 80px;')
-//-         .admin-header-title
-//-           .headline.primary--text.animated.fadeInLeft {{$t('admin.api.title')}}
-//-           .subtitle-1.grey--text.animated.fadeInLeft {{$t('admin.api.subtitle')}}
-//-         v-spacer
-//-         template(v-if='enabled')
-//-           status-indicator.mr-3(positive, pulse)
-//-           .caption.green--text.animated.fadeInLeft {{$t('admin.api.enabled')}}
-//-         template(v-else)
-//-           status-indicator.mr-3(negative, pulse)
-//-           .caption.red--text.animated.fadeInLeft {{$t('admin.api.disabled')}}
-//-         v-spacer
-//-         v-btn.mr-3.animated.fadeInDown.wait-p2s(outlined, color='grey', icon, @click='refresh')
-//-           v-icon mdi-refresh
-//-         v-btn.mr-3.animated.fadeInDown.wait-p1s(:color='enabled ? `red` : `green`', depressed, @click='globalSwitch', dark, :loading='isToggleLoading')
-//-           v-icon(left) mdi-power
-//-           span(v-if='!enabled') {{$t('admin.api.enableButton')}}
-//-           span(v-else) {{$t('admin.api.disableButton')}}
-//-         v-btn.animated.fadeInDown(color='primary', depressed, large, @click='newKey', dark)
-//-           v-icon(left) mdi-plus
-//-           span {{$t('admin.api.newKeyButton')}}
-//-       v-card.mt-3.animated.fadeInUp
-//-         v-simple-table(v-if='keys && keys.length > 0')
-//-           template(v-slot:default)
-//-             thead
-//-               tr.grey(:class='$vuetify.theme.dark ? `darken-4-d5` : `lighten-5`')
-//-                 th {{$t('admin.api.headerName')}}
-//-                 th {{$t('admin.api.headerKeyEnding')}}
-//-                 th {{$t('admin.api.headerExpiration')}}
-//-                 th {{$t('admin.api.headerCreated')}}
-//-                 th {{$t('admin.api.headerLastUpdated')}}
-//-                 th(width='100') {{$t('admin.api.headerRevoke')}}
-//-             tbody
-//-               tr(v-for='key of keys', :key='`key-` + key.id')
-//-                 td
-//-                   strong(:class='key.isRevoked ? `red--text` : ``') {{ key.name }}
-//-                   em.caption.ml-1.red--text(v-if='key.isRevoked') (revoked)
-//-                 td.caption {{ key.keyShort }}
-//-                 td(:style='key.isRevoked ? `text-decoration: line-through;` : ``') {{ key.expiration | moment('LL') }}
-//-                 td {{ key.createdAt | moment('calendar') }}
-//-                 td {{ key.updatedAt | moment('calendar') }}
-//-                 td: v-btn(icon, @click='revoke(key)', :disabled='key.isRevoked'): v-icon(color='error') mdi-cancel
-//-         v-card-text(v-else)
-//-           v-alert.mb-0(icon='mdi-information', :value='true', outlined, color='info') {{$t('admin.api.noKeyInfo')}}
-
-//-   create-api-key(v-model='isCreateDialogShown', @refresh='refresh(false)')
-
-//-   v-dialog(v-model='isRevokeConfirmDialogShown', max-width='500', persistent)
-//-     v-card
-//-       .dialog-header.is-red {{$t('admin.api.revokeConfirm')}}
-//-       v-card-text.pa-4
-//-         i18next(tag='span', path='admin.api.revokeConfirmText')
-//-           strong(place='name') {{ current.name }}
-//-       v-card-actions
-//-         v-spacer
-//-         v-btn(text, @click='isRevokeConfirmDialogShown = false', :disabled='revokeLoading') {{$t('common.actions.cancel')}}
-//-         v-btn(color='red', dark, @click='revokeConfirm', :loading='revokeLoading') {{$t('admin.api.revoke')}}
+    .col-12(v-if='state.keys.length < 1')
+      q-card.rounded-borders(
+        flat
+        :class='$q.dark.isActive ? `bg-dark-5 text-white` : `bg-grey-3 text-dark`'
+        )
+        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.api.none') }}
+    .col-12(v-else)
+      q-card
+        q-list(separator)
+          q-item(v-for='key of state.keys', :key='key.id')
+            q-item-section(side)
+              q-icon(name='las la-key', :color='key.isRevoked ? `negative` : `positive`')
+            q-item-section
+              q-item-label {{key.name}}
+              q-item-label(caption) Ending in {{key.keyShort}}
+              q-item-label(caption) Created On: #[strong {{DateTime.fromISO(key.createdAt).toFormat('fff')}}]
+              q-item-label(caption) Expiration: #[strong(:style='key.isRevoked ? `text-decoration: line-through;` : ``') {{DateTime.fromISO(key.expiration).toFormat('fff')}}]
+            q-item-section(
+              v-if='key.isRevoked'
+              side
+              style='flex-direction: row; align-items: center;'
+              )
+              q-icon.q-mr-sm(
+                color='negative'
+                size='xs'
+                name='las la-exclamation-triangle'
+              )
+              .text-caption.text-negative {{t('admin.api.revoked')}}
+              q-tooltip(anchor='center left', self='center right') {{t('admin.api.revokedHint')}}
+            q-separator.q-ml-md(vertical)
+            q-item-section(side, style='flex-direction: row; align-items: center;')
+              q-btn.acrylic-btn(
+                :color='key.isRevoked ? `gray` : `red`'
+                icon='las la-ban'
+                flat
+                @click='revoke(key)'
+                :disable='key.isRevoked'
+              )
 </template>
 
 <script setup>
@@ -120,6 +98,10 @@ import { cloneDeep } from 'lodash-es'
 import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, watch } from 'vue'
+import { DateTime } from 'luxon'
+
+import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue'
+import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue'
 
 // QUASAR
 
@@ -140,6 +122,7 @@ useMeta({
 const state = reactive({
   enabled: false,
   loading: 0,
+  isToggleLoading: false,
   keys: [],
   isCreateDialogShown: false,
   isRevokeConfirmDialogShown: false,
@@ -154,7 +137,7 @@ async function load () {
   $q.loading.show()
   const resp = await APOLLO_CLIENT.query({
     query: gql`
-      query getHooks {
+      query getApiKeys {
         apiKeys {
           id
           name
@@ -175,20 +158,24 @@ async function load () {
   state.loading--
 }
 
+async function refresh () {
+  await load()
+  $q.notify({
+    type: 'positive',
+    message: t('admin.api.refreshSuccess')
+  })
+}
+
 async function globalSwitch () {
   state.isToggleLoading = true
   try {
     const resp = await APOLLO_CLIENT.mutate({
       mutation: gql`
         mutation ($enabled: Boolean!) {
-          authentication {
-            setApiState (enabled: $enabled) {
-              responseResult {
-                succeeded
-                errorCode
-                slug
-                message
-              }
+          setApiState (enabled: $enabled) {
+            operation {
+              succeeded
+              message
             }
           }
         }
@@ -204,7 +191,7 @@ async function globalSwitch () {
       })
       await load()
     } else {
-      throw new Error(resp?.data?.setApiState?.operation.message || 'An unexpected error occurred.')
+      throw new Error(resp?.data?.setApiState?.operation?.message || 'An unexpected error occurred.')
     }
   } catch (err) {
     $q.notify({
@@ -217,62 +204,27 @@ async function globalSwitch () {
 }
 
 async function newKey () {
-  state.isCreateDialogShown = true
+  $q.dialog({
+    component: ApiKeyCreateDialog
+  }).onOk(() => {
+    load()
+  })
 }
 
 function revoke (key) {
-  state.current = key
-  state.isRevokeConfirmDialogShown = true
-}
-
-async function revokeConfirm () {
-  state.revokeLoading = true
-  try {
-    const resp = await APOLLO_CLIENT.mutate({
-      mutation: gql`
-        mutation ($id: Int!) {
-          authentication {
-            revokeApiKey (id: $id) {
-              responseResult {
-                succeeded
-                errorCode
-                slug
-                message
-              }
-            }
-          }
-        }
-      `,
-      variables: {
-        id: state.current.id
-      }
-    })
-    // if (_get(resp, 'data.authentication.revokeApiKey.responseResult.succeeded', false)) {
-    //   this.$store.commit('showNotification', {
-    //     style: 'success',
-    //     message: this.$t('admin.api.revokeSuccess'),
-    //     icon: 'check'
-    //   })
-    //   this.load()
-    // } else {
-    //   this.$store.commit('showNotification', {
-    //     style: 'red',
-    //     message: _get(resp, 'data.authentication.revokeApiKey.responseResult.message', 'An unexpected error occurred.'),
-    //     icon: 'alert'
-    //   })
-    // }
-  } catch (err) {
-    // this.$store.commit('pushGraphError', err)
-  }
-  state.isRevokeConfirmDialogShown = false
-  state.revokeLoading = false
+  $q.dialog({
+    component: ApiKeyRevokeDialog,
+    componentProps: {
+      apiKey: key
+    }
+  }).onOk(() => {
+    load()
+  })
 }
 
 // MOUNTED
 
-onMounted(() => {
-  load()
-})
+onMounted(load)
 
 </script>
 

+ 0 - 236
ux/src/pages/AdminApiCreate.vue

@@ -1,236 +0,0 @@
-<template lang="pug">
-  div
-    v-dialog(v-model='isShown', max-width='650', persistent)
-      v-card
-        .dialog-header.is-short
-          v-icon.mr-3(color='white') mdi-plus
-          span {{$t('admin.api.newKeyTitle')}}
-        v-card-text.pt-5
-          v-text-field(
-            outlined
-            prepend-icon='mdi-format-title'
-            v-model='name'
-            :label='$t(`admin.api.newKeyName`)'
-            persistent-hint
-            ref='keyNameInput'
-            :hint='$t(`admin.api.newKeyNameHint`)'
-            counter='255'
-            )
-          v-select.mt-3(
-            :items='expirations'
-            outlined
-            prepend-icon='mdi-clock'
-            v-model='expiration'
-            :label='$t(`admin.api.newKeyExpiration`)'
-            :hint='$t(`admin.api.newKeyExpirationHint`)'
-            persistent-hint
-            )
-          v-divider.mt-4
-          v-subheader.pl-2: strong.indigo--text {{$t('admin.api.newKeyPermissionScopes')}}
-          v-list.pl-8(nav)
-            v-list-item-group(v-model='fullAccess')
-              v-list-item(
-                :value='true'
-                active-class='indigo--text'
-                )
-                template(v-slot:default='{ active, toggle }')
-                  v-list-item-action
-                    v-checkbox(
-                      :input-value='active'
-                      :true-value='true'
-                      color='indigo'
-                      @click='toggle'
-                    )
-                  v-list-item-content
-                    v-list-item-title {{$t('admin.api.newKeyFullAccess')}}
-            v-divider.mt-3
-            v-subheader.caption.indigo--text {{$t('admin.api.newKeyGroupPermissions')}}
-            v-list-item
-              v-select(
-                :disabled='fullAccess'
-                :items='groups'
-                item-text='name'
-                item-value='id'
-                outlined
-                color='indigo'
-                v-model='group'
-                :label='$t(`admin.api.newKeyGroup`)'
-                :hint='$t(`admin.api.newKeyGroupHint`)'
-                persistent-hint
-                )
-        v-card-chin
-          v-spacer
-          v-btn(text, @click='isShown = false', :disabled='loading') {{$t('common.actions.cancel')}}
-          v-btn.px-3(depressed, color='primary', @click='generate', :loading='loading')
-            v-icon(left) mdi-chevron-right
-            span {{$t('common.actions.generate')}}
-
-    v-dialog(
-      v-model='isCopyKeyDialogShown'
-      max-width='750'
-      persistent
-      overlay-color='blue darken-5'
-      overlay-opacity='.9'
-      )
-      v-card
-        v-toolbar(dense, flat, color='primary', dark) {{$t('admin.api.newKeyTitle')}}
-        v-card-text.pt-5
-          .body-2.text-center
-            i18next(tag='span', path='admin.api.newKeyCopyWarn')
-              strong(place='bold') {{$t('admin.api.newKeyCopyWarnBold')}}
-          v-textarea.mt-3(
-            ref='keyContentsIpt'
-            filled
-            no-resize
-            readonly
-            v-model='key'
-            :rows='10'
-            hide-details
-          )
-        v-card-chin
-          v-spacer
-          v-btn.px-3(depressed, dark, color='primary', @click='isCopyKeyDialogShown = false') {{$t('common.actions.close')}}
-</template>
-
-<script>
-import _ from 'lodash'
-import gql from 'graphql-tag'
-
-import groupsQuery from 'gql/admin/users/users-query-groups.gql'
-
-export default {
-  props: {
-    value: {
-      type: Boolean,
-      default: false
-    }
-  },
-  data() {
-    return {
-      loading: false,
-      name: '',
-      expiration: '1y',
-      fullAccess: true,
-      groups: [],
-      group: null,
-      isCopyKeyDialogShown: false,
-      key: ''
-    }
-  },
-  computed: {
-    isShown: {
-      get() { return this.value },
-      set(val) { this.$emit('input', val) }
-    },
-    expirations() {
-      return [
-        { value: '30d', text: this.$t('admin.api.expiration30d') },
-        { value: '90d', text: this.$t('admin.api.expiration90d') },
-        { value: '180d', text: this.$t('admin.api.expiration180d') },
-        { value: '1y', text: this.$t('admin.api.expiration1y') },
-        { value: '3y', text: this.$t('admin.api.expiration3y') }
-      ]
-    }
-  },
-  watch: {
-    value (newValue, oldValue) {
-      if (newValue) {
-        setTimeout(() => {
-          this.$refs.keyNameInput.focus()
-        }, 400)
-      }
-    }
-  },
-  methods: {
-    async generate () {
-      try {
-        if (_.trim(this.name).length < 2 || this.name.length > 255) {
-          throw new Error(this.$t('admin.api.newKeyNameError'))
-        } else if (!this.fullAccess && !this.group) {
-          throw new Error(this.$t('admin.api.newKeyGroupError'))
-        } else if (!this.fullAccess && this.group === 2) {
-          throw new Error(this.$t('admin.api.newKeyGuestGroupError'))
-        }
-      } catch (err) {
-        return this.$store.commit('showNotification', {
-          style: 'red',
-          message: err,
-          icon: 'alert'
-        })
-      }
-
-      this.loading = true
-
-      try {
-        const resp = await this.$apollo.mutate({
-          mutation: gql`
-            mutation ($name: String!, $expiration: String!, $fullAccess: Boolean!, $group: Int) {
-              authentication {
-                createApiKey (name: $name, expiration: $expiration, fullAccess: $fullAccess, group: $group) {
-                  key
-                  responseResult {
-                    succeeded
-                    errorCode
-                    slug
-                    message
-                  }
-                }
-              }
-            }
-          `,
-          variables: {
-            name: this.name,
-            expiration: this.expiration,
-            fullAccess: (this.fullAccess === true),
-            group: this.group
-          },
-          watchLoading (isLoading) {
-            this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-create')
-          }
-        })
-        if (_.get(resp, 'data.authentication.createApiKey.responseResult.succeeded', false)) {
-          this.$store.commit('showNotification', {
-            style: 'success',
-            message: this.$t('admin.api.newKeySuccess'),
-            icon: 'check'
-          })
-
-          this.name = ''
-          this.expiration = '1y'
-          this.fullAccess = true
-          this.group = null
-          this.isShown = false
-          this.$emit('refresh')
-
-          this.key = _.get(resp, 'data.authentication.createApiKey.key', '???')
-          this.isCopyKeyDialogShown = true
-
-          setTimeout(() => {
-            this.$refs.keyContentsIpt.$refs.input.select()
-          }, 400)
-        } else {
-          this.$store.commit('showNotification', {
-            style: 'red',
-            message: _.get(resp, 'data.authentication.createApiKey.responseResult.message', 'An unexpected error occurred.'),
-            icon: 'alert'
-          })
-        }
-      } catch (err) {
-        this.$store.commit('pushGraphError', err)
-      }
-
-      this.loading = false
-    }
-  },
-  apollo: {
-    groups: {
-      query: groupsQuery,
-      fetchPolicy: 'network-only',
-      update: (data) => data.groups.list,
-      watchLoading (isLoading) {
-        this.$store.commit(`loading${isLoading ? 'Start' : 'Stop'}`, 'admin-api-groups-refresh')
-      }
-    }
-  }
-}
-</script>

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

@@ -162,8 +162,9 @@ q-page.admin-mail
           .text-subtitle1 {{state.strategy.strategy.title}}
           q-img.q-mt-sm.rounded-borders(
             :src='state.strategy.strategy.logo'
-            fit='cover'
+            fit='contain'
             no-spinner
+            style='height: 100px;'
           )
           .text-body2.q-mt-md {{state.strategy.strategy.description}}
         q-separator.q-mb-sm(inset)

+ 9 - 2
ux/src/pages/AdminDashboard.vue

@@ -97,12 +97,13 @@ q-page.admin-dashboard
         template(#action)
           q-btn(
             flat
-            label='Check'
+            :label='t(`admin.system.checkForUpdates`)'
+            @click='checkForUpdates'
             )
           q-separator.q-mx-sm(vertical, dark)
           q-btn(
             flat
-            label='System Info'
+            :label='t(`admin.system.title`)'
             to='/_admin/system'
             )
     .col-12
@@ -224,6 +225,7 @@ import { useAdminStore } from '../stores/admin'
 
 // COMPONENTS
 
+import CheckUpdateDialog from '../components/CheckUpdateDialog.vue'
 import SiteCreateDialog from '../components/SiteCreateDialog.vue'
 import UserCreateDialog from '../components/UserCreateDialog.vue'
 
@@ -265,6 +267,11 @@ function newUser () {
     router.push('/_admin/users')
   })
 }
+function checkForUpdates () {
+  $q.dialog({
+    component: CheckUpdateDialog
+  })
+}
 
 </script>
 

+ 4 - 0
ux/src/pages/AdminFlags.vue

@@ -77,6 +77,10 @@ q-page.admin-flags
               unchecked-icon='las la-times'
               :aria-label='t(`admin.flags.hidedonatebtn.label`)'
               )
+
+    .col-12.col-lg-5.gt-md
+      .q-pa-md.text-center
+        img(src='/_assets/illustrations/undraw_settings.svg', style='width: 80%;')
 </template>
 
 <script setup>

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

@@ -117,6 +117,9 @@ q-page.admin-locale
               :aria-label='lc.name'
               )
 
+      .q-pa-md.text-center.gt-md(v-else)
+        img(src='/_assets/illustrations/undraw_world.svg', style='width: 80%;')
+
         //- q-separator.q-my-sm(inset)
         //- q-item
         //-   blueprint-icon(icon='test-passed')

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

@@ -54,7 +54,16 @@ q-page.admin-system
             q-item-label {{ t('admin.system.latestVersion') }}
             q-item-label(caption) {{t('admin.system.latestVersionHint')}}
           q-item-section
-            q-item-label.dark-value(caption) {{ state.info.latestVersion }}
+            .row.q-col-gutter-sm
+              .col
+                .dark-value(caption) {{ state.info.latestVersion }}
+              .col-auto
+                q-btn.acrylic-btn(
+                  flat
+                  color='purple'
+                  @click='checkForUpdates'
+                  :label='t(`admin.system.checkUpdate`)'
+                )
 
       //- -----------------------
       //- CLIENT
@@ -234,6 +243,8 @@ import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, ref, watch } from 'vue'
 import ClipboardJS from 'clipboard'
 
+import CheckUpdateDialog from '../components/CheckUpdateDialog.vue'
+
 // QUASAR
 
 const $q = useQuasar()
@@ -340,6 +351,12 @@ async function load () {
   state.loading--
 }
 
+function checkForUpdates () {
+  $q.dialog({
+    component: CheckUpdateDialog
+  })
+}
+
 // async function performUpgrade () {
 //   state.isUpgrading = true
 //   state.isUpgradingStarted = false

+ 101 - 103
ux/yarn.lock

@@ -570,21 +570,21 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@intlify/bundle-utils@npm:^2.2.2":
-  version: 2.2.2
-  resolution: "@intlify/bundle-utils@npm:2.2.2"
+"@intlify/bundle-utils@npm:next":
+  version: 3.1.0
+  resolution: "@intlify/bundle-utils@npm:3.1.0"
   dependencies:
-    "@intlify/message-compiler": ^9.1.0
-    "@intlify/shared": ^9.1.0
+    "@intlify/message-compiler": next
+    "@intlify/shared": next
     jsonc-eslint-parser: ^1.0.1
-    source-map: ^0.6.1
+    source-map: 0.6.1
     yaml-eslint-parser: ^0.3.2
   peerDependenciesMeta:
     petite-vue-i18n:
       optional: true
     vue-i18n:
       optional: true
-  checksum: fc447a353eb076797f60800e9d5ef451a5dacc758bbb9288306d418e963c06c6aa28973e0901b4924c160b3ed38126cf5697bad03ea11b9c21bf5ec6a9aae4a9
+  checksum: 708f071736e5ae2f55929b9561b5488c4bb087a46e7e75208e181ef3e6fea66c283a2cd47027df7bb2b2d7dbcc9792bdbe9bd94753400b8f18ea86c057f2bdd2
   languageName: node
   linkType: hard
 
@@ -622,14 +622,13 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@intlify/message-compiler@npm:^9.1.0":
-  version: 9.1.9
-  resolution: "@intlify/message-compiler@npm:9.1.9"
+"@intlify/message-compiler@npm:next":
+  version: 9.2.0-beta.40
+  resolution: "@intlify/message-compiler@npm:9.2.0-beta.40"
   dependencies:
-    "@intlify/message-resolver": 9.1.9
-    "@intlify/shared": 9.1.9
+    "@intlify/shared": 9.2.0-beta.40
     source-map: 0.6.1
-  checksum: 4677349715199f833a042df9852b4285c4384accf3715182492b6fffdbbd5948bb8527eb611021f9c5184e599882e79ea5935d7d43e4f8ab02a47dc7e7989c3e
+  checksum: 4f1c47b1a00b98213b8d18b8f458f092850b2a64ef82ba915486ab82b7fa9f0f94cc57e0d7cbfb63cad27d284a7c351300165eac414f383e55013cc7c928c2a5
   languageName: node
   linkType: hard
 
@@ -640,13 +639,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@intlify/message-resolver@npm:9.1.9":
-  version: 9.1.9
-  resolution: "@intlify/message-resolver@npm:9.1.9"
-  checksum: bd7097927f0a83b3f44d810be0aaa46f4d779e0a70c2a1a71dfc1ad058de5f0b1afa9121cde03dd42732303a70a66ec5c9679efda53c36222945081c1fde9cbb
-  languageName: node
-  linkType: hard
-
 "@intlify/runtime@npm:9.1.10":
   version: 9.1.10
   resolution: "@intlify/runtime@npm:9.1.10"
@@ -665,33 +657,35 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@intlify/shared@npm:9.1.9, @intlify/shared@npm:^9.1.0":
-  version: 9.1.9
-  resolution: "@intlify/shared@npm:9.1.9"
-  checksum: 39c589eefadff0a5b1f6210f9010a382c1e100a79beebddd57f67e74297abb67a501d6e0d7e0dc5ba49ccef4069cd877bc36cc0ea4f712442bad76eaa15eed4a
+"@intlify/shared@npm:9.2.0-beta.40, @intlify/shared@npm:next":
+  version: 9.2.0-beta.40
+  resolution: "@intlify/shared@npm:9.2.0-beta.40"
+  checksum: a28f175c8e79136e43bcb4c817e586c859fcff195b11eb79d25e6a9c82f8e2079b8a4447333be93e36533957cafb35d7e0e550659eeb8451aa0841185c1d59db
   languageName: node
   linkType: hard
 
-"@intlify/vite-plugin-vue-i18n@npm:3.4.0":
-  version: 3.4.0
-  resolution: "@intlify/vite-plugin-vue-i18n@npm:3.4.0"
+"@intlify/vite-plugin-vue-i18n@npm:5.0.1":
+  version: 5.0.1
+  resolution: "@intlify/vite-plugin-vue-i18n@npm:5.0.1"
   dependencies:
-    "@intlify/bundle-utils": ^2.2.2
-    "@intlify/shared": ^9.1.0
-    "@rollup/pluginutils": ^4.1.0
+    "@intlify/bundle-utils": next
+    "@intlify/shared": next
+    "@rollup/pluginutils": ^4.2.0
     debug: ^4.3.1
     fast-glob: ^3.2.5
     source-map: 0.6.1
   peerDependencies:
-    petite-vue-i18n: ^9.1.0
-    vite: ^2.0.0
-    vue-i18n: ^9.1.0
+    petite-vue-i18n: "*"
+    vite: ^2.9.0 || ^3.0.0
+    vue-i18n: "*"
   peerDependenciesMeta:
     petite-vue-i18n:
       optional: true
+    vite:
+      optional: true
     vue-i18n:
       optional: true
-  checksum: 5d1c59525e00c25d6b6ee0b3f9ae94ec0f67a0c96f4954051dc28a922da673dff90d79bfac090a830194ffc11e4e523691d56de609d3723142491e3b34c2537e
+  checksum: ba7561ea1be2cba4cbd738064bf1b2944b7487364b83e8e8af705d8b05e39467ada16008560c7564a532c2d59cf95be83401fe8f3e6e0b3e01ecad11f42d6fa0
   languageName: node
   linkType: hard
 
@@ -952,10 +946,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@quasar/extras@npm:1.14.2":
-  version: 1.14.2
-  resolution: "@quasar/extras@npm:1.14.2"
-  checksum: 600305d8fb641eca6990ef1527ce544c35835a4af088b58263851b70df444d6890e134dc01cb7da3f256ec8b5ae369ce2d5c29ddadff2452fff6e080e0411905
+"@quasar/extras@npm:1.15.0":
+  version: 1.15.0
+  resolution: "@quasar/extras@npm:1.15.0"
+  checksum: 2fb3274f441ef4db6b0925a9d72bc80845793ac3ca07b7929a89978922236e8c09667bf2f633a8103ba197d0535a757c66fc4d5337664138b8c6f35418ae9ea2
   languageName: node
   linkType: hard
 
@@ -977,7 +971,7 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@rollup/pluginutils@npm:^4.1.0, @rollup/pluginutils@npm:^4.1.2":
+"@rollup/pluginutils@npm:^4.1.2":
   version: 4.2.0
   resolution: "@rollup/pluginutils@npm:4.2.0"
   dependencies:
@@ -987,6 +981,16 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@rollup/pluginutils@npm:^4.2.0":
+  version: 4.2.1
+  resolution: "@rollup/pluginutils@npm:4.2.1"
+  dependencies:
+    estree-walker: ^2.0.1
+    picomatch: ^2.2.2
+  checksum: 6bc41f22b1a0f1efec3043899e4d3b6b1497b3dea4d94292d8f83b4cf07a1073ecbaedd562a22d11913ff7659f459677b01b09e9598a98936e746780ecc93a12
+  languageName: node
+  linkType: hard
+
 "@tiptap/core@npm:2.0.0-beta.176, @tiptap/core@npm:^2.0.0-beta.176":
   version: 2.0.0-beta.176
   resolution: "@tiptap/core@npm:2.0.0-beta.176"
@@ -1710,18 +1714,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"@vue/apollo-option@npm:4.0.0-alpha.17":
-  version: 4.0.0-alpha.17
-  resolution: "@vue/apollo-option@npm:4.0.0-alpha.17"
-  dependencies:
-    throttle-debounce: ^3.0.1
-  peerDependencies:
-    "@apollo/client": ^3.2.1
-    vue: ^3.1.0
-  checksum: f1d1152131a1bfa36497c7bc42fd7c1ca2848a0a98943990b4943ef80d528ce7713be41e6943dbf7755726f413b350731fdb868cece69dee5d37377346b7fe01
-  languageName: node
-  linkType: hard
-
 "@vue/compiler-core@npm:3.2.37":
   version: 3.2.37
   resolution: "@vue/compiler-core@npm:3.2.37"
@@ -1779,6 +1771,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"@vue/devtools-api@npm:^6.2.1":
+  version: 6.2.1
+  resolution: "@vue/devtools-api@npm:6.2.1"
+  checksum: 34765af0be9b0cc7e3def73b2792b1514e3c348852c5a7503fe07d013f0e907af6c27c0a32c0637dd748caf37c075af8e53ca3220433e0bd34b6f3405f358272
+  languageName: node
+  linkType: hard
+
 "@vue/reactivity-transform@npm:3.2.37":
   version: 3.2.37
   resolution: "@vue/reactivity-transform@npm:3.2.37"
@@ -2232,10 +2231,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"browser-fs-access@npm:0.30.2":
-  version: 0.30.2
-  resolution: "browser-fs-access@npm:0.30.2"
-  checksum: 4fa53ebe2e5ea5fb2df39f2ce78ae67cf3b0710877f123d57d8a9f7be171e27b8c8413079fe023612aa05d27549c38ff68a94f694750fd766cf9ecd80067d822
+"browser-fs-access@npm:0.31.0":
+  version: 0.31.0
+  resolution: "browser-fs-access@npm:0.31.0"
+  checksum: d1b6682415c2ee4c05dc44cd95daaa1a4f3f59d1ec723bde0d384bf44b71c804a665ca24a3805d0a76d7f2626541d4a777df4f45ae4a7439a8b76e20897d301d
   languageName: node
   linkType: hard
 
@@ -2705,6 +2704,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"csstype@npm:^3.1.0":
+  version: 3.1.0
+  resolution: "csstype@npm:3.1.0"
+  checksum: 644e986cefab86525f0b674a06889cfdbb1f117e5b7d1ce0fc55b0423ecc58807a1ea42ecc75c4f18999d14fc42d1d255f84662a45003a52bb5840e977eb2ffd
+  languageName: node
+  linkType: hard
+
 "d3-dispatch@npm:1 - 3":
   version: 3.0.1
   resolution: "d3-dispatch@npm:3.0.1"
@@ -3513,9 +3519,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"eslint-plugin-vue@npm:9.2.0":
-  version: 9.2.0
-  resolution: "eslint-plugin-vue@npm:9.2.0"
+"eslint-plugin-vue@npm:9.3.0":
+  version: 9.3.0
+  resolution: "eslint-plugin-vue@npm:9.3.0"
   dependencies:
     eslint-utils: ^3.0.0
     natural-compare: ^1.4.0
@@ -3526,7 +3532,7 @@ __metadata:
     xml-name-validator: ^4.0.0
   peerDependencies:
     eslint: ^6.2.0 || ^7.0.0 || ^8.0.0
-  checksum: 008819b12ad50ed62bfdc7f93e9e610575fa16dcccfb62ad9bb4ad27e69b1245419bf76752285742434fa6b9cf076f6fc173324084639e10209b2af2992630df
+  checksum: 3a1819749fb9a617f3647a58ee21595e811a9bcbf2a5eb1c2105da24c41c7b91a58e4473d751d6ab78cc0bb140dc7e11aac09337fc2c7d44401564a11ec92100
   languageName: node
   linkType: hard
 
@@ -3581,9 +3587,9 @@ __metadata:
   languageName: node
   linkType: hard
 
-"eslint@npm:8.19.0":
-  version: 8.19.0
-  resolution: "eslint@npm:8.19.0"
+"eslint@npm:8.20.0":
+  version: 8.20.0
+  resolution: "eslint@npm:8.20.0"
   dependencies:
     "@eslint/eslintrc": ^1.3.0
     "@humanwhocodes/config-array": ^0.9.2
@@ -3622,7 +3628,7 @@ __metadata:
     v8-compile-cache: ^2.0.3
   bin:
     eslint: bin/eslint.js
-  checksum: 0bc9df1a3a09dcd5a781ec728f280aa8af3ab19c2d1f14e2668b5ee5b8b1fb0e72dde5c3acf738e7f4281685fb24ec149b6154255470b06cf41de76350bca7a4
+  checksum: a31adf390d71d916925586bc8467b48f620e93dd0416bc1e897d99265af88b48d4eba3985b5ff4653ae5cc46311a360d373574002277e159bb38a4363abf9228
   languageName: node
   linkType: hard
 
@@ -4905,10 +4911,10 @@ __metadata:
   languageName: node
   linkType: hard
 
-"luxon@npm:2.5.0":
-  version: 2.5.0
-  resolution: "luxon@npm:2.5.0"
-  checksum: 2fccce6bbdfc8f13c5a8c148ff045ab3b10f4f80cac28dd92575588fffce9b2d7197096d7fedcc61a6245b59f4233507797f530e63f22b9ae4c425dff2909ae3
+"luxon@npm:3.0.1":
+  version: 3.0.1
+  resolution: "luxon@npm:3.0.1"
+  checksum: aa966eb919bf95b1bd819cda784d1f6f66e3fb65bd9ec7bf68b6a978eeb4e3e14f7e2275021b473f93b15b6b7ba2e5a30471e53add3929a7e695fcfd6dd40ec8
   languageName: node
   linkType: hard
 
@@ -5518,11 +5524,11 @@ __metadata:
   languageName: node
   linkType: hard
 
-"pinia@npm:2.0.14":
-  version: 2.0.14
-  resolution: "pinia@npm:2.0.14"
+"pinia@npm:2.0.17":
+  version: 2.0.17
+  resolution: "pinia@npm:2.0.17"
   dependencies:
-    "@vue/devtools-api": ^6.1.4
+    "@vue/devtools-api": ^6.2.1
     vue-demi: "*"
   peerDependencies:
     "@vue/composition-api": ^1.4.0
@@ -5533,7 +5539,7 @@ __metadata:
       optional: true
     typescript:
       optional: true
-  checksum: d07ed55b53e92da0971c3fcc0a1bd520b26cd50d266f46c8bab24fed87788461782a2c75088cf97c79810e7a4ceb28381f5d77daf280883ad3340c41962d0934
+  checksum: d308c6358570242b6c8126d990d756a224d1a77dfa375db5069da7f1c350372bf4e07feb4efce0d924dfc1b39f39d533234cdecf3594c4a2876de30477f6f978
   languageName: node
   linkType: hard
 
@@ -6591,13 +6597,6 @@ __metadata:
   languageName: node
   linkType: hard
 
-"throttle-debounce@npm:^3.0.1":
-  version: 3.0.1
-  resolution: "throttle-debounce@npm:3.0.1"
-  checksum: e34ef638e8df3a9154249101b68afcbf2652a139c803415ef8a2f6a8bc577bcd4d79e4bb914ad3cd206523ac78b9fb7e80885bfa049f64fbb1927f99d98b5736
-  languageName: node
-  linkType: hard
-
 "through@npm:^2.3.6":
   version: 2.3.8
   resolution: "through@npm:2.3.8"
@@ -6837,10 +6836,10 @@ __metadata:
     "@codemirror/state": 6.0.1
     "@codemirror/tooltip": 0.19.16
     "@codemirror/view": 6.0.2
-    "@intlify/vite-plugin-vue-i18n": 3.4.0
+    "@intlify/vite-plugin-vue-i18n": 5.0.1
     "@lezer/common": 1.0.0
     "@quasar/app-vite": 1.0.5
-    "@quasar/extras": 1.14.2
+    "@quasar/extras": 1.15.0
     "@tiptap/core": 2.0.0-beta.176
     "@tiptap/extension-code-block": 2.0.0-beta.37
     "@tiptap/extension-code-block-lowlight": 2.0.0-beta.68
@@ -6866,18 +6865,17 @@ __metadata:
     "@tiptap/starter-kit": 2.0.0-beta.185
     "@tiptap/vue-3": 2.0.0-beta.91
     "@types/lodash": 4.14.182
-    "@vue/apollo-option": 4.0.0-alpha.17
     apollo-upload-client: 17.0.0
-    browser-fs-access: 0.30.2
+    browser-fs-access: 0.31.0
     browserlist: latest
     clipboard: 2.0.11
     codemirror: 6.0.1
-    eslint: 8.19.0
+    eslint: 8.20.0
     eslint-config-standard: 17.0.0
     eslint-plugin-import: 2.26.0
     eslint-plugin-n: 15.2.4
     eslint-plugin-promise: 6.0.0
-    eslint-plugin-vue: 9.2.0
+    eslint-plugin-vue: 9.3.0
     filesize: 9.0.11
     filesize-parser: 1.5.0
     graphql: 16.5.0
@@ -6885,32 +6883,32 @@ __metadata:
     js-cookie: 3.0.1
     jwt-decode: 3.1.2
     lodash-es: 4.17.21
-    luxon: 2.5.0
-    pinia: 2.0.14
+    luxon: 3.0.1
+    pinia: 2.0.17
     pug: 3.0.2
     quasar: 2.7.5
     tippy.js: 6.3.7
     uuid: 8.3.2
-    v-network-graph: 0.6.3
+    v-network-graph: 0.6.5
     vue: 3.2.37
-    vue-codemirror: 6.0.0
+    vue-codemirror: 6.0.2
     vue-i18n: 9.1.10
-    vue-router: 4.1.1
+    vue-router: 4.1.3
     vuedraggable: 4.1.0
     zxcvbn: 4.4.2
   languageName: unknown
   linkType: soft
 
-"v-network-graph@npm:0.6.3":
-  version: 0.6.3
-  resolution: "v-network-graph@npm:0.6.3"
+"v-network-graph@npm:0.6.5":
+  version: 0.6.5
+  resolution: "v-network-graph@npm:0.6.5"
   dependencies:
     "@dash14/svg-pan-zoom": ^3.6.8
     mitt: ^3.0.0
   peerDependencies:
     d3-force: ^3.0.0
     vue: ^3.2.31
-  checksum: cecae746aaf6fd0f480b0a251b4d50dab89a16e88420c7ffd2de0fe5c5fb32a72cb19f94c3b6a19a4e83110fa0a136635c148b5231988c29e807dadb877f691a
+  checksum: a6312014cd424cc6747dafd8b05eef5bfd3f1a1a01ca67363ca1d17553d1c493b4a80f5d3d0bbd9c45b709ff78618f596f3a12673c9518086b303b39c6e7b5dd
   languageName: node
   linkType: hard
 
@@ -6993,19 +6991,19 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vue-codemirror@npm:6.0.0":
-  version: 6.0.0
-  resolution: "vue-codemirror@npm:6.0.0"
+"vue-codemirror@npm:6.0.2":
+  version: 6.0.2
+  resolution: "vue-codemirror@npm:6.0.2"
   dependencies:
     "@codemirror/commands": 6.x
     "@codemirror/language": 6.x
     "@codemirror/state": 6.x
     "@codemirror/view": 6.x
-    csstype: ^2.6.8
+    csstype: ^3.1.0
   peerDependencies:
     codemirror: 6.x
     vue: 3.x
-  checksum: aa762ebfe8b8f9f43c98b76ba822842c79af2fc16be1b2639142d3c85d86f73376c1467d0372bdae1b5e73fd4b76405f23b0266d6e5c72505408677164505e0e
+  checksum: 77620c05c1d23ef8d5ab287fc6b28246a1b0c4317a5f23d457da742d7906890d2995e68d592439c552c9163ca2e63e34b6d077ba6059c0d2717a6ad9f57eac92
   languageName: node
   linkType: hard
 
@@ -7056,14 +7054,14 @@ __metadata:
   languageName: node
   linkType: hard
 
-"vue-router@npm:4.1.1":
-  version: 4.1.1
-  resolution: "vue-router@npm:4.1.1"
+"vue-router@npm:4.1.3":
+  version: 4.1.3
+  resolution: "vue-router@npm:4.1.3"
   dependencies:
     "@vue/devtools-api": ^6.1.4
   peerDependencies:
     vue: ^3.2.0
-  checksum: a521d9c8e225497ba3f0759b00fdb48fdbf5ed7a81b7f05558733aaecbc539e94180c0bbf336063c65b1c2cf5e3e93fced2ec79718dbadac082fa8150bbfb924
+  checksum: 92a827a05e3afd5cc6ff6a4d033abaefae098b60e4d128b402bebb88473ad8ca485e675a6f7df2a5598ba4a8a23a2652c0f7c55c2c57c4ac433ce75fb4672222
   languageName: node
   linkType: hard
 

Some files were not shown because too many files changed in this diff