瀏覽代碼

feat: error pages + update docs links in admin pages

Nicolas Giard 2 年之前
父節點
當前提交
82ebac2dd6

+ 10 - 50
server/controllers/auth.js

@@ -6,6 +6,7 @@ const BruteKnex = require('../helpers/brute-knex')
 const router = express.Router()
 const moment = require('moment')
 const _ = require('lodash')
+const path = require('path')
 
 const bruteforce = new ExpressBrute(new BruteKnex({
   createTable: true,
@@ -23,28 +24,16 @@ const bruteforce = new ExpressBrute(new BruteKnex({
  * Login form
  */
 router.get('/login', async (req, res, next) => {
-  _.set(res.locals, 'pageMeta.title', 'Login')
-
-  if (req.query.legacy || (req.get('user-agent') && req.get('user-agent').indexOf('Trident') >= 0)) {
-    const { formStrategies, socialStrategies } = await WIKI.models.authentication.getStrategiesForLegacyClient()
-    res.render('legacy/login', {
-      err: false,
-      formStrategies,
-      socialStrategies
-    })
-  } else {
-    // -> Bypass Login
-    if (WIKI.config.auth.autoLogin && !req.query.all) {
-      const stg = await WIKI.models.authentication.query().orderBy('order').first()
-      const stgInfo = _.find(WIKI.data.authentication, ['key', stg.strategyKey])
-      if (!stgInfo.useForm) {
-        return res.redirect(`/login/${stg.key}`)
-      }
+  // -> Bypass Login
+  if (WIKI.config.auth.autoLogin && !req.query.all) {
+    const stg = await WIKI.models.authentication.query().orderBy('order').first()
+    const stgInfo = _.find(WIKI.data.authentication, ['key', stg.strategyKey])
+    if (!stgInfo.useForm) {
+      return res.redirect(`/login/${stg.key}`)
     }
-    // -> Show Login
-    const bgUrl = !_.isEmpty(WIKI.config.auth.loginBgUrl) ? WIKI.config.auth.loginBgUrl : '/_assets/img/splash/1.jpg'
-    res.render('login', { bgUrl, hideLocal: WIKI.config.auth.hideLocal })
   }
+  // -> Show Login
+  res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
 })
 
 /**
@@ -89,35 +78,6 @@ router.all('/login/:strategy/callback', async (req, res, next) => {
   }
 })
 
-/**
- * LEGACY - Login form handling
- */
-router.post('/login', bruteforce.prevent, async (req, res, next) => {
-  _.set(res.locals, 'pageMeta.title', 'Login')
-
-  if (req.query.legacy || req.get('user-agent').indexOf('Trident') >= 0) {
-    try {
-      const authResult = await WIKI.models.users.login({
-        strategy: req.body.strategy,
-        username: req.body.user,
-        password: req.body.pass
-      }, { req, res })
-      req.brute.reset()
-      res.cookie('jwt', authResult.jwt, { expires: moment().add(1, 'y').toDate() })
-      res.redirect('/')
-    } catch (err) {
-      const { formStrategies, socialStrategies } = await WIKI.models.authentication.getStrategiesForLegacyClient()
-      res.render('legacy/login', {
-        err,
-        formStrategies,
-        socialStrategies
-      })
-    }
-  } else {
-    res.redirect('/login')
-  }
-})
-
 /**
  * Logout
  */
@@ -135,7 +95,7 @@ router.get('/register', async (req, res, next) => {
   _.set(res.locals, 'pageMeta.title', 'Register')
   const localStrg = await WIKI.models.authentication.getStrategy('local')
   if (localStrg.selfRegistration) {
-    res.render('register')
+    res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
   } else {
     next(new WIKI.Error.AuthRegistrationDisabled())
   }

+ 39 - 62
server/controllers/common.js

@@ -34,9 +34,16 @@ router.get('/healthz', (req, res, next) => {
 })
 
 /**
- * Administration
+ * New v3 vue app
  */
-router.get(['/_admin', '/_admin/*'], (req, res, next) => {
+router.get([
+  '/_admin',
+  '/_admin/*',
+  '/_profile',
+  '/_profile/*',
+  '/_error',
+  '/_error/*'
+], (req, res, next) => {
   res.sendFile(path.join(WIKI.ROOTPATH, 'assets/index.html'))
 })
 // router.get(['/_admin', '/_admin/*'], (req, res, next) => {
@@ -318,18 +325,6 @@ router.get(['/i', '/i/:id'], async (req, res, next) => {
   }
 })
 
-/**
- * Profile
- */
-router.get(['/p', '/p/*'], (req, res, next) => {
-  if (!req.user || req.user.id < 1 || req.user.id === 2) {
-    return res.render('unauthorized', { action: 'view' })
-  }
-
-  _.set(res.locals, 'pageMeta.title', 'User Profile')
-  res.render('profile')
-})
-
 /**
  * Source
  */
@@ -453,10 +448,7 @@ router.get('/*', async (req, res, next) => {
         if (pageArgs.path === 'home' && req.user.id === 2) {
           return res.redirect('/login')
         }
-        _.set(res.locals, 'pageMeta.title', 'Unauthorized')
-        return res.status(403).render('unauthorized', {
-          action: 'view'
-        })
+        return res.redirect(`/_error/unauthorized?from=${req.path}`)
       }
 
       _.set(res, 'locals.siteConfig.lang', pageArgs.locale)
@@ -510,52 +502,37 @@ router.get('/*', async (req, res, next) => {
           injectCode.body = `${injectCode.body}\n${page.extra.js}`
         }
 
-        if (req.query.legacy || req.get('user-agent').indexOf('Trident') >= 0) {
-          // -> Convert page TOC
-          if (_.isString(page.toc)) {
-            page.toc = JSON.parse(page.toc)
-          }
-
-          // -> Render legacy view
-          res.render('legacy/page', {
-            page,
-            sidebar,
-            injectCode,
-            isAuthenticated: req.user && req.user.id !== 2
-          })
-        } else {
-          // -> Convert page TOC
-          if (!_.isString(page.toc)) {
-            page.toc = JSON.stringify(page.toc)
-          }
-
-          // -> Inject comments variables
-          const commentTmpl = {
-            codeTemplate: WIKI.data.commentProvider.codeTemplate,
-            head: WIKI.data.commentProvider.head,
-            body: WIKI.data.commentProvider.body,
-            main: WIKI.data.commentProvider.main
-          }
-          if (WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
-            [
-              { key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
-              { key: 'pageId', value: page.id }
-            ].forEach((cfg) => {
-              commentTmpl.head = _.replace(commentTmpl.head, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-              commentTmpl.body = _.replace(commentTmpl.body, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-              commentTmpl.main = _.replace(commentTmpl.main, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
-            })
-          }
-
-          // -> Render view
-          res.render('page', {
-            page,
-            sidebar,
-            injectCode,
-            comments: commentTmpl,
-            effectivePermissions
+        // -> Convert page TOC
+        if (!_.isString(page.toc)) {
+          page.toc = JSON.stringify(page.toc)
+        }
+
+        // -> Inject comments variables
+        const commentTmpl = {
+          codeTemplate: WIKI.data.commentProvider.codeTemplate,
+          head: WIKI.data.commentProvider.head,
+          body: WIKI.data.commentProvider.body,
+          main: WIKI.data.commentProvider.main
+        }
+        if (WIKI.config.features.featurePageComments && WIKI.data.commentProvider.codeTemplate) {
+          [
+            { key: 'pageUrl', value: `${WIKI.config.host}/i/${page.id}` },
+            { key: 'pageId', value: page.id }
+          ].forEach((cfg) => {
+            commentTmpl.head = _.replace(commentTmpl.head, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
+            commentTmpl.body = _.replace(commentTmpl.body, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
+            commentTmpl.main = _.replace(commentTmpl.main, new RegExp(`{{${cfg.key}}}`, 'g'), cfg.value)
           })
         }
+
+        // -> Render view
+        res.render('page', {
+          page,
+          sidebar,
+          injectCode,
+          comments: commentTmpl,
+          effectivePermissions
+        })
       } else if (pageArgs.path === 'home') {
         _.set(res.locals, 'pageMeta.title', 'Welcome')
         res.render('welcome', { locale: pageArgs.locale })

+ 1 - 1
server/graph/resolvers/system.js

@@ -95,7 +95,7 @@ module.exports = {
       return DateTime.fromISO(WIKI.system.updates.releaseDate).toJSDate()
     },
     mailConfigured () {
-      return false // TODO: return true if mail is setup
+      return WIKI.config?.mail?.host?.length > 2
     },
     nodeVersion () {
       return process.version.substr(1)

+ 0 - 5
server/views/admin.pug

@@ -1,5 +0,0 @@
-extends base.pug
-
-block body
-  #root
-    admin

+ 0 - 9
server/views/login.pug

@@ -1,9 +0,0 @@
-extends base.pug
-
-block body
-  #root.is-fullscreen
-    login(
-      bg-url=bgUrl
-      hide-local=hideLocal
-      change-pwd-continuation-token=changePwdContinuationToken
-    )

+ 0 - 5
server/views/notfound.pug

@@ -1,5 +0,0 @@
-extends base.pug
-
-block body
-  #root.is-fullscreen
-    not-found

+ 0 - 5
server/views/profile.pug

@@ -1,5 +0,0 @@
-extends base.pug
-
-block body
-  #root
-    profile

+ 0 - 5
server/views/register.pug

@@ -1,5 +0,0 @@
-extends base.pug
-
-block body
-  #root.is-fullscreen
-    register

+ 0 - 5
server/views/unauthorized.pug

@@ -1,5 +0,0 @@
-extends base.pug
-
-block body
-  #root.is-fullscreen
-    unauthorized(action=action)

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

@@ -0,0 +1 @@
+<svg xmlns="http://www.w3.org/2000/svg"  viewBox="0 0 48 48" width="96px" height="96px"><defs><linearGradient id="YP84gtDqSIMPo~qxagCUGa" x1="16.442" x2="35.628" y1="3.234" y2="55.948" data-name="Безымянный градиент 100" gradientUnits="userSpaceOnUse"><stop offset="0" stop-color="#21ad64"/><stop offset="1" stop-color="#088242"/></linearGradient></defs><path fill="url(#YP84gtDqSIMPo~qxagCUGa)" d="M42,8V40a2.00591,2.00591,0,0,1-2,2H8a2.00591,2.00591,0,0,1-2-2V15.83a2.00616,2.00616,0,0,1,.59-1.42L14.41,6.59A2.00614,2.00614,0,0,1,15.83,6H40A2.00587,2.00587,0,0,1,42,8Z"/><path fill="#50d18d" d="M12,36V17.41L17.41,12H36V30H18V20.41406L20.41406,18H30v6H28V22a1.07539,1.07539,0,0,0-1-1H24.41418a1,1,0,0,0-.70709.29291l-1.41418,1.41418A1,1,0,0,0,22,23.41418V25a.99974.99974,0,0,0,1,1h8a.99974.99974,0,0,0,1-1V17a.99974.99974,0,0,0-1-1H20a.99928.99928,0,0,0-.707.293l-3,3A1.00012,1.00012,0,0,0,16,20V31a.99974.99974,0,0,0,1,1H37a.99974.99974,0,0,0,1-1V11a1.003,1.003,0,0,0-1-1H17a1.03276,1.03276,0,0,0-.71.29l-6,6A1.03276,1.03276,0,0,0,10,17V37a1.003,1.003,0,0,0,1,1H42V36Z"/></svg>

+ 0 - 1
ux/src/components/GroupEditOverlay.vue

@@ -638,7 +638,6 @@ const permissions = [
     warning: true,
     restrictedForSystem: true,
     disabled: true
-
   }
 ]
 

+ 4 - 3
ux/src/components/UserCreateDialog.vue

@@ -161,7 +161,7 @@ q-dialog(ref='dialogRef', @hide='onDialogHide')
 
 <script setup>
 import gql from 'graphql-tag'
-import { cloneDeep, sampleSize } from 'lodash-es'
+import { cloneDeep, sample, sampleSize } from 'lodash-es'
 import zxcvbn from 'zxcvbn'
 import { useI18n } from 'vue-i18n'
 import { useDialogPluginComponent, useQuasar } from 'quasar'
@@ -287,8 +287,9 @@ async function loadGroups () {
 }
 
 function randomizePassword () {
-  const pwdChars = 'abcdefghkmnpqrstuvwxyzABCDEFHJKLMNPQRSTUVWXYZ23456789_*=?#!()+'
-  state.userPassword = sampleSize(pwdChars, 16).join('')
+  const pwdChars = 'abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789' // omit easily confused chars like O,0 or I,1,l
+  const withSymbols = `${pwdChars}_*=?#!()+-$%&.`
+  state.userPassword = `${sample(pwdChars)}${sampleSize(withSymbols, 15).join('')}`
 }
 
 async function create () {

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

@@ -58,6 +58,8 @@ body::-webkit-scrollbar-thumb {
 .bg-dark-5 { background-color: #0d1117; }
 .bg-dark-4 { background-color: #161b22; }
 .bg-dark-3 { background-color: #1e232a; }
+.bg-dark-2 { background-color: #292f39; }
+.bg-dark-1 { background-color: #343b48; }
 
 // ------------------------------------------------------------------
 // FORMS

+ 2 - 0
ux/src/css/quasar.variables.scss

@@ -30,3 +30,5 @@ $dark-6: #070a0d;
 $dark-5: #0d1117;
 $dark-4: #161b22;
 $dark-3: #1e232a;
+$dark-2: #292f39;
+$dark-1: #343b48;

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

@@ -498,7 +498,7 @@
   "admin.storage.actionsInactiveWarn": "You must enable this storage target and apply changes before you can run actions.",
   "admin.storage.addTarget": "Add Storage Target",
   "admin.storage.assetDelivery": "Asset Delivery",
-  "admin.storage.assetDeliveryHint": "Select how uploaded assets should be delivered to the user. Note that not all storage origins support asset delivery and can only be used for backup purposes.",
+  "admin.storage.assetDeliveryHint": "Select how uploaded assets should be delivered to the user. Note that not all storage origins support asset delivery and some can only be used for backup purposes.",
   "admin.storage.assetDirectAccess": "Direct Access",
   "admin.storage.assetDirectAccessHint": "Assets are accessed directly by the user using a secure / signed link. When enabled, takes priority over file streaming.",
   "admin.storage.assetDirectAccessNotSupported": "Not supported by this storage target.",
@@ -1467,5 +1467,13 @@
   "admin.api.createSuccess": "API Key created successfully.",
   "admin.api.revoked": "Revoked",
   "admin.api.revokedHint": "This key has been revoked and can no longer be used.",
-  "admin.storage.setupRequired": "Setup required"
+  "admin.storage.setupRequired": "Setup required",
+  "admin.blocks.title": "Content Blocks",
+  "common.error.title": "Error",
+  "common.error.unauthorized.title": "Unauthorized",
+  "common.error.unauthorized.hint": "You don't have the required permissions to access this page.",
+  "common.error.generic.title": "Unexpected Error",
+  "common.error.generic.hint": "Oops, something went wrong...",
+  "common.error.notfound.title": "Not Found",
+  "common.error.notfound.hint": "That page doesn't exist or is not available."
 }

+ 4 - 0
ux/src/layouts/AdminLayout.vue

@@ -82,6 +82,10 @@ q-layout.admin(view='hHh Lpr lff')
           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')

+ 10 - 1
ux/src/pages/AdminApi.vue

@@ -19,7 +19,7 @@ q-page.admin-api
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/api'
+        :href='siteStore.docsBase + `/dev/api`'
         target='_blank'
         type='a'
         )
@@ -103,10 +103,18 @@ import { DateTime } from 'luxon'
 import ApiKeyCreateDialog from '../components/ApiKeyCreateDialog.vue'
 import ApiKeyRevokeDialog from '../components/ApiKeyRevokeDialog.vue'
 
+import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
+
 // QUASAR
 
 const $q = useQuasar()
 
+// STORES
+
+const adminStore = useAdminStore()
+const siteStore = useSiteStore()
+
 // I18N
 
 const { t } = useI18n()
@@ -154,6 +162,7 @@ async function load () {
   })
   state.keys = cloneDeep(resp?.data?.apiKeys) ?? []
   state.enabled = resp?.data?.apiState === true
+  adminStore.info.isApiEnabled = state.enabled
   $q.loading.hide()
   state.loading--
 }

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

@@ -11,7 +11,7 @@ q-page.admin-mail
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/auth'
+        :href='siteStore.docsBase + `/admin/auth`'
         target='_blank'
         type='a'
         )

+ 8 - 2
ux/src/pages/AdminEditors.vue

@@ -11,7 +11,7 @@ q-page.admin-flags
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/editors'
+        :href='siteStore.docsBase + `/admin/editors`'
         target='_blank'
         type='a'
         )
@@ -70,7 +70,13 @@ import { useMeta } from 'quasar'
 import { useI18n } from 'vue-i18n'
 import { defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue'
 
-// STORES / ROUTERS / i18n
+import { useSiteStore } from 'src/stores/site'
+
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
 
 const { t } = useI18n()
 

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

@@ -11,7 +11,7 @@ q-page.admin-extensions
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/extensions'
+        :href='siteStore.docsBase + `/system/extensions`'
         target='_blank'
         type='a'
         )

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

@@ -11,7 +11,7 @@ q-page.admin-flags
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/flags'
+        :href='siteStore.docsBase + `/system/flags`'
         target='_blank'
         type='a'
         )
@@ -90,11 +90,17 @@ import { transform } from 'lodash-es'
 import { useMeta, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 
+import { useSiteStore } from 'src/stores/site'
+
 // QUASAR
 
 const $q = useQuasar()
 
-// STORES / ROUTERS / i18n
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
 
 const { t } = useI18n()
 

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

@@ -11,7 +11,7 @@ q-page.admin-general
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/general'
+        :href='siteStore.docsBase + `/admin/sites`'
         target='_blank'
         type='a'
         )
@@ -770,10 +770,17 @@ onMounted(() => {
   &-favicontabs {
     overflow: hidden;
     border-radius: 5px;
-    background-color: rgba(0,0,0,.1);
     display: flex;
     padding: 5px 5px 0 12px;
 
+    @at-root .body--light & {
+      background-color: rgba(0,0,0,.1);
+    }
+
+    @at-root .body--dark & {
+      background-color: rgba(255,255,255,.1);
+    }
+
     > div {
       display: flex;
       padding: 4px 12px;

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

@@ -20,7 +20,7 @@ q-page.admin-groups
         flat
         color='grey'
         type='a'
-        href='https://docs.js.wiki/admin/groups'
+        :href='siteStore.docsBase + `/admin/groups`'
         target='_blank'
         )
       q-btn.q-mr-sm.acrylic-btn(
@@ -100,6 +100,7 @@ import { computed, onBeforeUnmount, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 import GroupCreateDialog from '../components/GroupCreateDialog.vue'
 import GroupDeleteDialog from '../components/GroupDeleteDialog.vue'
@@ -111,6 +112,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const siteStore = useSiteStore()
 
 // ROUTER
 

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

@@ -20,7 +20,7 @@ q-page.admin-locale
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/locale'
+        :href='siteStore.docsBase + `/admin/locale`'
         target='_blank'
         type='a'
         )

+ 3 - 1
ux/src/pages/AdminLogin.vue

@@ -11,7 +11,7 @@ q-page.admin-login
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/login'
+        :href='siteStore.docsBase + `/admin/auth`'
         target='_blank'
         type='a'
         )
@@ -185,6 +185,7 @@ import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, watch } from 'vue'
 
 import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 // QUASAR
 
@@ -193,6 +194,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const siteStore = useSiteStore()
 
 // I18N
 

+ 3 - 1
ux/src/pages/AdminMail.vue

@@ -11,7 +11,7 @@ q-page.admin-mail
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/mail'
+        :href='siteStore.docsBase + `/system/mail`'
         target='_blank'
         type='a'
         )
@@ -380,6 +380,7 @@ async function load () {
       throw new Error('Failed to fetch mail config.')
     }
     state.config = cloneDeep(resp.data.mailConfig)
+    adminStore.info.isMailConfigured = state.config?.host?.length > 2
   } catch (err) {
     $q.notify({
       type: 'negative',
@@ -455,6 +456,7 @@ async function save () {
       type: 'positive',
       message: t('admin.mail.saveSuccess')
     })
+    adminStore.info.isMailConfigured = state.config?.host?.length > 2
   } catch (err) {
     $q.notify({
       type: 'negative',

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

@@ -11,7 +11,7 @@ q-page.admin-navigation
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.requarks.io/admin/navigation'
+        :href='siteStore.docsBase + `/admin/navigation`'
         target='_blank'
         type='a'
         )

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

@@ -11,7 +11,7 @@ q-page.admin-mail
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/security'
+        :href='siteStore.docsBase + `/system/security`'
         target='_blank'
         type='a'
         )

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

@@ -12,7 +12,7 @@ q-page.admin-locale
         flat
         color='grey'
         type='a'
-        href='https://docs.js.wiki/admin/sites'
+        :href='siteStore.docsBase + `/admin/sites`'
         target='_blank'
         )
       q-btn.q-mr-sm.acrylic-btn(
@@ -104,6 +104,7 @@ import { nextTick, onMounted } from 'vue'
 import { useRouter } from 'vue-router'
 
 import { useAdminStore } from '../stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 // COMPONENTS
 
@@ -118,6 +119,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const siteStore = useSiteStore()
 
 // ROUTER
 

+ 6 - 3
ux/src/pages/AdminStorage.vue

@@ -16,7 +16,10 @@ q-page.admin-storage
         v-model='state.displayMode'
         push
         no-caps
-        toggle-color='black'
+        :toggle-color='$q.dark.isActive ? `white` : `black`'
+        :toggle-text-color='$q.dark.isActive ? `black` : `white`'
+        :text-color='$q.dark.isActive ? `white` : `black`'
+        :color='$q.dark.isActive ? `dark-1` : `white`'
         :options=`[
           { label: t('admin.storage.targets'), value: 'targets' },
           { label: t('admin.storage.deliveryPaths'), value: 'delivery' }
@@ -27,7 +30,7 @@ q-page.admin-storage
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/storage'
+        :href='siteStore.docsBase + `/admin/storage`'
         target='_blank'
         type='a'
         )
@@ -512,7 +515,7 @@ q-page.admin-storage
           :edges='state.deliveryEdges'
           :paths='state.deliveryPaths'
           :layouts='state.deliveryLayouts'
-          style='height: 600px;'
+          style='height: 600px; background-color: #FFF;'
           )
           template(#override-node='{ nodeId, scale, config, ...slotProps }')
             rect(

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

@@ -11,7 +11,7 @@ q-page.admin-system
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/system'
+        :href='siteStore.docsBase + `/system/`'
         target='_blank'
         type='a'
         )
@@ -243,12 +243,18 @@ import { useMeta, useQuasar } from 'quasar'
 import { computed, onMounted, reactive, ref, watch } from 'vue'
 import ClipboardJS from 'clipboard'
 
+import { useSiteStore } from 'src/stores/site'
+
 import CheckUpdateDialog from '../components/CheckUpdateDialog.vue'
 
 // QUASAR
 
 const $q = useQuasar()
 
+// STORES
+
+const siteStore = useSiteStore()
+
 // I18N
 
 const { t } = useI18n()

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

@@ -12,7 +12,7 @@ q-page.admin-theme
         flat
         color='grey'
         type='a'
-        href='https://docs.js.wiki/admin/theme'
+        :href='siteStore.docsBase + `/admin/theme`'
         target='_blank'
         )
       q-btn.q-mr-sm.acrylic-btn(

+ 3 - 1
ux/src/pages/AdminUsers.vue

@@ -20,7 +20,7 @@ q-page.admin-groups
         flat
         color='grey'
         type='a'
-        href='https://docs.js.wiki/admin/users'
+        :href='siteStore.docsBase + `/admin/groups`'
         target='_blank'
         )
       q-btn.q-mr-sm.acrylic-btn(
@@ -114,6 +114,7 @@ import { onBeforeUnmount, onMounted, reactive, watch } from 'vue'
 import { useRouter, useRoute } from 'vue-router'
 
 import { useAdminStore } from 'src/stores/admin'
+import { useSiteStore } from 'src/stores/site'
 
 import UserCreateDialog from '../components/UserCreateDialog.vue'
 
@@ -124,6 +125,7 @@ const $q = useQuasar()
 // STORES
 
 const adminStore = useAdminStore()
+const siteStore = useSiteStore()
 
 // ROUTER
 

+ 8 - 2
ux/src/pages/AdminUtilities.vue

@@ -11,7 +11,7 @@ q-page.admin-utilities
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/utilities'
+        :href='siteStore.docsBase + `/admin/utilities`'
         target='_blank'
         type='a'
         )
@@ -104,11 +104,17 @@ import { computed, reactive } from 'vue'
 import { useMeta, useQuasar } from 'quasar'
 import { useI18n } from 'vue-i18n'
 
+import { useSiteStore } from 'src/stores/site'
+
 // QUASAR
 
 const $q = useQuasar()
 
-// STORES / ROUTERS / i18n
+// STORES
+
+const siteStore = useSiteStore()
+
+// I18N
 
 const { t } = useI18n()
 

+ 7 - 1
ux/src/pages/AdminWebhooks.vue

@@ -11,7 +11,7 @@ q-page.admin-webhooks
         icon='las la-question-circle'
         flat
         color='grey'
-        href='https://docs.js.wiki/admin/webhooks'
+        :href='siteStore.docsBase + `/system/webhooks`'
         target='_blank'
         type='a'
         )
@@ -99,6 +99,8 @@ import { useI18n } from 'vue-i18n'
 import { useMeta, useQuasar } from 'quasar'
 import { onMounted, reactive } from 'vue'
 
+import { useSiteStore } from 'src/stores/site'
+
 import WebhookEditDialog from 'src/components/WebhookEditDialog.vue'
 import WebhookDeleteDialog from 'src/components/WebhookDeleteDialog.vue'
 
@@ -106,6 +108,10 @@ import WebhookDeleteDialog from 'src/components/WebhookDeleteDialog.vue'
 
 const $q = useQuasar()
 
+// STORES
+
+const siteStore = useSiteStore()
+
 // I18N
 
 const { t } = useI18n()

+ 137 - 0
ux/src/pages/ErrorGeneric.vue

@@ -0,0 +1,137 @@
+<template lang='pug'>
+.errorpage
+  .errorpage-bg
+  .errorpage-content
+    .errorpage-code {{error.code}}
+    .errorpage-title {{error.title}}
+    .errorpage-hint {{error.hint}}
+    .errorpage-actions
+      q-btn(
+        push
+        color='primary'
+        label='Go to home'
+        icon='las la-home'
+        to='/'
+      )
+      q-btn.q-ml-md(
+        v-if='error.showLoginBtn'
+        push
+        color='primary'
+        label='Login As...'
+        icon='las la-sign-in-alt'
+        to='/login'
+      )
+
+</template>
+
+<script setup>
+import { useI18n } from 'vue-i18n'
+import { useMeta } from 'quasar'
+import { computed } from 'vue'
+import { useRoute } from 'vue-router'
+
+const actions = {
+  unauthorized: {
+    code: 403,
+    showLoginBtn: true
+  },
+  notfound: {
+    code: 404
+  },
+  generic: {
+    code: '!?0'
+  }
+}
+
+// ROUTER
+
+const route = useRoute()
+
+// I18N
+
+const { t } = useI18n()
+
+// META
+
+useMeta({
+  title: t('common.error.title')
+})
+
+// COMPUTED
+
+const error = computed(() => {
+  if (route.params.action && actions[route.params.action]) {
+    return {
+      ...actions[route.params.action],
+      title: t(`common.error.${route.params.action}.title`),
+      hint: t(`common.error.${route.params.action}.hint`)
+    }
+  } else {
+    return {
+      ...actions.generic,
+      title: t('common.error.generic.title'),
+      hint: t('common.error.generic.hint')
+    }
+  }
+})
+</script>
+
+<style lang="scss">
+  .errorpage {
+    background: $dark-6 radial-gradient(ellipse, $dark-4, $dark-6);
+    color: #FFF;
+    height: 100vh;
+
+    &-bg {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      width: 320px;
+      height: 320px;
+      background: linear-gradient(0, transparent 50%, $red-9 50%);
+      border-radius: 50%;
+      filter: blur(80px);
+      transform: translate(-50%, -50%);
+      visibility: hidden;
+    }
+
+    &-content {
+      position: absolute;
+      top: 50%;
+      left: 50%;
+      transform: translate(-50%, -50%);
+      display: flex;
+      flex-direction: column;
+      justify-content: center;
+      align-items: center;
+    }
+
+    &-code {
+      font-size: 12rem;
+      line-height: 12rem;
+      font-weight: 700;
+      background: linear-gradient(45deg, $red-9, $red-3);
+      background-clip: text;
+      -webkit-text-fill-color: transparent;
+      user-select: none;
+    }
+
+    &-title {
+      font-size: 5rem;
+      font-weight: 500;
+      line-height: 5rem;
+    }
+
+    &-hint {
+      font-size: 1.2rem;
+      font-weight: 500;
+      color: $red-3;
+      line-height: 1.2rem;
+      margin-top: 1rem;
+    }
+
+    &-actions {
+      margin-top: 2rem;
+    }
+  }
+</style>

+ 30 - 26
ux/src/router/routes.js

@@ -10,50 +10,54 @@ const routes = [
   // },
   {
     path: '/login',
-    component: () => import('../layouts/AuthLayout.vue'),
+    component: () => import('layouts/AuthLayout.vue'),
     children: [
-      { path: '', component: () => import('../pages/Login.vue') }
+      { path: '', component: () => import('pages/Login.vue') }
     ]
   },
   {
     path: '/_profile',
-    component: () => import('../layouts/ProfileLayout.vue'),
+    component: () => import('layouts/ProfileLayout.vue'),
     children: [
       { path: '', redirect: '/_profile/info' },
-      { path: 'info', component: () => import('../pages/Profile.vue') }
+      { path: 'info', component: () => import('pages/Profile.vue') }
     ]
   },
   {
     path: '/_admin',
-    component: () => import('../layouts/AdminLayout.vue'),
+    component: () => import('layouts/AdminLayout.vue'),
     children: [
       { path: '', redirect: '/_admin/dashboard' },
-      { path: 'dashboard', component: () => import('../pages/AdminDashboard.vue') },
-      { path: 'sites', component: () => import('../pages/AdminSites.vue') },
+      { path: 'dashboard', component: () => import('pages/AdminDashboard.vue') },
+      { path: 'sites', component: () => import('pages/AdminSites.vue') },
       // -> Site
-      { path: ':siteid/general', component: () => import('../pages/AdminGeneral.vue') },
-      { path: ':siteid/editors', component: () => import('../pages/AdminEditors.vue') },
-      { path: ':siteid/locale', component: () => import('../pages/AdminLocale.vue') },
-      { path: ':siteid/login', component: () => import('../pages/AdminLogin.vue') },
-      { path: ':siteid/navigation', component: () => import('../pages/AdminNavigation.vue') },
-      // { path: ':siteid/rendering', component: () => import('../pages/AdminRendering.vue') },
-      { path: ':siteid/storage/:id?', component: () => import('../pages/AdminStorage.vue') },
-      { path: ':siteid/theme', component: () => import('../pages/AdminTheme.vue') },
+      { path: ':siteid/general', component: () => import('pages/AdminGeneral.vue') },
+      { path: ':siteid/editors', component: () => import('pages/AdminEditors.vue') },
+      { path: ':siteid/locale', component: () => import('pages/AdminLocale.vue') },
+      { path: ':siteid/login', component: () => import('pages/AdminLogin.vue') },
+      { path: ':siteid/navigation', component: () => import('pages/AdminNavigation.vue') },
+      // { path: ':siteid/rendering', component: () => import('pages/AdminRendering.vue') },
+      { path: ':siteid/storage/:id?', component: () => import('pages/AdminStorage.vue') },
+      { path: ':siteid/theme', component: () => import('pages/AdminTheme.vue') },
       // -> Users
-      { path: 'auth', component: () => import('../pages/AdminAuth.vue') },
-      { path: 'groups/:id?/:section?', component: () => import('../pages/AdminGroups.vue') },
-      { path: 'users/:id?/:section?', component: () => import('../pages/AdminUsers.vue') },
+      { path: 'auth', component: () => import('pages/AdminAuth.vue') },
+      { path: 'groups/:id?/:section?', component: () => import('pages/AdminGroups.vue') },
+      { path: 'users/:id?/:section?', component: () => import('pages/AdminUsers.vue') },
       // -> System
-      { path: 'api', component: () => import('../pages/AdminApi.vue') },
-      { path: 'extensions', component: () => import('../pages/AdminExtensions.vue') },
-      { path: 'mail', component: () => import('../pages/AdminMail.vue') },
-      { path: 'security', component: () => import('../pages/AdminSecurity.vue') },
-      { path: 'system', component: () => import('../pages/AdminSystem.vue') },
-      { path: 'utilities', component: () => import('../pages/AdminUtilities.vue') },
-      { path: 'webhooks', component: () => import('../pages/AdminWebhooks.vue') },
-      { path: 'flags', component: () => import('../pages/AdminFlags.vue') }
+      { path: 'api', component: () => import('pages/AdminApi.vue') },
+      { path: 'extensions', component: () => import('pages/AdminExtensions.vue') },
+      { path: 'mail', component: () => import('pages/AdminMail.vue') },
+      { path: 'security', component: () => import('pages/AdminSecurity.vue') },
+      { path: 'system', component: () => import('pages/AdminSystem.vue') },
+      { path: 'utilities', component: () => import('pages/AdminUtilities.vue') },
+      { path: 'webhooks', component: () => import('pages/AdminWebhooks.vue') },
+      { path: 'flags', component: () => import('pages/AdminFlags.vue') }
     ]
   },
+  {
+    path: '/_error/:action?',
+    component: () => import('pages/ErrorGeneric.vue')
+  },
   // {
   //   path: '/_unknown-site',
   //   component: () => import('../pages/UnknownSite.vue')

+ 2 - 1
ux/src/stores/site.js

@@ -50,7 +50,8 @@ export const useSiteStore = defineStore('site', {
       backgroundColor: '#FAFAFA',
       width: '9px',
       opacity: 1
-    }
+    },
+    docsBase: 'https://next.js.wiki/docs'
   }),
   getters: {},
   actions: {