소스 검색

feat: update permission system + dark theme fixes + logout

NGPixel 2 년 전
부모
커밋
960a8a03b2
45개의 변경된 파일631개의 추가작업 그리고 424개의 파일을 삭제
  1. 1 1
      server/core/kernel.mjs
  2. 1 1
      server/db/migrations/3.0.0.mjs
  3. 13 1
      server/graph/resolvers/user.mjs
  4. 7 0
      server/graph/schemas/user.graphql
  5. 2 2
      server/models/authentication.mjs
  6. 83 102
      server/models/users.mjs
  7. 1 1
      server/modules/authentication/local/authentication.mjs
  8. 1 15
      server/package-lock.json
  9. 1 1
      server/package.json
  10. 25 1
      ux/src/App.vue
  11. 1 1
      ux/src/components/AccountMenu.vue
  12. 8 15
      ux/src/components/GroupEditOverlay.vue
  13. 20 1
      ux/src/components/HeaderNav.vue
  14. 35 24
      ux/src/components/PageActionsCol.vue
  15. 38 11
      ux/src/components/PageHeader.vue
  16. 25 0
      ux/src/css/app.scss
  17. 1 1
      ux/src/css/page-contents.scss
  18. 2 0
      ux/src/i18n/locales/en.json
  19. 168 143
      ux/src/layouts/AdminLayout.vue
  20. 8 7
      ux/src/layouts/MainLayout.vue
  21. 1 1
      ux/src/layouts/ProfileLayout.vue
  22. 2 2
      ux/src/pages/AdminAuth.vue
  23. 14 6
      ux/src/pages/AdminDashboard.vue
  24. 1 1
      ux/src/pages/AdminEditors.vue
  25. 1 1
      ux/src/pages/AdminExtensions.vue
  26. 2 2
      ux/src/pages/AdminFlags.vue
  27. 8 8
      ux/src/pages/AdminGeneral.vue
  28. 1 1
      ux/src/pages/AdminGroups.vue
  29. 1 1
      ux/src/pages/AdminIcons.vue
  30. 1 1
      ux/src/pages/AdminInstances.vue
  31. 2 2
      ux/src/pages/AdminLocale.vue
  32. 2 2
      ux/src/pages/AdminLogin.vue
  33. 5 5
      ux/src/pages/AdminMail.vue
  34. 3 3
      ux/src/pages/AdminScheduler.vue
  35. 5 5
      ux/src/pages/AdminSecurity.vue
  36. 1 1
      ux/src/pages/AdminSites.vue
  37. 7 7
      ux/src/pages/AdminStorage.vue
  38. 9 4
      ux/src/pages/AdminSystem.vue
  39. 1 1
      ux/src/pages/AdminTerminal.vue
  40. 5 10
      ux/src/pages/AdminTheme.vue
  41. 2 2
      ux/src/pages/AdminUsers.vue
  42. 1 1
      ux/src/pages/AdminUtilities.vue
  43. 14 2
      ux/src/pages/Index.vue
  44. 35 27
      ux/src/stores/page.js
  45. 66 0
      ux/src/stores/user.js

+ 1 - 1
server/core/kernel.mjs

@@ -39,7 +39,7 @@ export default {
    */
   async preBootWeb() {
     try {
-      WIKI.cache = new NodeCache()
+      WIKI.cache = new NodeCache({ checkperiod: 0 })
       WIKI.scheduler = await scheduler.init()
       WIKI.servers = servers
       WIKI.events = {

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

@@ -1,5 +1,5 @@
 import { v4 as uuid } from 'uuid'
-import bcrypt from 'bcryptjs-then'
+import bcrypt from 'bcryptjs'
 import crypto from 'node:crypto'
 import { DateTime } from 'luxon'
 import { pem2jwk } from 'pem-jwk'

+ 13 - 1
server/graph/resolvers/user.mjs

@@ -85,12 +85,24 @@ export default {
         .whereNotNull('lastLoginAt')
         .orderBy('lastLoginAt', 'desc')
         .limit(10)
+    },
+
+    async userPermissions (obj, args, context) {
+      if (!context.req.user || context.req.user.id === WIKI.auth.guest.id) {
+        throw new WIKI.Error.AuthRequired()
+      }
+
+      const currentUser = await WIKI.db.users.getById(context.req.user.id)
+      return currentUser.getPermissions()
+    },
+    async userPermissionsAtPath (obj, args, context) {
+      return []
     }
   },
   Mutation: {
     async createUser (obj, args) {
       try {
-        await WIKI.db.users.createNewUser({ ...args, passwordRaw: args.password, isVerified: true })
+        await WIKI.db.users.createNewUser({ ...args, isVerified: true })
 
         return {
           operation: generateSuccess('User created successfully')

+ 7 - 0
server/graph/schemas/user.graphql

@@ -17,6 +17,13 @@ extend type Query {
   ): User
 
   lastLogins: [UserLastLogin]
+
+  userPermissions: [String]
+
+  userPermissionsAtPath(
+    siteId: UUID!
+    path: String!
+  ): [String]
 }
 
 extend type Mutation {

+ 2 - 2
server/models/authentication.mjs

@@ -29,8 +29,8 @@ export class Authentication extends Model {
     return ['config', 'domainWhitelist', 'autoEnrollGroups']
   }
 
-  static async getStrategy(key) {
-    return WIKI.db.authentication.query().findOne({ key })
+  static async getStrategy(module) {
+    return WIKI.db.authentication.query().findOne({ module })
   }
 
   static async getStrategies({ enabledOnly = false } = {}) {

+ 83 - 102
server/models/users.mjs

@@ -6,12 +6,11 @@ import jwt from 'jsonwebtoken'
 import { Model } from 'objection'
 import validate from 'validate.js'
 import qr from 'qr-image'
+import bcrypt from 'bcryptjs'
 
 import { Group } from './groups.mjs'
 import { Locale } from './locales.mjs'
 
-const bcryptRegexp = /^\$2[ayb]\$[0-9]{2}\$[A-Za-z0-9./]{53}$/
-
 /**
  * Users model
  */
@@ -70,18 +69,12 @@ export class User extends Model {
     await super.$beforeUpdate(opt, context)
 
     this.updatedAt = new Date().toISOString()
-
-    if (!(opt.patch && this.password === undefined)) {
-      await this.generateHash()
-    }
   }
   async $beforeInsert(context) {
     await super.$beforeInsert(context)
 
     this.createdAt = new Date().toISOString()
     this.updatedAt = new Date().toISOString()
-
-    await this.generateHash()
   }
 
   // ------------------------------------------------
@@ -524,116 +517,104 @@ export class User extends Model {
    *
    * @param {Object} param0 User Fields
    */
-  static async createNewUser ({ providerKey, email, passwordRaw, name, groups, mustChangePassword, sendWelcomeEmail }) {
+  static async createNewUser ({ email, password, name, groups, mustChangePassword = false, sendWelcomeEmail = false }) {
     // Input sanitization
-    email = email.toLowerCase()
+    email = email.toLowerCase().trim()
 
     // Input validation
-    let validation = null
-    if (providerKey === 'local') {
-      validation = validate({
-        email,
-        passwordRaw,
-        name
-      }, {
-        email: {
-          email: true,
-          length: {
-            maximum: 255
-          }
-        },
-        passwordRaw: {
-          presence: {
-            allowEmpty: false
-          },
-          length: {
-            minimum: 6
-          }
+    const validation = validate({
+      email,
+      password,
+      name
+    }, {
+      email: {
+        email: true,
+        length: {
+          maximum: 255
+        }
+      },
+      password: {
+        presence: {
+          allowEmpty: false
         },
-        name: {
-          presence: {
-            allowEmpty: false
-          },
-          length: {
-            minimum: 2,
-            maximum: 255
-          }
+        length: {
+          minimum: 6
         }
-      }, { format: 'flat' })
-    } else {
-      validation = validate({
-        email,
-        name
-      }, {
-        email: {
-          email: true,
-          length: {
-            maximum: 255
-          }
+      },
+      name: {
+        presence: {
+          allowEmpty: false
         },
-        name: {
-          presence: {
-            allowEmpty: false
-          },
-          length: {
-            minimum: 2,
-            maximum: 255
-          }
+        length: {
+          minimum: 2,
+          maximum: 255
         }
-      }, { format: 'flat' })
-    }
+      }
+    }, { format: 'flat' })
 
     if (validation && validation.length > 0) {
-      throw new WIKI.Error.InputInvalid(validation[0])
+      throw new Error(`ERR_INVALID_INPUT: ${validation[0]}`)
     }
 
     // Check if email already exists
-    const usr = await WIKI.db.users.query().findOne({ email, providerKey })
-    if (!usr) {
-      // Create the account
-      let newUsrData = {
-        providerKey,
-        email,
-        name,
-        locale: 'en',
-        defaultEditor: 'markdown',
-        tfaIsActive: false,
-        isSystem: false,
-        isActive: true,
-        isVerified: true,
-        mustChangePwd: false
-      }
+    const usr = await WIKI.db.users.query().findOne({ email })
+    if (usr) {
+      throw new Error('ERR_ACCOUNT_ALREADY_EXIST')
+    }
 
-      if (providerKey === `local`) {
-        newUsrData.password = passwordRaw
-        newUsrData.mustChangePwd = (mustChangePassword === true)
+    // Create the account
+    const localAuth = await WIKI.db.authentication.getStrategy('local')
+    const newUsr = await WIKI.db.users.query().insert({
+      email,
+      name,
+      auth: {
+        [localAuth.id]: {
+          password: await bcrypt.hash(password, 12),
+          mustChangePwd: mustChangePassword,
+          restrictLogin: false,
+          tfaRequired: false,
+          tfaSecret: ''
+        }
+      },
+      localeCode: 'en',
+      hasAvatar: false,
+      isSystem: false,
+      isActive: true,
+      isVerified: true,
+      meta: {
+        jobTitle: '',
+        location: '',
+        pronouns: ''
+      },
+      prefs: {
+        cvd: 'none',
+        timezone: 'America/New_York',
+        appearance: 'site',
+        dateFormat: 'YYYY-MM-DD',
+        timeFormat: '12h'
       }
+    })
 
-      const newUsr = await WIKI.db.users.query().insert(newUsrData)
+    // Assign to group(s)
+    if (groups.length > 0) {
+      await newUsr.$relatedQuery('groups').relate(groups)
+    }
 
-      // Assign to group(s)
-      if (groups.length > 0) {
-        await newUsr.$relatedQuery('groups').relate(groups)
-      }
-
-      if (sendWelcomeEmail) {
-        // Send welcome email
-        await WIKI.mail.send({
-          template: 'accountWelcome',
-          to: email,
-          subject: `Welcome to the wiki ${WIKI.config.title}`,
-          data: {
-            preheadertext: `You've been invited to the wiki ${WIKI.config.title}`,
-            title: `You've been invited to the wiki ${WIKI.config.title}`,
-            content: `Click the button below to access the wiki.`,
-            buttonLink: `${WIKI.config.host}/login`,
-            buttonText: 'Login'
-          },
-          text: `You've been invited to the wiki ${WIKI.config.title}: ${WIKI.config.host}/login`
-        })
-      }
-    } else {
-      throw new WIKI.Error.AuthAccountAlreadyExists()
+    if (sendWelcomeEmail) {
+      // Send welcome email
+      await WIKI.mail.send({
+        template: 'accountWelcome',
+        to: email,
+        subject: `Welcome to the wiki ${WIKI.config.title}`,
+        data: {
+          preheadertext: `You've been invited to the wiki ${WIKI.config.title}`,
+          title: `You've been invited to the wiki ${WIKI.config.title}`,
+          content: `Click the button below to access the wiki.`,
+          buttonLink: `${WIKI.config.host}/login`,
+          buttonText: 'Login'
+        },
+        text: `You've been invited to the wiki ${WIKI.config.title}: ${WIKI.config.host}/login`
+      })
     }
   }
 

+ 1 - 1
server/modules/authentication/local/authentication.mjs

@@ -1,5 +1,5 @@
 /* global WIKI */
-import bcrypt from 'bcryptjs-then'
+import bcrypt from 'bcryptjs'
 
 // ------------------------------------
 // Local Account

+ 1 - 15
server/package-lock.json

@@ -24,7 +24,7 @@
         "apollo-server-express": "3.6.7",
         "auto-load": "3.0.4",
         "aws-sdk": "2.1353.0",
-        "bcryptjs-then": "1.0.1",
+        "bcryptjs": "2.4.3",
         "body-parser": "1.20.2",
         "chalk": "5.2.0",
         "cheerio": "1.0.0-rc.12",
@@ -1356,11 +1356,6 @@
         "url": "https://github.com/chalk/ansi-styles?sponsor=1"
       }
     },
-    "node_modules/any-promise": {
-      "version": "1.3.0",
-      "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz",
-      "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="
-    },
     "node_modules/anymatch": {
       "version": "3.1.3",
       "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
@@ -1917,15 +1912,6 @@
       "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz",
       "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ=="
     },
-    "node_modules/bcryptjs-then": {
-      "version": "1.0.1",
-      "resolved": "https://registry.npmjs.org/bcryptjs-then/-/bcryptjs-then-1.0.1.tgz",
-      "integrity": "sha512-TxMcXHT1pjB3ffitBr7vYw0Kg3GOs9YfSl6RWwk6Do5IcEpUduFzZr6/zwfxzCrlFxGw19YjKXyDOhERMY6jIQ==",
-      "dependencies": {
-        "any-promise": "^1.1.0",
-        "bcryptjs": "^2.3.0"
-      }
-    },
     "node_modules/binary-extensions": {
       "version": "2.2.0",
       "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.2.0.tgz",

+ 1 - 1
server/package.json

@@ -49,7 +49,7 @@
     "apollo-server-express": "3.6.7",
     "auto-load": "3.0.4",
     "aws-sdk": "2.1353.0",
-    "bcryptjs-then": "1.0.1",
+    "bcryptjs": "2.4.3",
     "body-parser": "1.20.2",
     "chalk": "5.2.0",
     "cheerio": "1.0.0-rc.12",

+ 25 - 1
ux/src/App.vue

@@ -9,6 +9,7 @@ import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
 import { useUserStore } from 'src/stores/user'
 import { setCssVar, useQuasar } from 'quasar'
+import { useI18n } from 'vue-i18n'
 
 import '@mdi/font/css/materialdesignicons.css'
 
@@ -24,6 +25,10 @@ const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
 const userStore = useUserStore()
 
+// I18N
+
+const { t } = useI18n()
+
 // ROUTER
 
 const router = useRouter()
@@ -94,14 +99,33 @@ router.beforeEach(async (to, from) => {
     console.info(`Refreshing user ${userStore.id} profile...`)
     await userStore.refreshProfile()
   }
-  // Apply Theme
+  // Page Permissions
+  await userStore.fetchPagePermissions(to.path)
+})
+
+// GLOBAL EVENTS HANDLERS
+
+EVENT_BUS.on('logout', () => {
+  router.push('/')
+  $q.notify({
+    type: 'positive',
+    icon: 'las la-sign-out-alt',
+    message: t('auth.logoutSuccess')
+  })
+})
+EVENT_BUS.on('applyTheme', () => {
   applyTheme()
 })
+
+// LOADER
+
 router.afterEach(() => {
   if (!state.isInitialized) {
     state.isInitialized = true
+    applyTheme()
     document.querySelector('.init-loading').remove()
   }
   siteStore.routerLoading = false
 })
+
 </script>

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

@@ -28,7 +28,7 @@ q-btn.q-ml-md(flat, round, dense, color='grey')
           :label='t(`common.header.logout`)'
           icon='las la-sign-out-alt'
           color='red'
-          href='/logout'
+          @click='userStore.logout()'
           no-caps
           )
   q-tooltip {{ t('common.header.account') }}

+ 8 - 15
ux/src/components/GroupEditOverlay.vue

@@ -584,50 +584,43 @@ const usersHeaders = [
 
 const permissions = [
   {
-    permission: 'write:users',
-    hint: 'Can create or authorize new users, but not modify existing ones',
+    permission: 'access:admin',
+    hint: 'Can access the administration area.',
     warning: false,
     restrictedForSystem: true,
     disabled: false
   },
   {
     permission: 'manage:users',
-    hint: 'Can manage all users (but not users with administrative permissions)',
-    warning: false,
-    restrictedForSystem: true,
-    disabled: false
-  },
-  {
-    permission: 'write:groups',
-    hint: 'Can manage groups and assign CONTENT permissions / page rules',
+    hint: 'Can create / manage users (but not users with administrative permissions)',
     warning: false,
     restrictedForSystem: true,
     disabled: false
   },
   {
     permission: 'manage:groups',
-    hint: 'Can manage groups and assign ANY permissions (but not manage:system) / page rules',
+    hint: 'Can create / manage groups and assign permissions (but not manage:system) / page rules',
     warning: true,
     restrictedForSystem: true,
     disabled: false
   },
   {
     permission: 'manage:navigation',
-    hint: 'Can manage the site navigation',
+    hint: 'Can manage site navigation',
     warning: false,
     restrictedForSystem: true,
     disabled: false
   },
   {
     permission: 'manage:theme',
-    hint: 'Can manage and modify themes',
+    hint: 'Can modify site theme settings',
     warning: false,
     restrictedForSystem: true,
     disabled: false
   },
   {
-    permission: 'manage:api',
-    hint: 'Can generate and revoke API keys',
+    permission: 'manage:sites',
+    hint: 'Can create / manage sites',
     warning: true,
     restrictedForSystem: true,
     disabled: false

+ 20 - 1
ux/src/components/HeaderNav.vue

@@ -73,6 +73,7 @@ q-header.bg-header.text-white.site-header(
           size='24px'
         )
       q-btn.q-ml-md(
+        v-if='userStore.can(`write:pages`)'
         flat
         round
         dense
@@ -83,6 +84,7 @@ q-header.bg-header.text-white.site-header(
         q-tooltip Create New Page
         new-menu
       q-btn.q-ml-md(
+        v-if='userStore.can(`browse:fileman`)'
         flat
         round
         dense
@@ -93,6 +95,7 @@ q-header.bg-header.text-white.site-header(
         )
         q-tooltip File Manager
       q-btn.q-ml-md(
+        v-if='userStore.can(`access:admin`)'
         flat
         round
         dense
@@ -102,7 +105,21 @@ q-header.bg-header.text-white.site-header(
         :aria-label='t(`common.header.admin`)'
         )
         q-tooltip {{ t('common.header.admin') }}
-      account-menu
+
+      //- USER BUTTON / DROPDOWN
+      account-menu(v-if='userStore.authenticated')
+      q-btn.q-ml-md(
+        v-else
+        flat
+        rounded
+        icon='las la-sign-in-alt'
+        color='white'
+        :label='$t(`common.actions.login`)'
+        :aria-label='$t(`common.actions.login`)'
+        to='/login'
+        padding='sm'
+        no-caps
+      )
 </template>
 
 <script setup>
@@ -114,6 +131,7 @@ import { useQuasar } from 'quasar'
 import { reactive } from 'vue'
 
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 // QUASAR
 
@@ -122,6 +140,7 @@ const $q = useQuasar()
 // STORES
 
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // I18N
 

+ 35 - 24
ux/src/components/PageActionsCol.vue

@@ -1,23 +1,39 @@
 <template lang="pug">
 .page-actions.column.items-stretch.order-last(:class='editorStore.isActive ? `is-editor` : ``')
+  template(v-if='userStore.can(`edit:pages`)')
+    q-btn.q-py-md(
+      flat
+      icon='las la-pen-nib'
+      :color='editorStore.isActive ? `white` : `deep-orange-9`'
+      aria-label='Page Properties'
+      @click='togglePageProperties'
+      )
+      q-tooltip(anchor='center left' self='center right') Page Properties
+    q-btn.q-py-md(
+      v-if='flagsStore.experimental'
+      flat
+      icon='las la-project-diagram'
+      :color='editorStore.isActive ? `white` : `deep-orange-9`'
+      aria-label='Page Data'
+      @click='togglePageData'
+      disable
+      )
+      q-tooltip(anchor='center left' self='center right') Page Data
+    q-separator.q-my-sm(inset)
   q-btn.q-py-md(
     flat
-    icon='las la-pen-nib'
-    :color='editorStore.isActive ? `white` : `deep-orange-9`'
-    aria-label='Page Properties'
-    @click='togglePageProperties'
+    icon='las la-history'
+    :color='editorStore.isActive ? `white` : `grey`'
+    aria-label='Page History'
     )
-    q-tooltip(anchor='center left' self='center right') Page Properties
+    q-tooltip(anchor='center left' self='center right') Page History
   q-btn.q-py-md(
     flat
-    icon='las la-project-diagram'
-    :color='editorStore.isActive ? `white` : `deep-orange-9`'
-    aria-label='Page Data'
-    @click='togglePageData'
-    disable
-    v-if='flagsStore.experimental'
+    icon='las la-code'
+    :color='editorStore.isActive ? `white` : `grey`'
+    aria-label='Page Source'
     )
-    q-tooltip(anchor='center left' self='center right') Page Data
+    q-tooltip(anchor='center left' self='center right') Page Source
   template(v-if='!(editorStore.isActive && editorStore.mode === `create`)')
     q-separator.q-my-sm(inset)
     q-btn.q-py-sm(
@@ -34,22 +50,12 @@
         transition-show='jump-left'
         )
         q-list(padding, style='min-width: 225px;')
-          q-item(clickable)
-            q-item-section.items-center(avatar)
-              q-icon(color='deep-orange-9', name='las la-history', size='sm')
-            q-item-section
-              q-item-label View History
-          q-item(clickable)
-            q-item-section.items-center(avatar)
-              q-icon(color='deep-orange-9', name='las la-code', size='sm')
-            q-item-section
-              q-item-label View Source
-          q-item(clickable)
+          q-item(clickable, v-if='userStore.can(`manage:pages`)')
             q-item-section.items-center(avatar)
               q-icon(color='deep-orange-9', name='las la-atom', size='sm')
             q-item-section
               q-item-label Convert Page
-          q-item(clickable)
+          q-item(clickable, v-if='userStore.can(`edit:pages`)')
             q-item-section.items-center(avatar)
               q-icon(color='deep-orange-9', name='las la-magic', size='sm')
             q-item-section
@@ -62,6 +68,7 @@
   q-space
   template(v-if='!(editorStore.isActive && editorStore.mode === `create`)')
     q-btn.q-py-sm(
+      v-if='userStore.can(`create:pages`)'
       flat
       icon='las la-copy'
       :color='editorStore.isActive ? `deep-orange-2` : `grey`'
@@ -70,6 +77,7 @@
       )
       q-tooltip(anchor='center left' self='center right') Duplicate Page
     q-btn.q-py-sm(
+      v-if='userStore.can(`manage:pages`)'
       flat
       icon='las la-share'
       :color='editorStore.isActive ? `deep-orange-2` : `grey`'
@@ -78,6 +86,7 @@
       )
       q-tooltip(anchor='center left' self='center right') Rename / Move Page
     q-btn.q-py-sm(
+      v-if='userStore.can(`delete:pages`)'
       flat
       icon='las la-trash'
       :color='editorStore.isActive ? `deep-orange-2` : `grey`'
@@ -99,6 +108,7 @@ import { useEditorStore } from 'src/stores/editor'
 import { useFlagsStore } from 'src/stores/flags'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 // QUASAR
 
@@ -110,6 +120,7 @@ const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // ROUTER
 

+ 38 - 11
ux/src/components/PageHeader.vue

@@ -73,7 +73,8 @@
   //- PAGE ACTIONS
   .col-auto.q-pa-md.flex.items-center.justify-end
     template(v-if='!editorStore.isActive')
-      q-btn.q-mr-md(
+      q-btn.q-ml-md(
+        v-if='userStore.authenticated'
         flat
         dense
         icon='las la-bell'
@@ -81,7 +82,8 @@
         aria-label='Watch Page'
         )
         q-tooltip Watch Page
-      q-btn.q-mr-md(
+      q-btn.q-ml-md(
+        v-if='userStore.authenticated'
         flat
         dense
         icon='las la-bookmark'
@@ -89,7 +91,7 @@
         aria-label='Bookmark Page'
         )
         q-tooltip Bookmark Page
-      q-btn.q-mr-md(
+      q-btn.q-ml-md(
         flat
         dense
         icon='las la-share-alt'
@@ -98,7 +100,7 @@
         )
         q-tooltip Share
         social-sharing-menu
-      q-btn.q-mr-md(
+      q-btn.q-ml-md(
         flat
         dense
         icon='las la-print'
@@ -107,7 +109,7 @@
         )
         q-tooltip Print
     template(v-if='editorStore.isActive')
-      q-btn.q-mr-sm.acrylic-btn(
+      q-btn.q-ml-md.acrylic-btn(
         icon='las la-question-circle'
         flat
         color='grey'
@@ -115,7 +117,7 @@
         target='_blank'
         type='a'
       )
-      q-btn.q-mr-sm.acrylic-btn(
+      q-btn.q-ml-sm.acrylic-btn(
         icon='las la-cog'
         flat
         color='grey'
@@ -123,7 +125,7 @@
         @click='openEditorSettings'
       )
     template(v-if='editorStore.isActive || editorStore.hasPendingChanges')
-      q-btn.acrylic-btn.q-mr-sm(
+      q-btn.acrylic-btn.q-ml-sm(
         flat
         icon='las la-times'
         color='negative'
@@ -132,7 +134,7 @@
         no-caps
         @click='discardChanges'
       )
-      q-btn.acrylic-btn(
+      q-btn.acrylic-btn.q-ml-sm(
         v-if='editorStore.mode === `create`'
         flat
         icon='las la-check'
@@ -142,7 +144,7 @@
         no-caps
         @click='createPage'
       )
-      q-btn.acrylic-btn(
+      q-btn.acrylic-btn.q-ml-sm(
         v-else
         flat
         icon='las la-check'
@@ -153,8 +155,8 @@
         no-caps
         @click='saveChanges'
       )
-    template(v-else)
-      q-btn.acrylic-btn(
+    template(v-else-if='userStore.can(`edit:pages`)')
+      q-btn.acrylic-btn.q-ml-md(
         flat
         icon='las la-edit'
         color='deep-orange-9'
@@ -292,6 +294,31 @@ async function saveChangesCommit () {
 }
 
 async function createPage () {
+  // Handle home page creation flow
+  if (pageStore.path === 'home') {
+    $q.loading.show()
+    try {
+      await pageStore.pageSave()
+      $q.notify({
+        type: 'positive',
+        message: 'Homepage created successfully.'
+      })
+      editorStore.$patch({
+        isActive: false
+      })
+      router.replace('/')
+    } catch (err) {
+      $q.notify({
+        type: 'negative',
+        message: 'Failed to create homepage.',
+        caption: err.message
+      })
+    }
+    $q.loading.hide()
+    return
+  }
+
+  // All other pages
   $q.dialog({
     component: defineAsyncComponent(() => import('../components/TreeBrowserDialog.vue')),
     componentProps: {

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

@@ -91,6 +91,11 @@ body::-webkit-scrollbar-thumb {
   }
 }
 
+.q-field--dark .q-field__control:before {
+  background-color: $dark-5;
+  border-color: rgba(255,255,255,.25);
+}
+
 // ------------------------------------------------------------------
 // ICONS SIZE FIX
 // ------------------------------------------------------------------
@@ -191,6 +196,22 @@ body::-webkit-scrollbar-thumb {
   }
 }
 
+// ------------------------------------------------------------------
+// CARDS
+// ------------------------------------------------------------------
+
+.q-card {
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12);
+
+  &.q-card--dark {
+    box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12);
+  }
+
+  .q-separator--dark {
+    background-color: rgba(0,0,0,.7);
+  }
+}
+
 // ------------------------------------------------------------------
 // DROPDOWN MENUS
 // ------------------------------------------------------------------
@@ -210,6 +231,10 @@ body::-webkit-scrollbar-thumb {
   }
 }
 
+.q-menu--dark {
+  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2), 0 1px 1px rgba(0, 0, 0, 0.14), 0 2px 1px -1px rgba(0, 0, 0, 0.12);
+}
+
 // ------------------------------------------------------------------
 // LOADING ANIMATIONS
 // ------------------------------------------------------------------

+ 1 - 1
ux/src/css/page-contents.scss

@@ -133,7 +133,7 @@
 
     &::before {
       display: inline-block;
-      font: normal normal normal 24px/1 "Line Awesome Free", sans-serif;
+      font: normal normal normal 24px/1 "Material Design Icons", sans-serif;
       position: absolute;
       margin-top: -12px;
       top: 50%;

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

@@ -1129,6 +1129,7 @@
   "auth.loginRequired": "Login required",
   "auth.loginSuccess": "Login Successful! Redirecting...",
   "auth.loginUsingStrategy": "Login using {strategy}",
+  "auth.logoutSuccess": "You've been logged out successfully.",
   "auth.missingEmail": "Missing email address.",
   "auth.missingName": "Name is missing.",
   "auth.missingPassword": "Missing password.",
@@ -1179,6 +1180,7 @@
   "common.actions.generate": "Generate",
   "common.actions.howItWorks": "How it works",
   "common.actions.insert": "Insert",
+  "common.actions.login": "Login",
   "common.actions.manage": "Manage",
   "common.actions.move": "Move",
   "common.actions.moveTo": "Move To",

+ 168 - 143
ux/src/layouts/AdminLayout.vue

@@ -46,157 +46,160 @@ q-layout.admin(view='hHh Lpr lff')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-apps-tab.svg')
           q-item-section {{ t('admin.dashboard.title') }}
-        q-item(to='/_admin/sites', v-ripple, active-class='bg-primary text-white')
+        q-item(to='/_admin/sites', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
           q-item-section(avatar)
             q-icon(name='img:/_assets/icons/fluent-change-theme.svg')
           q-item-section {{ t('admin.sites.title') }}
           q-item-section(side)
             q-badge(color='dark-3', :label='adminStore.sites.length')
-        q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.site') }}
-        q-item.q-mb-md
-          q-item-section
-            q-select(
-              dark
-              standout
-              dense
-              v-model='adminStore.currentSiteId'
-              :options='adminStore.sites'
-              option-value='id'
-              option-label='title'
-              emit-value
-              map-options
-            )
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/general`', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-web.svg')
-          q-item-section {{ t('admin.general.title') }}
-        template(v-if='flagsStore.experimental')
-          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/analytics`', v-ripple, active-class='bg-primary text-white', disabled)
+        template(v-if='siteSectionShown')
+          q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.site') }}
+          q-item.q-mb-md
+            q-item-section
+              q-select(
+                dark
+                standout
+                dense
+                v-model='adminStore.currentSiteId'
+                :options='adminStore.sites'
+                option-value='id'
+                option-label='title'
+                emit-value
+                map-options
+              )
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/general`', v-ripple, active-class='bg-primary text-white')
             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-icon(name='img:/_assets/icons/fluent-web.svg')
+            q-item-section {{ t('admin.general.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', v-if='userStore.can(`manage:sites`)')
             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-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', v-if='userStore.can(`manage:sites`)')
             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-icon(name='img:/_assets/icons/fluent-language.svg')
+            q-item-section {{ t('admin.locale.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/login`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
             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')
-          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')
-          q-item-section {{ t('admin.locale.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/login`', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-bunch-of-keys.svg')
-          q-item-section {{ t('admin.login.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/navigation`', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
-          q-item-section {{ t('admin.navigation.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/storage`', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-ssd.svg')
-          q-item-section {{ t('admin.storage.title') }}
-        q-item(:to='`/_admin/` + adminStore.currentSiteId + `/theme`', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-paint-roller.svg')
-          q-item-section {{ t('admin.theme.title') }}
-        q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.users') }}
-        q-item(to='/_admin/auth', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-security-lock.svg')
-          q-item-section {{ t('admin.auth.title') }}
-        q-item(to='/_admin/groups', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-people.svg')
-          q-item-section {{ t('admin.groups.title') }}
-          q-item-section(side)
-            q-badge(color='dark-3', :label='adminStore.info.groupsTotal')
-        q-item(to='/_admin/users', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-account.svg')
-          q-item-section {{ t('admin.users.title') }}
-          q-item-section(side)
-            q-badge(color='dark-3', :label='adminStore.info.usersTotal')
-        q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.system') }}
-        q-item(to='/_admin/api', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-rest-api.svg')
-          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, 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') }}
-        q-item(to='/_admin/extensions', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-module.svg')
-          q-item-section {{ t('admin.extensions.title') }}
-        q-item(to='/_admin/icons', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-spring.svg')
-          q-item-section {{ t('admin.icons.title') }}
-        q-item(to='/_admin/instances', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-network.svg')
-          q-item-section {{ t('admin.instances.title') }}
-        q-item(to='/_admin/mail', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-message-settings.svg')
-          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')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
-          q-item-section {{ t('admin.rendering.title') }}
-        q-item(to='/_admin/scheduler', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-bot.svg')
-          q-item-section {{ t('admin.scheduler.title') }}
-          q-item-section(side)
-            status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`')
-        q-item(to='/_admin/security', v-ripple, active-class='bg-primary text-white')
-          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, 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') }}
-        q-item(to='/_admin/system', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-processor.svg')
-          q-item-section {{ t('admin.system.title') }}
-          q-item-section(side)
-            status-light(:color='adminStore.isVersionLatest ? `positive` : `warning`')
-        q-item(to='/_admin/terminal', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-linux-terminal.svg')
-          q-item-section {{ t('admin.terminal.title') }}
-        q-item(to='/_admin/utilities', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-swiss-army-knife.svg')
-          q-item-section {{ t('admin.utilities.title') }}
-        q-item(to='/_admin/webhooks', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-lightning-bolt.svg')
-          q-item-section {{ t('admin.webhooks.title') }}
-        q-item(to='/_admin/flags', v-ripple, active-class='bg-primary text-white')
-          q-item-section(avatar)
-            q-icon(name='img:/_assets/icons/fluent-windsock.svg')
-          q-item-section {{ t('admin.dev.flags.title') }}
+              q-icon(name='img:/_assets/icons/fluent-bunch-of-keys.svg')
+            q-item-section {{ t('admin.login.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/navigation`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`) || userStore.can(`manage:navigation`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-tree-structure.svg')
+            q-item-section {{ t('admin.navigation.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/storage`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-ssd.svg')
+            q-item-section {{ t('admin.storage.title') }}
+          q-item(:to='`/_admin/` + adminStore.currentSiteId + `/theme`', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:sites`) || userStore.can(`manage:theme`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-paint-roller.svg')
+            q-item-section {{ t('admin.theme.title') }}
+        template(v-if='usersSectionShown')
+          q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.users') }}
+          q-item(to='/_admin/auth', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:system`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-security-lock.svg')
+            q-item-section {{ t('admin.auth.title') }}
+          q-item(to='/_admin/groups', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:groups`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-people.svg')
+            q-item-section {{ t('admin.groups.title') }}
+            q-item-section(side)
+              q-badge(color='dark-3', :label='adminStore.info.groupsTotal')
+          q-item(to='/_admin/users', v-ripple, active-class='bg-primary text-white', v-if='userStore.can(`manage:users`)')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-account.svg')
+            q-item-section {{ t('admin.users.title') }}
+            q-item-section(side)
+              q-badge(color='dark-3', :label='adminStore.info.usersTotal')
+        template(v-if='userStore.can(`manage:system`)')
+          q-item-label.q-mt-sm(header).text-caption.text-blue-grey-4 {{ t('admin.nav.system') }}
+          q-item(to='/_admin/api', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-rest-api.svg')
+            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, 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') }}
+          q-item(to='/_admin/extensions', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-module.svg')
+            q-item-section {{ t('admin.extensions.title') }}
+          q-item(to='/_admin/icons', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-spring.svg')
+            q-item-section {{ t('admin.icons.title') }}
+          q-item(to='/_admin/instances', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-network.svg')
+            q-item-section {{ t('admin.instances.title') }}
+          q-item(to='/_admin/mail', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-message-settings.svg')
+            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')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-rich-text-converter.svg')
+            q-item-section {{ t('admin.rendering.title') }}
+          q-item(to='/_admin/scheduler', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-bot.svg')
+            q-item-section {{ t('admin.scheduler.title') }}
+            q-item-section(side)
+              status-light(:color='adminStore.info.isSchedulerHealthy ? `positive` : `warning`')
+          q-item(to='/_admin/security', v-ripple, active-class='bg-primary text-white')
+            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, 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') }}
+          q-item(to='/_admin/system', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-processor.svg')
+            q-item-section {{ t('admin.system.title') }}
+            q-item-section(side)
+              status-light(:color='adminStore.isVersionLatest ? `positive` : `warning`')
+          q-item(to='/_admin/terminal', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-linux-terminal.svg')
+            q-item-section {{ t('admin.terminal.title') }}
+          q-item(to='/_admin/utilities', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-swiss-army-knife.svg')
+            q-item-section {{ t('admin.utilities.title') }}
+          q-item(to='/_admin/webhooks', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-lightning-bolt.svg')
+            q-item-section {{ t('admin.webhooks.title') }}
+          q-item(to='/_admin/flags', v-ripple, active-class='bg-primary text-white')
+            q-item-section(avatar)
+              q-icon(name='img:/_assets/icons/fluent-windsock.svg')
+            q-item-section {{ t('admin.dev.flags.title') }}
   q-page-container.admin-container
     router-view(v-slot='{ Component }')
       component(:is='Component')
@@ -215,13 +218,14 @@ q-layout.admin(view='hHh Lpr lff')
 
 <script setup>
 import { useMeta, useQuasar, setCssVar } from 'quasar'
-import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
+import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 import { useI18n } from 'vue-i18n'
 
 import { useAdminStore } from 'src/stores/admin'
 import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 // COMPONENTS
 
@@ -242,6 +246,7 @@ const $q = useQuasar()
 const adminStore = useAdminStore()
 const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // ROUTER
 
@@ -279,8 +284,23 @@ const barStyle = {
   width: '7px'
 }
 
+// COMPUTED
+
+const siteSectionShown = computed(() => {
+  return userStore.can('manage:sites') || userStore.can('manage:navigation') || userStore.can('manage:theme')
+})
+const usersSectionShown = computed(() => {
+  return userStore.can('manage:groups') || userStore.can('manage:users')
+})
+
 // WATCHERS
 
+watch(() => route.path, async (newValue) => {
+  if (!newValue.startsWith('/_admin')) { return }
+  if (!userStore.can('access:admin')) {
+    router.replace('/_error/unauthorized')
+  }
+}, { immediate: true })
 watch(() => adminStore.sites, (newValue) => {
   if (adminStore.currentSiteId === null && newValue.length > 0) {
     adminStore.$patch({
@@ -300,6 +320,11 @@ watch(() => adminStore.currentSiteId, (newValue) => {
 // MOUNTED
 
 onMounted(async () => {
+  if (!userStore.can('access:admin')) {
+    router.replace('/_error/unauthorized')
+    return
+  }
+
   await adminStore.fetchSites()
   if (route.params.siteid) {
     adminStore.$patch({

+ 8 - 7
ux/src/layouts/MainLayout.vue

@@ -27,7 +27,6 @@ q-layout(view='hHh Lpr lff')
         label='Browse'
         aria-label='Browse'
         size='sm'
-        @click='openFileManager'
         )
     q-scroll-area.sidebar-nav(
       :thumb-style='thumbStyle'
@@ -38,21 +37,21 @@ q-layout(view='hHh Lpr lff')
         dense
         dark
         )
-        q-item-label.text-blue-2.text-caption(header) Getting Started
+        q-item-label.text-blue-2.text-caption(header) Header
         q-item(to='/install')
           q-item-section(side)
             q-icon(name='las la-dog', color='white')
-          q-item-section Requirements
+          q-item-section Link 1
         q-item(to='/install')
           q-item-section(side)
             q-icon(name='las la-cat', color='white')
-          q-item-section Installation
+          q-item-section Link 2
         q-separator.q-my-sm(dark)
         q-item(to='/install')
           q-item-section(side)
-            q-icon(name='las la-cat', color='white')
-          q-item-section Installation
-    q-bar.bg-blue-9.text-white(dense, v-if='flagsStore.experimental')
+            q-icon(name='mdi-fruit-grapes', color='white')
+          q-item-section Link 3
+    q-bar.bg-blue-9.text-white(dense, v-if='flagsStore.experimental && userStore.authenticated')
       q-btn.col(
         icon='las la-dharmachakra'
         label='History'
@@ -90,6 +89,7 @@ import { useI18n } from 'vue-i18n'
 import { useEditorStore } from 'src/stores/editor'
 import { useFlagsStore } from 'src/stores/flags'
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 // COMPONENTS
 
@@ -106,6 +106,7 @@ const $q = useQuasar()
 const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // ROUTER
 

+ 1 - 1
ux/src/layouts/ProfileLayout.vue

@@ -32,7 +32,7 @@ q-layout(view='hHh Lpr lff')
           q-item(
             clickable
             v-ripple
-            href='/logout'
+            @click='userStore.logout()'
             )
             q-item-section(side)
               q-icon(name='las la-sign-out-alt', color='negative')

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

@@ -73,7 +73,7 @@ q-page.admin-mail
                 q-item-label: strong {{str.title}}
                 q-item-label(caption, lines='2') {{str.description}}
     .col
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.storage.contentTypes')}}
           .text-body2.text-grey {{ t('admin.storage.contentTypesHint') }}
@@ -81,7 +81,7 @@ q-page.admin-mail
       //- -----------------------
       //- Configuration
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.storage.config')}}
           q-banner.q-mt-md(

+ 14 - 6
ux/src/pages/AdminDashboard.vue

@@ -8,7 +8,7 @@ q-page.admin-dashboard
       .text-subtitle1.text-grey.animated.fadeInLeft.wait-p2s {{ t('admin.dashboard.subtitle') }}
   .row.q-px-md.q-col-gutter-md
     .col-12.col-sm-6.col-lg-3
-      q-card.shadow-1
+      q-card
         q-card-section.admin-dashboard-card
           img(src='/_assets/icons/fluent-change-theme.svg')
           div
@@ -21,6 +21,7 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-plus-circle'
             :label='t(`common.actions.new`)'
+            :disable='!userStore.can(`manage:sites`)'
             @click='newSite'
             )
           q-separator.q-mx-sm(vertical)
@@ -29,10 +30,11 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-sitemap'
             :label='t(`common.actions.manage`)'
+            :disable='!userStore.can(`manage:sites`)'
             to='/_admin/sites'
             )
     .col-12.col-sm-6.col-lg-3
-      q-card.shadow-1
+      q-card
         q-card-section.admin-dashboard-card
           img(src='/_assets/icons/fluent-account.svg')
           div
@@ -45,6 +47,7 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-user-plus'
             :label='t(`common.actions.new`)'
+            :disable='!userStore.can(`manage:users`)'
             @click='newUser'
             )
           q-separator.q-mx-sm(vertical)
@@ -53,10 +56,11 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-users'
             :label='t(`common.actions.manage`)'
+            :disable='!userStore.can(`manage:users`)'
             to='/_admin/users'
             )
     .col-12.col-sm-6.col-lg-3
-      q-card.shadow-1
+      q-card
         q-card-section.admin-dashboard-card
           img(src='/_assets/icons/fluent-female-working-with-a-laptop.svg')
           div
@@ -69,10 +73,11 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-chart-area'
             :label='t(`admin.analytics.title`)'
+            :disable='!userStore.can(`manage:sites`)'
             :to='`/_admin/` + adminStore.currentSiteId + `/analytics`'
             )
     .col-12.col-sm-6.col-lg-3
-      q-card.shadow-1
+      q-card
         q-card-section.admin-dashboard-card
           img(src='/_assets/icons/fluent-ssd-animated.svg')
           div
@@ -85,6 +90,7 @@ q-page.admin-dashboard
             color='primary'
             icon='las la-server'
             :label='t(`common.actions.manage`)'
+            :disable='!userStore.can(`manage:sites`)'
             :to='`/_admin/` + adminStore.currentSiteId + `/storage`'
             )
     .col-12
@@ -96,7 +102,7 @@ q-page.admin-dashboard
         i.las.la-check.q-mr-sm
         span.text-weight-medium(v-if='adminStore.isVersionLatest') Your Wiki.js server is running the latest version!
         span.text-weight-medium(v-else) A new version of Wiki.js is available. Please update to the latest version.
-        template(#action)
+        template(#action, v-if='userStore.can(`manage:system`)')
           q-btn(
             flat
             :label='t(`admin.system.checkForUpdates`)'
@@ -109,7 +115,7 @@ q-page.admin-dashboard
             to='/_admin/system'
             )
     .col-12
-      q-card.shadow-1
+      q-card
         q-card-section ---
 
 //- v-container(fluid, grid-list-lg)
@@ -224,6 +230,7 @@ import { useI18n } from 'vue-i18n'
 import { useRouter } from 'vue-router'
 
 import { useAdminStore } from '../stores/admin'
+import { useUserStore } from 'src/stores/user'
 
 // COMPONENTS
 
@@ -238,6 +245,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const userStore = useUserStore()
 
 // ROUTER
 

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

@@ -32,7 +32,7 @@ q-page.admin-flags
       )
   q-separator(inset)
   .q-pa-md.q-gutter-md
-    q-card.shadow-1
+    q-card
       q-list(separator)
         template(v-for='editor of editors', :key='editor.id')
           q-item(v-if='flagsStore.experimental || !editor.isDisabled')

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

@@ -25,7 +25,7 @@ q-page.admin-extensions
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
-      q-card.shadow-1
+      q-card
         q-list(separator)
           q-item(
             v-for='(ext, idx) of state.extensions'

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

@@ -33,7 +33,7 @@ q-page.admin-flags
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12.col-lg-7
-      q-card.shadow-1.q-py-sm
+      q-card.q-py-sm
         q-item
           q-item-section
             q-card.bg-negative.text-white.rounded-borders(flat)
@@ -84,7 +84,7 @@ q-page.admin-flags
               unchecked-icon='las la-times'
               :aria-label='t(`admin.flags.sqlLog.label`)'
               )
-      q-card.shadow-1.q-py-sm.q-mt-md
+      q-card.q-py-sm.q-mt-md
         q-item
           blueprint-icon(icon='administrative-tools')
           q-item-section

+ 8 - 8
ux/src/pages/AdminGeneral.vue

@@ -36,7 +36,7 @@ q-page.admin-general
       //- -----------------------
       //- Site Info
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.general.siteInfo')}}
         q-item
@@ -85,7 +85,7 @@ q-page.admin-general
       //- -----------------------
       //- Footer / Copyright
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.general.footerCopyright')}}
         q-item
@@ -135,7 +135,7 @@ q-page.admin-general
       //- -----------------------
       //- FEATURES
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.general.features')}}
         q-item(tag='label')
@@ -227,7 +227,7 @@ q-page.admin-general
       //- -----------------------
       //- URL Handling
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.general.urlHandling')}}
         q-item
@@ -247,7 +247,7 @@ q-page.admin-general
       //- -----------------------
       //- Logo
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.general.logo')}}
         q-item
@@ -333,7 +333,7 @@ q-page.admin-general
       //- -----------------------
       //- Defaults
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.config.defaults')
+      q-card.q-pb-sm.q-mt-md(v-if='state.config.defaults')
         q-card-section
           .text-subtitle1 {{t('admin.general.defaults')}}
         q-item
@@ -409,7 +409,7 @@ q-page.admin-general
       //- -----------------------
       //- Uploads
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.config.uploads')
+      q-card.q-pb-sm.q-mt-md(v-if='state.config.uploads')
         q-card-section
           .text-subtitle1 {{t('admin.general.uploads')}}
         q-item
@@ -449,7 +449,7 @@ q-page.admin-general
       //- -----------------------
       //- SEO
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.config.robots')
+      q-card.q-pb-sm.q-mt-md(v-if='state.config.robots')
         q-card-section
           .text-subtitle1 SEO
         q-item(tag='label')

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

@@ -40,7 +40,7 @@ q-page.admin-groups
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
-      q-card.shadow-1
+      q-card
         q-table(
           :rows='state.groups'
           :columns='headers'

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

@@ -33,7 +33,7 @@ q-page.admin-icons
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
-      q-card.shadow-1
+      q-card
         q-card-section
           q-card.bg-negative.text-white.rounded-borders(flat)
             q-card-section.items-center(horizontal)

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

@@ -24,7 +24,7 @@ q-page.admin-terminal
       )
   q-separator(inset)
   .q-pa-md.q-gutter-md
-    q-card.shadow-1
+    q-card
       q-table(
         :rows='state.instances'
         :columns='instancesHeaders'

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

@@ -45,7 +45,7 @@ q-page.admin-locale
       //- -----------------------
       //- Locale Options
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.locale.settings')}}
         q-item
@@ -93,7 +93,7 @@ q-page.admin-locale
       //- -----------------------
       //- Namespacing
       //- -----------------------
-      q-card.shadow-1.q-pb-sm(v-if='state.namespacing')
+      q-card.q-pb-sm(v-if='state.namespacing')
         q-card-section
           .text-subtitle1 {{t('admin.locale.activeNamespaces')}}
 

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

@@ -36,7 +36,7 @@ q-page.admin-login
       //- -----------------------
       //- Experience
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.login.experience')}}
         q-item
@@ -137,7 +137,7 @@ q-page.admin-login
       //- -----------------------
       //- Providers
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.login.providers')}}
         q-card-section.admin-login-providers.q-pt-none

+ 5 - 5
ux/src/pages/AdminMail.vue

@@ -36,7 +36,7 @@ q-page.admin-mail
       //- -----------------------
       //- Configuration
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.mail.configuration')}}
         q-item
@@ -68,7 +68,7 @@ q-page.admin-mail
       //- -----------------------
       //- SMTP
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.mail.smtp')}}
         q-item
@@ -168,7 +168,7 @@ q-page.admin-mail
       //- -----------------------
       //- DKIM
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.mail.dkim')}}
         q-item.q-pt-none
@@ -237,7 +237,7 @@ q-page.admin-mail
       //- -----------------------
       //- MAIL TEMPLATES
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.mail.templates')}}
         q-list
@@ -271,7 +271,7 @@ q-page.admin-mail
       //- -----------------------
       //- SMTP TEST
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.mail.test')}}
         q-item

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

@@ -52,7 +52,7 @@ q-page.admin-terminal
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-info-circle', size='sm')
           q-card-section.text-caption {{ t('admin.scheduler.scheduledNone') }}
-      q-card.shadow-1(v-else)
+      q-card(v-else)
         q-table(
           :rows='state.scheduledJobs'
           :columns='scheduledJobsHeaders'
@@ -109,7 +109,7 @@ q-page.admin-terminal
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-info-circle', size='sm')
           q-card-section.text-caption {{ t('admin.scheduler.upcomingNone') }}
-      q-card.shadow-1(v-else)
+      q-card(v-else)
         q-table(
           :rows='state.upcomingJobs'
           :columns='upcomingJobsHeaders'
@@ -167,7 +167,7 @@ q-page.admin-terminal
           q-card-section.col-auto.q-pr-none
             q-icon(name='las la-info-circle', size='sm')
           q-card-section.text-caption {{ t('admin.scheduler.' + state.displayMode + 'None') }}
-      q-card.shadow-1(v-else)
+      q-card(v-else)
         q-table(
           :rows='state.jobs'
           :columns='jobsHeaders'

+ 5 - 5
ux/src/pages/AdminSecurity.vue

@@ -36,7 +36,7 @@ q-page.admin-mail
       //- -----------------------
       //- Security
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.security.title')}}
         q-item.q-pt-none
@@ -132,7 +132,7 @@ q-page.admin-mail
       //- -----------------------
       //- HSTS
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.security.hsts')}}
         q-item(tag='label', v-ripple)
@@ -172,7 +172,7 @@ q-page.admin-mail
       //- -----------------------
       //- Uploads
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.security.uploads')}}
         q-item.q-pt-none
@@ -226,7 +226,7 @@ q-page.admin-mail
       //- -----------------------
       //- CORS
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.security.cors')}}
         q-item
@@ -279,7 +279,7 @@ q-page.admin-mail
       //- -----------------------
       //- JWT
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.security.jwt')}}
         q-item

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

@@ -31,7 +31,7 @@ q-page.admin-locale
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
-      q-card.shadow-1
+      q-card
         q-list(separator)
           q-item(
             v-for='site of adminStore.sites'

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

@@ -77,7 +77,7 @@ q-page.admin-storage
           //- -----------------------
           //- Setup
           //- -----------------------
-          q-card.shadow-1.q-pb-sm.q-mb-md(v-if='state.target.setup && state.target.setup.handler && state.target.setup.state !== `configured`')
+          q-card.q-pb-sm.q-mb-md(v-if='state.target.setup && state.target.setup.handler && state.target.setup.state !== `configured`')
             q-card-section
               .text-subtitle1 {{t('admin.storage.setup')}}
               .text-body2.text-grey {{ t('admin.storage.setupHint') }}
@@ -167,7 +167,7 @@ q-page.admin-storage
                   @click='setupGitHubStep(`verify`)'
                   :loading='state.setupCfg.loading'
                 )
-          q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.target.setup && state.target.setup.handler && state.target.setup.state === `configured`')
+          q-card.q-pb-sm.q-mt-md(v-if='state.target.setup && state.target.setup.handler && state.target.setup.state === `configured`')
             q-card-section
               .text-subtitle1 {{t('admin.storage.setup')}}
               .text-body2.text-grey {{ t('admin.storage.setupConfiguredHint') }}
@@ -189,7 +189,7 @@ q-page.admin-storage
           //- -----------------------
           //- Content Types
           //- -----------------------
-          q-card.shadow-1.q-pb-sm
+          q-card.q-pb-sm
             q-card-section
               .text-subtitle1 {{t('admin.storage.contentTypes')}}
               .text-body2.text-grey {{ t('admin.storage.contentTypesHint') }}
@@ -262,7 +262,7 @@ q-page.admin-storage
           //- -----------------------
           //- Content Delivery
           //- -----------------------
-          q-card.shadow-1.q-pb-sm.q-mt-md
+          q-card.q-pb-sm.q-mt-md
             q-card-section
               .text-subtitle1 {{t('admin.storage.assetDelivery')}}
               .text-body2.text-grey {{ t('admin.storage.assetDeliveryHint') }}
@@ -294,7 +294,7 @@ q-page.admin-storage
           //- -----------------------
           //- Configuration
           //- -----------------------
-          q-card.shadow-1.q-pb-sm.q-mt-md
+          q-card.q-pb-sm.q-mt-md
             q-card-section
               .text-subtitle1 {{t('admin.storage.config')}}
               q-banner.q-mt-md(
@@ -367,7 +367,7 @@ q-page.admin-storage
           //- -----------------------
           //- Sync
           //- -----------------------
-          q-card.shadow-1.q-pb-sm.q-mt-md(v-if='state.target.sync && Object.keys(state.target.sync).length > 0')
+          q-card.q-pb-sm.q-mt-md(v-if='state.target.sync && Object.keys(state.target.sync).length > 0')
             q-card-section
               .text-subtitle1 {{t('admin.storage.sync')}}
               q-banner.q-mt-md(
@@ -378,7 +378,7 @@ q-page.admin-storage
           //- -----------------------
           //- Actions
           //- -----------------------
-          q-card.shadow-1.q-pb-sm.q-mt-md
+          q-card.q-pb-sm.q-mt-md
             q-card-section
               .text-subtitle1 {{t('admin.storage.actions')}}
               q-banner.q-mt-md(

+ 9 - 4
ux/src/pages/AdminSystem.vue

@@ -37,7 +37,7 @@ q-page.admin-system
       //- -----------------------
       //- WIKI.JS
       //- -----------------------
-      q-card.q-pb-sm.shadow-1
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 Wiki.js
         q-item
@@ -69,7 +69,7 @@ q-page.admin-system
       //- CLIENT
       //- -----------------------
       q-no-ssr
-        q-card.q-mt-md.q-pb-sm.shadow-1
+        q-card.q-mt-md.q-pb-sm
           q-card-section
             .text-subtitle1 {{t('admin.system.client')}}
           q-item
@@ -116,7 +116,7 @@ q-page.admin-system
       //- -----------------------
       //- ENGINES
       //- -----------------------
-      q-card.q-pb-sm.shadow-1
+      q-card.q-pb-sm
         q-card-section
           .text-subtitle1 {{t('admin.system.engines')}}
         q-item
@@ -146,7 +146,7 @@ q-page.admin-system
       //- -----------------------
       //- HOST INFORMATION
       //- -----------------------
-      q-card.q-mt-md.q-pb-sm.shadow-1
+      q-card.q-mt-md.q-pb-sm
         q-card-section
           .text-subtitle1 {{ t('admin.system.hostInfo') }}
         q-item
@@ -450,6 +450,11 @@ Total RAM: ${state.info.ramTotal}`
     padding: 8px 12px;
     border-radius: 4px;
     font-family: 'Roboto Mono', Consolas, "Liberation Mono", Courier, monospace;
+
+    @at-root .body--dark & {
+      background-color: $dark-4;
+      color: #FFF;
+    }
   }
 }
 </style>

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

@@ -43,7 +43,7 @@ q-page.admin-terminal
         )
   q-separator(inset)
   .q-pa-md.q-gutter-md
-    q-card.shadow-1
+    q-card
       .admin-terminal-term(ref='termDiv')
 
 </template>

+ 5 - 10
ux/src/pages/AdminTheme.vue

@@ -36,7 +36,7 @@ q-page.admin-theme
       //- -----------------------
       //- Theme Options
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section.flex.items-center
           .text-subtitle1 {{t('admin.theme.appearance')}}
           q-space
@@ -90,7 +90,7 @@ q-page.admin-theme
       //- -----------------------
       //- Theme Layout
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.theme.layout')}}
         q-item
@@ -170,7 +170,7 @@ q-page.admin-theme
       //- -----------------------
       //- Fonts
       //- -----------------------
-      q-card.shadow-1.q-pb-sm
+      q-card.q-pb-sm
         q-card-section.flex.items-center
           .text-subtitle1 {{t('admin.theme.fonts')}}
           q-space
@@ -216,7 +216,7 @@ q-page.admin-theme
       //- -----------------------
       //- Code Injection
       //- -----------------------
-      q-card.shadow-1.q-pb-sm.q-mt-md
+      q-card.q-pb-sm.q-mt-md
         q-card-section
           .text-subtitle1 {{t('admin.theme.codeInjection')}}
         q-item
@@ -471,12 +471,7 @@ async function save () {
         siteStore.$patch({
           theme: patchTheme
         })
-        $q.dark.set(state.config.dark)
-        setCssVar('primary', state.config.colorPrimary)
-        setCssVar('secondary', state.config.colorSecondary)
-        setCssVar('accent', state.config.colorAccent)
-        setCssVar('header', state.config.colorHeader)
-        setCssVar('sidebar', state.config.colorSidebar)
+        EVENT_BUS.emit('applyTheme')
       }
       $q.notify({
         type: 'positive',

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

@@ -41,7 +41,7 @@ q-page.admin-groups
   q-separator(inset)
   .row.q-pa-md.q-col-gutter-md
     .col-12
-      q-card.shadow-1
+      q-card
         q-table(
           :rows='state.users'
           :columns='headers'
@@ -246,7 +246,7 @@ function createUser () {
   $q.dialog({
     component: UserCreateDialog
   }).onOk(() => {
-    this.load()
+    load()
   })
 }
 

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

@@ -17,7 +17,7 @@ q-page.admin-utilities
         )
   q-separator(inset)
   .q-pa-md.q-gutter-md
-    q-card.shadow-1
+    q-card
       q-list(separator)
         q-item
           blueprint-icon(icon='disconnected', :hue-rotate='45')

+ 14 - 2
ux/src/pages/Index.vue

@@ -34,7 +34,7 @@ q-page.column
         v-if='editorStore.isActive'
         )
         component(:is='editorComponents[editorStore.editor]')
-      q-scroll-area(
+      q-scroll-area.page-container-scrl(
         v-else
         :thumb-style='thumbStyle'
         :bar-style='barStyle'
@@ -165,6 +165,7 @@ import { useEditorStore } from 'src/stores/editor'
 import { useFlagsStore } from 'src/stores/flags'
 import { usePageStore } from 'src/stores/page'
 import { useSiteStore } from 'src/stores/site'
+import { useUserStore } from 'src/stores/user'
 
 // COMPONENTS
 
@@ -195,6 +196,7 @@ const editorStore = useEditorStore()
 const flagsStore = useFlagsStore()
 const pageStore = usePageStore()
 const siteStore = useSiteStore()
+const userStore = useUserStore()
 
 // ROUTER
 
@@ -269,7 +271,13 @@ watch(() => route.path, async (newValue) => {
   } catch (err) {
     if (err.message === 'ERR_PAGE_NOT_FOUND') {
       if (newValue === '/') {
-        siteStore.overlay = 'Welcome'
+        if (!userStore.authenticated) {
+          router.push('/login')
+        } else if (!userStore.can('write:pages')) {
+          router.replace('/_error/unauthorized')
+        } else {
+          siteStore.overlay = 'Welcome'
+        }
       } else {
         $q.notify({
           type: 'negative',
@@ -369,6 +377,10 @@ function refreshTocExpanded (baseToc, lvl) {
   // @at-root .body--dark & {
   //   border-top: 1px solid $dark-6;
   // }
+
+  .page-container-scrl > .q-scrollarea__container > .q-scrollarea__content {
+    width: 100%;
+  }
 }
 .page-sidebar {
   flex: 0 0 300px;

+ 35 - 27
ux/src/stores/page.js

@@ -369,6 +369,10 @@ export const usePageStore = defineStore('page', {
             tocDepth: pick(pageData.tocDepth, ['min', 'max'])
           })
 
+          editorStore.$patch({
+            mode: 'edit'
+          })
+
           this.router.replace(`/${this.path}`)
         } else {
           const resp = await APOLLO_CLIENT.mutate({
@@ -390,32 +394,35 @@ export const usePageStore = defineStore('page', {
               `,
             variables: {
               id: this.id,
-              patch: pick(this, [
-                'allowComments',
-                'allowContributions',
-                'allowRatings',
-                'content',
-                'description',
-                'icon',
-                'isBrowsable',
-                'locale',
-                'password',
-                'path',
-                'publishEndDate',
-                'publishStartDate',
-                'publishState',
-                'relations',
-                'render',
-                'scriptJsLoad',
-                'scriptJsUnload',
-                'scriptCss',
-                'showSidebar',
-                'showTags',
-                'showToc',
-                'tags',
-                'title',
-                'tocDepth'
-              ])
+              patch: {
+                ...pick(this, [
+                  'allowComments',
+                  'allowContributions',
+                  'allowRatings',
+                  'content',
+                  'description',
+                  'icon',
+                  'isBrowsable',
+                  'locale',
+                  'password',
+                  'path',
+                  'publishEndDate',
+                  'publishStartDate',
+                  'publishState',
+                  'relations',
+                  'render',
+                  'scriptJsLoad',
+                  'scriptJsUnload',
+                  'scriptCss',
+                  'showSidebar',
+                  'showTags',
+                  'showToc',
+                  'tags',
+                  'title',
+                  'tocDepth'
+                ]),
+                reasonForChange: editorStore.reasonForChange
+              }
             }
           })
           const result = resp?.data?.updatePage?.operation ?? {}
@@ -427,7 +434,8 @@ export const usePageStore = defineStore('page', {
         const curDate = DateTime.utc()
         editorStore.$patch({
           lastChangeTimestamp: curDate,
-          lastSaveTimestamp: curDate
+          lastSaveTimestamp: curDate,
+          reasonForChange: ''
         })
       } catch (err) {
         console.warn(err)

+ 66 - 0
ux/src/stores/user.js

@@ -5,6 +5,8 @@ import gql from 'graphql-tag'
 import { DateTime } from 'luxon'
 import { getAccessibleColor } from 'src/helpers/accessibility'
 
+import { useSiteStore } from './site'
+
 export const useUserStore = defineStore('user', {
   state: () => ({
     id: '10000000-0000-4000-8000-000000000001',
@@ -18,6 +20,7 @@ export const useUserStore = defineStore('user', {
     appearance: 'site',
     cvd: 'none',
     permissions: [],
+    pagePermissions: [],
     iat: 0,
     exp: null,
     authenticated: false,
@@ -27,6 +30,9 @@ export const useUserStore = defineStore('user', {
   getters: {},
   actions: {
     async refreshAuth () {
+      if (this.exp && this.exp < DateTime.now()) {
+        return
+      }
       const jwtCookie = Cookies.get('jwt')
       if (jwtCookie) {
         try {
@@ -38,6 +44,7 @@ export const useUserStore = defineStore('user', {
           this.token = jwtCookie
           if (this.exp <= DateTime.utc()) {
             console.info('Token has expired. Attempting renew...')
+            // TODO: Renew token
           } else {
             this.authenticated = true
           }
@@ -69,6 +76,7 @@ export const useUserStore = defineStore('user', {
                   name
                 }
               }
+              userPermissions
             }
           `,
           variables: {
@@ -90,13 +98,71 @@ export const useUserStore = defineStore('user', {
         this.timeFormat = resp.prefs.timeFormat || '12h'
         this.appearance = resp.prefs.appearance || 'site'
         this.cvd = resp.prefs.cvd || 'none'
+        this.permissions = respRaw.data.userPermissions || []
         this.profileLoaded = true
       } catch (err) {
         console.warn(err)
       }
     },
+    logout () {
+      Cookies.remove('jwt', { path: '/' })
+      this.$patch({
+        id: '10000000-0000-4000-8000-000000000001',
+        email: '',
+        name: '',
+        hasAvatar: false,
+        localeCode: '',
+        timezone: '',
+        dateFormat: 'YYYY-MM-DD',
+        timeFormat: '12h',
+        appearance: 'site',
+        cvd: 'none',
+        permissions: [],
+        iat: 0,
+        exp: null,
+        authenticated: false,
+        token: '',
+        profileLoaded: false
+      })
+      EVENT_BUS.emit('logout')
+    },
     getAccessibleColor (base, hexBase) {
       return getAccessibleColor(base, hexBase, this.cvd)
+    },
+    can (permission) {
+      if (this.permissions.includes('manage:system') || this.permissions.includes(permission) || this.pagePermissions.includes(permission)) {
+        return true
+      }
+      return false
+    },
+    async fetchPagePermissions (path) {
+      if (path.startsWith('/_')) {
+        this.pagePermissions = []
+        return
+      }
+      const siteStore = useSiteStore()
+      try {
+        const respRaw = await APOLLO_CLIENT.query({
+          query: gql`
+            query fetchPagePermissions (
+              $siteId: UUID!
+              $path: String!
+            ) {
+              userPermissionsAtPath(
+                siteId: $siteId
+                path: $path
+              )
+            }
+          `,
+          variables: {
+            siteId: siteStore.id,
+            path
+          }
+        })
+        this.pagePermissions = respRaw?.data?.userPermissionsAtPath || []
+      } catch (err) {
+        console.warn(`Failed to fetch page permissions at path ${path}!`)
+      }
     }
   }
 })