feat: update permission system + dark theme fixes + logout

pull/6775/head
NGPixel 2 years ago
parent 59f6b6fedc
commit 960a8a03b2
No known key found for this signature in database
GPG Key ID: B755FB6870B30F63

@ -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,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'

@ -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')

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

@ -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 } = {}) {

@ -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,5 +1,5 @@
/* global WIKI */
import bcrypt from 'bcryptjs-then'
import bcrypt from 'bcryptjs'
// ------------------------------------
// Local Account

@ -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",

@ -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",

@ -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>

@ -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') }}

@ -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

@ -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

@ -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

@ -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: {

@ -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
// ------------------------------------------------------------------

@ -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%;

@ -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",

@ -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({

@ -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

@ -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')

@ -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(

@ -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

@ -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')

@ -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'

@ -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

@ -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')

@ -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'

@ -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)

@ -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'

@ -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')}}

@ -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

@ -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

@ -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'

@ -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

@ -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'

@ -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(

@ -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>

@ -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>

@ -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',

@ -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()
})
}

@ -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')

@ -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;

@ -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)

@ -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}!`)
}
}
}
})

Loading…
Cancel
Save