浏览代码

feat: file manager improvements + admin user edit fixes

Nicolas Giard 2 年之前
父节点
当前提交
bfbb64a749

+ 49 - 0
server/graph/resolvers/tree.js

@@ -91,6 +91,9 @@ module.exports = {
     }
   },
   Mutation: {
+    /**
+     * CREATE FOLDER
+     */
     async createFolder (obj, args, context) {
       try {
         // Get parent path
@@ -138,6 +141,52 @@ module.exports = {
       } catch (err) {
         return graphHelper.generateError(err)
       }
+    },
+    /**
+     * DELETE FOLDER
+     */
+    async deleteFolder (obj, args, context) {
+      try {
+        // Get folder
+        const folder = await WIKI.db.knex('tree').where('id', args.folderId).first()
+        const folderPath = folder.folderPath ? `${folder.folderPath}.${folder.fileName}` : folder.fileName
+        WIKI.logger.debug(`Deleting folder ${folder.id} at path ${folderPath}...`)
+
+        // Delete all children
+        const deletedNodes = await WIKI.db.knex('tree').where('folderPath', '~', `${folderPath}.*`).del().returning(['id', 'type'])
+
+        // Delete folders
+        const deletedFolders = deletedNodes.filter(n => n.type === 'folder').map(n => n.id)
+        if (deletedFolders.length > 0) {
+          WIKI.logger.debug(`Deleted ${deletedFolders.length} children folders.`)
+        }
+
+        // Delete pages
+        const deletedPages = deletedNodes.filter(n => n.type === 'page').map(n => n.id)
+        if (deletedPages.length > 0) {
+          WIKI.logger.debug(`Deleting ${deletedPages.length} children pages...`)
+
+          // TODO: Delete page
+        }
+
+        // Delete assets
+        const deletedAssets = deletedNodes.filter(n => n.type === 'asset').map(n => n.id)
+        if (deletedAssets.length > 0) {
+          WIKI.logger.debug(`Deleting ${deletedPages.length} children assets...`)
+
+          // TODO: Delete asset
+        }
+
+        // Delete the folder itself
+        await WIKI.db.knex('tree').where('id', folder.id).del()
+        WIKI.logger.debug(`Deleting folder ${folder.id} successfully.`)
+
+        return {
+          operation: graphHelper.generateSuccess('Folder deleted successfully')
+        }
+      } catch (err) {
+        return graphHelper.generateError(err)
+      }
     }
   },
   TreeItem: {

+ 6 - 2
server/graph/resolvers/user.js

@@ -352,8 +352,12 @@ module.exports = {
           strategyKey: authStrategy.module,
           strategyIcon: authModule.icon,
           config: authStrategy.module === 'local' ? {
-            isTfaSetup: value.tfaSecret?.length > 0
-          } : {}
+            isPasswordSet: value.password?.length > 0,
+            isTfaSetup: value.tfaSecret?.length > 0,
+            isTfaRequired: value.tfaRequired ?? false,
+            mustChangePwd: value.mustChangePwd ?? false,
+            restrictLogin: value.restrictLogin ?? false
+          } : value
         })
       }, [])
     },

+ 1 - 1
server/graph/schemas/tree.graphql

@@ -76,7 +76,7 @@ type TreeItemPage {
   depth: Int
   fileName: String
   folderPath: String
-  pageEditor: String
+  editor: String
   pageType: String
   title: String
   updatedAt: Date

+ 1 - 0
ux/package.json

@@ -63,6 +63,7 @@
     "codemirror": "6.0.1",
     "filesize": "10.0.5",
     "filesize-parser": "1.5.0",
+    "fuse.js": "6.6.2",
     "graphql": "16.6.0",
     "graphql-tag": "2.12.6",
     "js-cookie": "3.0.1",

+ 13 - 0
ux/public/_assets/illustrations/fileman-page.svg

@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
+<svg width="100%" height="100%" viewBox="0 0 320 200" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:2;">
+    <rect x="0" y="0" width="320" height="200" style="fill:url(#_Linear1);"/>
+    <path d="M203.194,73.306L175.694,45.806C175.18,45.289 174.481,45 173.75,45L129.75,45C122.168,45 116,51.168 116,58.75L116,141.25C116,148.832 122.168,155 129.75,155L190.25,155C197.832,155 204,148.832 204,141.25L204,75.25C204,74.521 203.711,73.82 203.194,73.306Z" style="fill:rgb(107,227,162);fill-rule:nonzero;"/>
+    <path d="M204,78L184.75,78C177.168,78 171,71.832 171,64.25L171,45C171,43.482 172.229,42.25 173.75,42.25C175.271,42.25 176.5,43.482 176.5,45L176.5,64.25C176.5,68.798 180.202,72.5 184.75,72.5L204,72.5C205.521,72.5 206.75,73.732 206.75,75.25C206.75,76.768 205.521,78 204,78Z" style="fill:rgb(50,69,97);fill-rule:nonzero;"/>
+    <path d="M182,102.75L138,102.75C136.479,102.75 135.25,101.518 135.25,100C135.25,98.482 136.479,97.25 138,97.25L182,97.25C183.521,97.25 184.75,98.482 184.75,100C184.75,101.518 183.521,102.75 182,102.75Z" style="fill:rgb(50,69,97);fill-rule:nonzero;"/>
+    <path d="M182,116.5L138,116.5C136.479,116.5 135.25,115.268 135.25,113.75C135.25,112.232 136.479,111 138,111L182,111C183.521,111 184.75,112.232 184.75,113.75C184.75,115.268 183.521,116.5 182,116.5Z" style="fill:rgb(50,69,97);fill-rule:nonzero;"/>
+    <path d="M165.5,130.25L138,130.25C136.479,130.25 135.25,129.018 135.25,127.5C135.25,125.982 136.479,124.75 138,124.75L165.5,124.75C167.021,124.75 168.25,125.982 168.25,127.5C168.25,129.018 167.021,130.25 165.5,130.25Z" style="fill:rgb(50,69,97);fill-rule:nonzero;"/>
+    <defs>
+        <linearGradient id="_Linear1" x1="0" y1="0" x2="1" y2="0" gradientUnits="userSpaceOnUse" gradientTransform="matrix(320,100,-62.5,200,0,100)"><stop offset="0" style="stop-color:rgb(38,43,49);stop-opacity:1"/><stop offset="1" style="stop-color:rgb(22,27,33);stop-opacity:1"/></linearGradient>
+    </defs>
+</svg>

+ 143 - 29
ux/src/components/FileManager.vue

@@ -13,6 +13,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
         ref='searchField'
         style='width: 100%;'
         label='Search folder...'
+        :debounce='500'
         )
         template(v-slot:prepend)
           q-icon(name='las la-search')
@@ -51,7 +52,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
     .q-pa-md
       template(v-if='currentFileDetails')
         q-img.rounded-borders.q-mb-md(
-          src='https://picsum.photos/id/134/340/340'
+          src='/_assets/illustrations/fileman-page.svg'
           width='100%'
           fit='cover'
           :ratio='16/10'
@@ -129,13 +130,21 @@ q-layout.fileman(view='hHh lpR lFr', container)
                               size='xs'
                               )
                           q-item-section.q-pr-sm Browse Using Titles
-                  q-item(clickable)
+                  q-item(clickable, @click='state.isCompact = !state.isCompact')
                     q-item-section(side)
-                      q-icon(name='las la-stop', color='grey', size='xs')
+                      q-icon(
+                        :name='state.isCompact ? `las la-check-square` : `las la-stop`'
+                        :color='state.isCompact ? `positive` : `grey`'
+                        size='xs'
+                      )
                     q-item-section.q-pr-sm Compact List
-                  q-item(clickable)
+                  q-item(clickable, @click='state.shouldShowFolders = !state.shouldShowFolders')
                     q-item-section(side)
-                      q-icon(name='las la-check-square', color='positive', size='xs')
+                      q-icon(
+                        :name='state.shouldShowFolders ? `las la-check-square` : `las la-stop`'
+                        :color='state.shouldShowFolders ? `positive` : `grey`'
+                        size='xs'
+                      )
                     q-item-section.q-pr-sm Show Folders
           q-btn.q-mr-sm(
             flat
@@ -191,10 +200,10 @@ q-layout.fileman(view='hHh lpR lFr', container)
           @dblclick.native='openItem(item)'
           )
           q-item-section.fileman-filelist-icon(avatar)
-            q-icon(:name='item.icon', size='xl')
+            q-icon(:name='item.icon', :size='state.isCompact ? `md` : `xl`')
           q-item-section.fileman-filelist-label
             q-item-label {{item.title}}
-            q-item-label(caption) {{item.caption}}
+            q-item-label(caption, v-if='!state.isCompact') {{item.caption}}
           q-item-section.fileman-filelist-side(side, v-if='item.side')
             .text-caption {{item.side}}
           //- RIGHT-CLICK MENU
@@ -211,11 +220,11 @@ q-layout.fileman(view='hHh lpR lFr', container)
                   q-item-section(side)
                     q-icon(name='las la-edit', color='orange')
                   q-item-section Edit
-                q-item(clickable, v-if='item.type !== `folder`')
+                q-item(clickable, v-if='item.type !== `folder`', @click='openItem(item)')
                   q-item-section(side)
                     q-icon(name='las la-eye', color='primary')
                   q-item-section View
-                q-item(clickable, v-if='item.type !== `folder`')
+                q-item(clickable, v-if='item.type !== `folder`', @click='copyItemURL(item)')
                   q-item-section(side)
                     q-icon(name='las la-clipboard', color='primary')
                   q-item-section Copy URL
@@ -231,7 +240,7 @@ q-layout.fileman(view='hHh lpR lFr', container)
                   q-item-section(side)
                     q-icon(name='las la-arrow-right', color='teal')
                   q-item-section Move to...
-                q-item(clickable)
+                q-item(clickable, @click='delItem(item)')
                   q-item-section(side)
                     q-icon(name='las la-trash-alt', color='negative')
                   q-item-section.text-negative Delete
@@ -255,7 +264,9 @@ import { filesize } from 'filesize'
 import { useQuasar } from 'quasar'
 import { DateTime } from 'luxon'
 import { cloneDeep, find } from 'lodash-es'
+import { useRoute, useRouter } from 'vue-router'
 import gql from 'graphql-tag'
+import Fuse from 'fuse.js/dist/fuse.basic.esm'
 
 import NewMenu from './PageNewMenu.vue'
 import Tree from './TreeNav.vue'
@@ -277,6 +288,11 @@ const $q = useQuasar()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
 
+// ROUTER
+
+const router = useRouter()
+const route = useRoute()
+
 // I18N
 
 const { t } = useI18n()
@@ -291,6 +307,8 @@ const state = reactive({
   treeNodes: {},
   treeRoots: [],
   displayMode: 'title',
+  isCompact: false,
+  shouldShowFolders: true,
   isUploading: false,
   shouldCancelUpload: false,
   uploadPercentage: 0,
@@ -314,8 +332,29 @@ const folderPath = computed(() => {
   }
 })
 
+const filteredFiles = computed(() => {
+  if (state.search) {
+    const fuse = new Fuse(state.fileList, {
+      keys: [
+        'title',
+        'fileName'
+      ]
+    })
+    return fuse.search(state.search).map(n => n.item)
+  } else {
+    return state.fileList
+  }
+})
+
 const files = computed(() => {
-  return state.fileList.map(f => {
+  return filteredFiles.value.filter(f => {
+    console.info(f)
+    // -> Show Folders Filter
+    if (f.type === 'folder' && !state.shouldShowFolders) {
+      return false
+    }
+    return true
+  }).map(f => {
     switch (f.type) {
       case 'folder': {
         f.icon = fileTypes.folder.icon
@@ -413,10 +452,9 @@ async function treeLazyLoad (nodeId, { done, fail }) {
   done()
 }
 
-async function loadTree (parentId, types, noCache = false) {
+async function loadTree (parentId, types) {
   if (!parentId) {
     parentId = null
-    state.treeRoots = []
   }
   if (parentId === state.currentFolderId) {
     state.fileListLoading = true
@@ -451,7 +489,7 @@ async function loadTree (parentId, types, noCache = false) {
               title
               createdAt
               updatedAt
-              pageEditor
+              editor
             }
             ... on TreeItemAsset {
               id
@@ -479,22 +517,21 @@ async function loadTree (parentId, types, noCache = false) {
         switch (item.__typename) {
           case 'TreeItemFolder': {
             // -> Tree Nodes
-            if (!state.treeNodes[item.id] || (parentId && !treeComp.value.isLoaded(item.id))) {
+            if (!state.treeNodes[item.id]) {
               state.treeNodes[item.id] = {
                 folderPath: item.folderPath,
                 fileName: item.fileName,
                 title: item.title,
-                children: []
-              }
-              if (item.folderPath) {
-                if (!state.treeNodes[parentId].children.includes(item.id)) {
-                  state.treeNodes[parentId].children.push(item.id)
-                }
+                children: state.treeNodes[item.id]?.children ?? []
               }
             }
 
-            // -> Set Tree Roots
-            if (!item.folderPath) {
+            // -> Set Ancestors / Tree Roots
+            if (item.folderPath) {
+              if (!state.treeNodes[parentId].children.includes(item.id)) {
+                state.treeNodes[parentId].children.push(item.id)
+              }
+            } else {
               newTreeRoots.push(item.id)
             }
 
@@ -504,6 +541,7 @@ async function loadTree (parentId, types, noCache = false) {
                 id: item.id,
                 type: 'folder',
                 title: item.title,
+                fileName: item.fileName,
                 children: 0
               })
             }
@@ -516,7 +554,9 @@ async function loadTree (parentId, types, noCache = false) {
                 type: 'asset',
                 title: item.title,
                 fileType: 'pdf',
-                fileSize: 19000
+                fileSize: 19000,
+                folderPath: item.folderPath,
+                fileName: item.fileName
               })
             }
             break
@@ -528,7 +568,9 @@ async function loadTree (parentId, types, noCache = false) {
                 type: 'page',
                 title: item.title,
                 pageType: 'markdown',
-                updatedAt: '2022-11-24T18:27:00Z'
+                updatedAt: '2022-11-24T18:27:00Z',
+                folderPath: item.folderPath,
+                fileName: item.fileName
               })
             }
             break
@@ -551,6 +593,9 @@ async function loadTree (parentId, types, noCache = false) {
       state.fileListLoading = false
     })
   }
+  if (parentId) {
+    treeComp.value.setLoaded(parentId)
+  }
 }
 
 function treeContextAction (nodeId, action) {
@@ -566,6 +611,10 @@ function treeContextAction (nodeId, action) {
   }
 }
 
+// --------------------------------------
+// FOLDER METHODS
+// --------------------------------------
+
 function newFolder (parentId) {
   $q.dialog({
     component: FolderCreateDialog,
@@ -577,7 +626,7 @@ function newFolder (parentId) {
   })
 }
 
-function delFolder (folderId) {
+function delFolder (folderId, mustReload = false) {
   $q.dialog({
     component: FolderDeleteDialog,
     componentProps: {
@@ -591,15 +640,23 @@ function delFolder (folderId) {
       }
     }
     delete state.treeNodes[folderId]
+    if (state.treeRoots.includes(folderId)) {
+      state.treeRoots = state.treeRoots.filter(n => n !== folderId)
+    }
+    if (mustReload) {
+      loadTree(state.currentFolderId, null)
+    }
   })
 }
 
 function reloadFolder (folderId) {
-  loadTree(folderId, null, true)
+  loadTree(folderId, null)
   treeComp.value.resetLoaded()
 }
 
-// -> Upload Methods
+// --------------------------------------
+// UPLOAD METHODS
+// --------------------------------------
 
 function uploadFile () {
   fileIpt.value.click()
@@ -678,6 +735,10 @@ function uploadCancel () {
   state.uploadPercentage = 0
 }
 
+// --------------------------------------
+// ITEM LIST ACTIONS
+// --------------------------------------
+
 function selectItem (item) {
   if (item.type === 'folder') {
     state.currentFolderId = item.id
@@ -688,7 +749,60 @@ function selectItem (item) {
 }
 
 function openItem (item) {
-  console.info(item.id)
+  switch (item.type) {
+    case 'folder': {
+      return
+    }
+    case 'page': {
+      const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
+      router.push(`/${pagePath}`)
+      close()
+      break
+    }
+    case 'asset': {
+      // TODO: Open asset
+      close()
+      break
+    }
+  }
+}
+
+async function copyItemURL (item) {
+  try {
+    switch (item.type) {
+      case 'page': {
+        const pagePath = item.folderPath ? `${item.folderPath}/${item.fileName}` : item.fileName
+        await navigator.clipboard.writeText(`${window.location.origin}/${pagePath}`)
+        break
+      }
+      case 'asset': {
+        // TODO: Copy asset URL to clibpard
+        break
+      }
+      default: {
+        throw new Error('Invalid Item Type')
+      }
+    }
+    $q.notify({
+      type: 'positive',
+      message: t('fileman.copyURLSuccess')
+    })
+  } catch (err) {
+    $q.notify({
+      type: 'negative',
+      message: 'Failed to copy URL to clipboard.',
+      caption: err.message
+    })
+  }
+}
+
+function delItem (item) {
+  switch (item.type) {
+    case 'folder': {
+      delFolder(item.id, true)
+      break
+    }
+  }
 }
 
 // MOUNTED

+ 2 - 0
ux/src/components/FolderCreateDialog.vue

@@ -19,6 +19,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             lazy-rules='ondemand'
             autofocus
             ref='iptTitle'
+            @keyup.enter='create'
             )
       q-item
         blueprint-icon.self-start(icon='file-submodule')
@@ -34,6 +35,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
             :hint='t(`fileman.folderFileNameHint`)'
             lazy-rules='ondemand'
             @focus='state.pathDirty = true'
+            @keyup.enter='create'
             )
     q-card-actions.card-actions
       q-space

+ 6 - 2
ux/src/components/TreeNav.vue

@@ -126,8 +126,11 @@ function setOpened (nodeId) {
 function isLoaded (nodeId) {
   return state.loaded[nodeId]
 }
-function resetLoaded (nodeId) {
-  state.loaded[nodeId] = false
+function setLoaded (nodeId, value) {
+  state.loaded[nodeId] = value
+}
+function resetLoaded () {
+  state.loaded = {}
 }
 
 // PROVIDE
@@ -146,6 +149,7 @@ provide('emitContextAction', emitContextAction)
 defineExpose({
   setOpened,
   isLoaded,
+  setLoaded,
   resetLoaded
 })
 

+ 33 - 31
ux/src/components/UserEditOverlay.vue

@@ -38,17 +38,20 @@ q-layout(view='hHh lpR fFf', container)
       )
   q-drawer.bg-dark-6(:model-value='true', :width='250', dark)
     q-list(padding, v-if='state.loading < 1')
-      q-item(
+      template(
         v-for='sc of sections'
         :key='`section-` + sc.key'
-        clickable
-        :to='{ params: { section: sc.key } }'
-        active-class='bg-primary text-white'
-        :disabled='sc.disabled'
         )
-        q-item-section(side)
-          q-icon(:name='sc.icon', color='white')
-        q-item-section {{sc.text}}
+        q-item(
+          v-if='!sc.disabled || flagsStore.experimental'
+          clickable
+          :to='{ params: { section: sc.key } }'
+          active-class='bg-primary text-white'
+          :disabled='sc.disabled'
+          )
+          q-item-section(side)
+            q-icon(:name='sc.icon', color='white')
+          q-item-section {{sc.text}}
   q-page-container
     q-page(v-if='state.loading > 0')
       .flex.q-pa-lg.items-center
@@ -268,7 +271,7 @@ q-layout(view='hHh lpR fFf', container)
                 q-item-section
                   q-item-label {{t(`admin.users.changePassword`)}}
                   q-item-label(caption) {{t(`admin.users.changePasswordHint`)}}
-                  q-item-label(caption): strong(:class='localAuth.password ? `text-positive` : `text-negative`') {{localAuth.password ? t(`admin.users.pwdSet`) : t(`admin.users.pwdNotSet`)}}
+                  q-item-label(caption): strong(:class='localAuth.isPasswordSet ? `text-positive` : `text-negative`') {{localAuth.isPasswordSet ? t(`admin.users.pwdSet`) : t(`admin.users.pwdNotSet`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
@@ -316,7 +319,7 @@ q-layout(view='hHh lpR fFf', container)
                   q-item-label(caption) {{t(`admin.users.tfaRequiredHint`)}}
                 q-item-section(avatar)
                   q-toggle(
-                    v-model='localAuth.tfaRequired'
+                    v-model='localAuth.isTfaRequired'
                     color='primary'
                     checked-icon='las la-check'
                     unchecked-icon='las la-times'
@@ -328,7 +331,7 @@ q-layout(view='hHh lpR fFf', container)
                 q-item-section
                   q-item-label {{t(`admin.users.tfaInvalidate`)}}
                   q-item-label(caption) {{t(`admin.users.tfaInvalidateHint`)}}
-                  q-item-label(caption): strong(:class='localAuth.tfaSecret ? `text-positive` : `text-negative`') {{localAuth.tfaSecret ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)}}
+                  q-item-label(caption): strong(:class='localAuth.isTfaSetup ? `text-positive` : `text-negative`') {{localAuth.isTfaSetup ? t(`admin.users.tfaSet`) : t(`admin.users.tfaNotSet`)}}
                 q-item-section(side)
                   q-btn.acrylic-btn(
                     flat
@@ -348,14 +351,14 @@ q-layout(view='hHh lpR fFf', container)
                   ) {{t('admin.users.noLinkedProviders')}}
               template(
                 v-for='(prv, idx) in linkedAuthProviders'
-                :key='prv._id'
+                :key='prv.authId'
                 )
                 q-separator.q-my-sm(inset, v-if='idx > 0')
                 q-item
-                  blueprint-icon(icon='google', :hue-rotate='-45')
+                  blueprint-icon(:icon='prv.strategyIcon', :hue-rotate='-45')
                   q-item-section
-                    q-item-label {{prv._moduleName}}
-                    q-item-label(caption) {{prv.key}}
+                    q-item-label {{prv.authName}}
+                    q-item-label(caption) {{prv.config.key}}
 
     q-page(v-else-if='route.params.section === `groups`')
       .q-pa-md
@@ -506,7 +509,7 @@ q-layout(view='hHh lpR fFf', container)
 
 <script setup>
 import gql from 'graphql-tag'
-import { cloneDeep, find, findKey, map, some } from 'lodash-es'
+import { cloneDeep, find, map, some } from 'lodash-es'
 import { DateTime } from 'luxon'
 
 import { useI18n } from 'vue-i18n'
@@ -515,6 +518,7 @@ import { computed, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'
+import { useFlagsStore } from 'src/stores/flags'
 
 import UserChangePwdDialog from './UserChangePwdDialog.vue'
 import UtilCodeEditor from './UtilCodeEditor.vue'
@@ -526,6 +530,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const flagsStore = useFlagsStore()
 
 // ROUTER
 
@@ -553,7 +558,7 @@ const state = reactive({
 
 const sections = [
   { key: 'overview', text: t('admin.users.overview'), icon: 'las la-user' },
-  { key: 'activity', text: t('admin.users.activity'), icon: 'las la-chart-area' },
+  { key: 'activity', text: t('admin.users.activity'), icon: 'las la-chart-area', disabled: true },
   { key: 'auth', text: t('admin.users.auth'), icon: 'las la-key' },
   { key: 'groups', text: t('admin.users.groups'), icon: 'las la-users' },
   { key: 'metadata', text: t('admin.users.metadata'), icon: 'las la-clipboard-list' },
@@ -576,17 +581,13 @@ const metadata = computed({
   }
 })
 
-const localAuthId = computed(() => {
-  return findKey(state.user.auth, ['module', 'local'])
-})
-
 const localAuth = computed({
   get () {
-    return localAuthId.value ? state.user.auth?.[localAuthId.value] || {} : {}
+    return find(state.user?.auth, ['strategyKey', 'local'])?.config ?? {}
   },
   set (val) {
-    if (localAuthId.value) {
-      state.user.auth[localAuthId.value] = val
+    if (localAuth.value.authId) {
+      find(state.user.auth, ['strategyKey', 'local']).config = val
     }
   }
 })
@@ -594,12 +595,7 @@ const localAuth = computed({
 const linkedAuthProviders = computed(() => {
   if (!state.user?.auth) { return [] }
 
-  return map(state.user.auth, (obj, key) => {
-    return {
-      ...obj,
-      _id: key
-    }
-  }).filter(prv => prv.module !== 'local')
+  return state.user.auth.filter(prv => prv.strategyKey !== 'local')
 })
 
 // WATCHERS
@@ -630,7 +626,13 @@ async function fetchUser () {
             isSystem
             isVerified
             isActive
-            auth
+            auth {
+              authId
+              authName
+              strategyKey
+              strategyIcon
+              config
+            }
             meta
             prefs
             lastLoginAt

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

@@ -1610,5 +1610,6 @@
   "admin.flags.experimental.hint": "Enable unstable / unfinished features. DO NOT enable in a production environment!",
   "admin.flags.advanced.label": "Custom Configuration",
   "admin.flags.advanced.hint": "Set custom configuration flags. Note that all values are public to all users! Do not insert senstive data.",
-  "admin.flags.saveSuccess": "Flags have been updated successfully."
+  "admin.flags.saveSuccess": "Flags have been updated successfully.",
+  "fileman.copyURLSuccess": "URL has been copied to the clipboard."
 }

+ 28 - 25
ux/src/layouts/AdminLayout.vue

@@ -70,26 +70,27 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-web.svg')
           q-item-section {{ t('admin.general.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/analytics`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-bar-chart.svg')
-          q-item-section {{ t('admin.analytics.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/approvals`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-inspection.svg')
-          q-item-section {{ t('admin.approval.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/comments`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-comments.svg')
-          q-item-section {{ t('admin.comments.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
-          q-item-section {{ t('admin.blocks.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/editors`', v-ripple, active-class='bg-primary text-white', disabled)
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-cashbook.svg')
-          q-item-section {{ t('admin.editors.title') }}
+        template(v-if='flagsStore.experimental')
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/analytics`', v-ripple, active-class='bg-primary text-white', disabled)
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-bar-chart.svg')
+            q-item-section {{ t('admin.analytics.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/approvals`', v-ripple, active-class='bg-primary text-white', disabled)
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-inspection.svg')
+            q-item-section {{ t('admin.approval.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/comments`', v-ripple, active-class='bg-primary text-white', disabled)
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-comments.svg')
+            q-item-section {{ t('admin.comments.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/blocks`', v-ripple, active-class='bg-primary text-white', disabled)
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-rfid-tag.svg')
+            q-item-section {{ t('admin.blocks.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/editors`', v-ripple, active-class='bg-primary text-white', disabled)
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-cashbook.svg')
+            q-item-section {{ t('admin.editors.title') }}
         q-item(:to='`/_admin/` + adminStore.currentSiteId + `/locale`', v-ripple, active-class='bg-primary text-white')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-language.svg')
@@ -134,7 +135,7 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section {{ t('admin.api.title') }}
           q-item-section(side)
             status-light(:color='adminStore.info.isApiEnabled ? `positive` : `negative`')
-        q-item(to='/_admin/audit', v-ripple, active-class='bg-primary text-white', disabled)
+        q-item(to='/_admin/audit', v-ripple, active-class='bg-primary text-white', disabled, v-if='flagsStore.experimental')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-event-log.svg')
           q-item-section {{ t('admin.audit.title') }}
@@ -156,7 +157,7 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section {{ t('admin.mail.title') }}
           q-item-section(side)
             status-light(:color='adminStore.info.isMailConfigured ? `positive` : `warning`')
-        q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white', disabled)
+        q-item(to='/_admin/rendering', v-ripple, active-class='bg-primary text-white', disabled, v-if='flagsStore.experimental')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
           q-item-section {{ t('admin.rendering.title') }}
@@ -170,7 +171,7 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-protect.svg')
           q-item-section {{ t('admin.security.title') }}
-        q-item(to='/_admin/ssl', v-ripple, active-class='bg-primary text-white', disabled)
+        q-item(to='/_admin/ssl', v-ripple, active-class='bg-primary text-white', disabled, v-if='flagsStore.experimental')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-security-ssl.svg')
           q-item-section {{ t('admin.ssl.title') }}
@@ -218,8 +219,9 @@ import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 
-import { useAdminStore } from '../stores/admin'
-import { useSiteStore } from '../stores/site'
+import { useAdminStore } from 'src/stores/admin'
+import { useFlagsStore } from 'src/stores/flags'
+import { useSiteStore } from 'src/stores/site'
 
 // COMPONENTS
 
@@ -237,6 +239,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 
 // ROUTER

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

@@ -234,6 +234,7 @@ q-page.column
         aria-label='Page Data'
         @click='togglePageData'
         disable
+        v-if='flagsStore.experimental'
         )
         q-tooltip(anchor='center left' self='center right') Page Data
       q-separator.q-my-sm(inset)
@@ -322,6 +323,7 @@ import { useI18n } from 'vue-i18n'
 import { DateTime } from 'luxon'
 
 import { useEditorStore } from 'src/stores/editor'
+import { useFlagsStore } from 'src/stores/flags'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
 
@@ -349,6 +351,7 @@ const $q = useQuasar()
 // STORES
 
 const editorStore = useEditorStore()
+const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
 
@@ -702,6 +705,7 @@ async function saveChanges () {
     width: 40px;
     border-radius: 4px !important;
     background-color: rgba(0,0,0,.75);
+    backdrop-filter: blur(5px);
     color: #FFF;
     position: fixed;
     right: 486px;

+ 8 - 0
ux/yarn.lock

@@ -4127,6 +4127,13 @@ __metadata:
   languageName: node
   linkType: hard
 
+"fuse.js@npm:6.6.2":
+  version: 6.6.2
+  resolution: "fuse.js@npm:6.6.2"
+  checksum: 17ae758ce205276ebd88bd9c9f088a100be0b4896abac9f6b09847151269d1690f41d7f98ff5813d4a58973162dbd99d0072ce807020fee6f9de60170f6b08eb
+  languageName: node
+  linkType: hard
+
 "gauge@npm:^4.0.3":
   version: 4.0.4
   resolution: "gauge@npm:4.0.4"
@@ -7179,6 +7186,7 @@ __metadata:
     eslint-plugin-vue: 9.7.0
     filesize: 10.0.5
     filesize-parser: 1.5.0
+    fuse.js: 6.6.2
     graphql: 16.6.0
     graphql-tag: 2.12.6
     js-cookie: 3.0.1