Fix role pickers to have limit and be searchable
This commit is contained in:
@@ -1,26 +1,24 @@
|
||||
<template>
|
||||
<div
|
||||
data-test-id="role-picker"
|
||||
class="d-flex flex-column"
|
||||
>
|
||||
<c-input-select
|
||||
ref="picker"
|
||||
<c-input-role
|
||||
data-test-id="input-role-picker"
|
||||
:options="filtered"
|
||||
:get-option-key="r => r.value"
|
||||
:get-option-label="r => getRoleLabel(r)"
|
||||
:selectable="r => !value.includes(r.roleID)"
|
||||
:placeholder="$t('admin:picker.role.placeholder')"
|
||||
:filterable="false"
|
||||
@search="search"
|
||||
@input="updateValue($event)"
|
||||
:visible="isRoleVisible"
|
||||
clear-on-select
|
||||
@input="addRole($event)"
|
||||
/>
|
||||
|
||||
<b-spinner
|
||||
v-if="preloading"
|
||||
class="mx-auto my-4"
|
||||
/>
|
||||
<b-form-text
|
||||
v-if="$slots['description']"
|
||||
>
|
||||
<slot name="description" />
|
||||
</b-form-text>
|
||||
|
||||
<b-table-simple
|
||||
v-if="selected"
|
||||
v-else-if="getSelectedRoles.length"
|
||||
responsive
|
||||
small
|
||||
hover
|
||||
@@ -28,7 +26,7 @@
|
||||
>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="role in selected"
|
||||
v-for="role in getSelectedRoles"
|
||||
:key="role.roleID"
|
||||
data-test-id="selected-row-list"
|
||||
>
|
||||
@@ -39,7 +37,7 @@
|
||||
<c-input-confirm
|
||||
data-test-id="button-remove-role"
|
||||
show-icon
|
||||
@confirmed="removeRole(role)"
|
||||
@confirmed="removeRole(role.roleID)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -49,19 +47,15 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
function roleSorter (a, b) {
|
||||
return `${a.name} ${a.handle} ${a.roleID}`.localeCompare(`${b.name} ${b.handle} ${b.roleID}`)
|
||||
}
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { CInputRole } = components
|
||||
|
||||
export default {
|
||||
props: {
|
||||
label: {
|
||||
type: String,
|
||||
default: 'count',
|
||||
},
|
||||
components: {
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
props: {
|
||||
// list of role IDs
|
||||
value: {
|
||||
type: Array,
|
||||
@@ -71,77 +65,58 @@ export default {
|
||||
|
||||
data () {
|
||||
return {
|
||||
roles: [],
|
||||
fetching: false,
|
||||
preloading: false,
|
||||
|
||||
filter: '',
|
||||
|
||||
selectedRoles: [],
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
selected () {
|
||||
return this.roles
|
||||
.filter(({ roleID }) => (this.value || []).includes(roleID))
|
||||
.sort(roleSorter)
|
||||
},
|
||||
|
||||
filtered () {
|
||||
const match = ({ name = '', handle = '', roleID = '' }) => {
|
||||
return `${name} ${handle} ${roleID}`.toLocaleLowerCase().indexOf(this.filter.toLocaleLowerCase()) > -1
|
||||
}
|
||||
|
||||
const fits = ({ isClosed, meta = {} }) => {
|
||||
return !(isClosed || (meta.context && meta.context.resourceTypes))
|
||||
}
|
||||
|
||||
return this.roles.filter(r => !(this.value || []).includes(r.roleID) && fits(r) && match(r))
|
||||
getSelectedRoles () {
|
||||
return this.selectedRoles.filter(({ roleID }) => this.value.includes(roleID))
|
||||
},
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.preload()
|
||||
this.preloadSelected()
|
||||
},
|
||||
|
||||
methods: {
|
||||
addRole (role) {
|
||||
if (!this.value.includes(role.roleID)) {
|
||||
this.value.push(role.roleID)
|
||||
this.$emit('input', this.value)
|
||||
this.selectedRoles.push(role)
|
||||
this.$emit('input', [...this.value, role.roleID])
|
||||
}
|
||||
},
|
||||
|
||||
removeRole (r) {
|
||||
this.value.splice(this.value.indexOf(r.roleID), 1)
|
||||
this.filter = ''
|
||||
removeRole (roleID) {
|
||||
this.selectedRoles = this.selectedRoles.filter(({ roleID: rID }) => rID !== roleID)
|
||||
this.$emit('input', this.value.filter(v => v !== roleID))
|
||||
},
|
||||
|
||||
preload () {
|
||||
return this.$SystemAPI.roleList({ query: this.filter })
|
||||
.then(({ set }) => { this.roles = set || [] })
|
||||
preloadSelected () {
|
||||
this.preloading = true
|
||||
|
||||
return this.$SystemAPI.roleList({ memberID: this.$auth.user.userID })
|
||||
.then(({ set }) => { this.selectedRoles = set || [] })
|
||||
.finally(() => { this.preloading = false })
|
||||
.catch(this.toastErrorHandler(this.$t('notification:role.fetch.error')))
|
||||
},
|
||||
|
||||
search: debounce(function (query = '') {
|
||||
if (query !== this.filter) {
|
||||
this.filter = query
|
||||
}
|
||||
|
||||
this.preload()
|
||||
}, 300),
|
||||
|
||||
updateValue (role) {
|
||||
// reset picker value for better value presentation
|
||||
if (this.$refs.picker) {
|
||||
this.$refs.picker._data._value = undefined
|
||||
}
|
||||
|
||||
this.addRole(role)
|
||||
},
|
||||
|
||||
getRoleLabel ({ name, handle, roleID }) {
|
||||
return name || handle || roleID
|
||||
},
|
||||
|
||||
isRoleVisible ({ isClosed, meta = {} }) {
|
||||
return !(isClosed || (meta.context && meta.context.resourceTypes))
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.results {
|
||||
z-index: 100;
|
||||
|
||||
@@ -19,23 +19,19 @@
|
||||
centered
|
||||
:title="$t('ui.clone.title')"
|
||||
:ok-title="$t('ui.clone.clone')"
|
||||
:ok-disabled="!selectedRoles.length || processingRoles || processingSubmit"
|
||||
@ok="clonePermissions()"
|
||||
:ok-disabled="!selectedRoles.length || processingSubmit"
|
||||
@ok="clonePermissions"
|
||||
>
|
||||
<b-form-group
|
||||
:description="$t('ui.clone.description')"
|
||||
class="mb-0"
|
||||
>
|
||||
<c-input-select
|
||||
<c-input-role
|
||||
v-model="selectedRoles"
|
||||
data-test-id="select-role-list"
|
||||
label="name"
|
||||
:options="roles"
|
||||
:get-option-key="getOptionKey"
|
||||
:reduce="role => role.roleID"
|
||||
:loading="processingRoles"
|
||||
multiple
|
||||
:selectable="r => !selectedRoles.some(rr => rr.roleID === r.roleID)"
|
||||
:placeholder="$t('ui.clone.pick-role')"
|
||||
multiple
|
||||
/>
|
||||
</b-form-group>
|
||||
</b-modal>
|
||||
@@ -43,11 +39,18 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { CInputRole } = components
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'permissions',
|
||||
},
|
||||
|
||||
components: {
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
props: {
|
||||
roleId: {
|
||||
type: String,
|
||||
@@ -60,32 +63,19 @@ export default {
|
||||
return {
|
||||
showModal: false,
|
||||
|
||||
roles: [],
|
||||
selectedRoles: [],
|
||||
|
||||
processingSubmit: false,
|
||||
processingRoles: false,
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.processingRoles = true
|
||||
|
||||
this.$SystemAPI.roleList()
|
||||
.then(({ set: roles = [] }) => {
|
||||
this.roles = roles
|
||||
})
|
||||
.catch(this.toastErrorHandler(this.$t('notification:role.fetch.error')))
|
||||
.finally(() => {
|
||||
this.processingRoles = false
|
||||
})
|
||||
},
|
||||
|
||||
methods: {
|
||||
clonePermissions () {
|
||||
this.processingSubmit = true
|
||||
|
||||
this.$SystemAPI.roleCloneRules({ roleID: this.roleId, cloneToRoleID: this.selectedRoles })
|
||||
const cloneToRoleID = this.selectedRoles.map(({ roleID }) => roleID)
|
||||
|
||||
this.$SystemAPI.roleCloneRules({ roleID: this.roleId, cloneToRoleID })
|
||||
.then(() => {
|
||||
this.selectedRoles = []
|
||||
this.toastSuccess(this.$t('notification:permissions.clone.success'))
|
||||
@@ -93,13 +83,8 @@ export default {
|
||||
.catch(this.toastErrorHandler(this.$t('notification:permissions.clone.error')))
|
||||
.finally(() => {
|
||||
this.processingSubmit = false
|
||||
this.showModal = false
|
||||
})
|
||||
},
|
||||
|
||||
getOptionKey ({ roleID }) {
|
||||
return roleID
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -217,15 +217,13 @@
|
||||
label-class="text-primary"
|
||||
class="mb-0"
|
||||
>
|
||||
<c-input-select
|
||||
<c-input-role
|
||||
v-model="add.roleID"
|
||||
:data-test-id="`select-${add.mode}-roles`"
|
||||
:options="availableRoles"
|
||||
:get-option-key="getOptionRoleKey"
|
||||
label="name"
|
||||
:placeholder="$t('ui.add.role.placeholder')"
|
||||
:visible="isRoleVisible"
|
||||
:multiple="add.mode === 'eval'"
|
||||
:disabled="add.mode === 'eval' && !!add.userID"
|
||||
:placeholder="$t('ui.add.role.placeholder')"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
@@ -252,23 +250,24 @@
|
||||
|
||||
<script>
|
||||
import _ from 'lodash'
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { CInputRole } = components
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'permissions',
|
||||
},
|
||||
|
||||
components: {
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
props: {
|
||||
roles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
allRoles: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
|
||||
permissions: {
|
||||
type: Object,
|
||||
required: true,
|
||||
@@ -331,20 +330,6 @@ export default {
|
||||
},
|
||||
|
||||
computed: {
|
||||
editableRoles () {
|
||||
return this.roles.filter(({ mode }) => mode !== 'eval').map(({ roleID }) => roleID.roleID)
|
||||
},
|
||||
|
||||
availableRoles () {
|
||||
if (this.add.mode === 'edit') {
|
||||
return this.allRoles.filter(({ roleID, isBypass }) => !isBypass && !this.editableRoles.includes(roleID))
|
||||
} else if (this.add.mode === 'eval') {
|
||||
return this.allRoles
|
||||
}
|
||||
|
||||
return []
|
||||
},
|
||||
|
||||
sortedPermissions () {
|
||||
return Object.keys(this.permissions).sort()
|
||||
},
|
||||
@@ -444,6 +429,10 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
isRoleVisible ({ isBypass }) {
|
||||
return this.add.mode === 'edit' || !isBypass
|
||||
},
|
||||
|
||||
getUserLabel (userID) {
|
||||
return this.fetchedUsers[userID]
|
||||
},
|
||||
@@ -472,7 +461,14 @@ export default {
|
||||
},
|
||||
|
||||
onAdd () {
|
||||
this.$emit('add', { ...this.add, userID: { userID: this.add.userID, name: this.fetchedUsers[this.add.userID] } })
|
||||
let { userID } = this.add
|
||||
|
||||
if (userID) {
|
||||
userID = { userID: this.add.userID, name: this.fetchedUsers[this.add.userID] }
|
||||
}
|
||||
|
||||
this.$emit('add', { ...this.add, userID })
|
||||
|
||||
this.add = {
|
||||
mode: 'edit',
|
||||
roleID: [],
|
||||
@@ -484,10 +480,6 @@ export default {
|
||||
this.$emit('hide', role)
|
||||
},
|
||||
|
||||
getOptionRoleKey ({ roleID }) {
|
||||
return roleID
|
||||
},
|
||||
|
||||
setDefaultValues () {
|
||||
this.add = {}
|
||||
this.modeOptions = []
|
||||
|
||||
@@ -8,7 +8,10 @@
|
||||
<b-form
|
||||
@submit.prevent="$emit('submit')"
|
||||
>
|
||||
<c-role-picker v-model="value" />
|
||||
<c-role-picker
|
||||
:value="value"
|
||||
@input="$emit('input', $event)"
|
||||
/>
|
||||
</b-form>
|
||||
|
||||
<template #header>
|
||||
@@ -59,17 +62,5 @@ export default {
|
||||
value: false,
|
||||
},
|
||||
},
|
||||
|
||||
computed: {
|
||||
roles: {
|
||||
get () {
|
||||
return this.currentRoles
|
||||
},
|
||||
|
||||
set (roles) {
|
||||
this.$emit('update:current-roles', roles)
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -59,25 +59,19 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchRoles () {
|
||||
prepareRoles () {
|
||||
this.incLoader()
|
||||
|
||||
return this.$SystemAPI.roleList()
|
||||
.then(({ set }) => {
|
||||
this.allRoles = set
|
||||
this.rolePermissions = []
|
||||
const roleIDs = this.allRoles.map(({ roleID }) => roleID)
|
||||
this.rolePermissions = []
|
||||
|
||||
// We read permissions for included roles
|
||||
return Promise.all(getIncludedRoles().filter(({ roleID, mode }) => mode === 'eval' || roleIDs.includes(roleID)).map(({ mode, name, roleID, userID }) => {
|
||||
if (mode === 'edit') {
|
||||
return this.readPermissions({ name, roleID })
|
||||
} else {
|
||||
return this.evaluatePermissions({ name, roleID, userID })
|
||||
}
|
||||
}))
|
||||
})
|
||||
.catch(this.toastErrorHandler(this.$t('notification:user.roles.error')))
|
||||
// We read permissions for included roles
|
||||
return Promise.all(getIncludedRoles().map(({ mode, name, roleID, userID }) => {
|
||||
if (mode === 'edit') {
|
||||
return this.readPermissions({ name, roleID })
|
||||
} else {
|
||||
return this.evaluatePermissions({ name, roleID, userID })
|
||||
}
|
||||
})).catch(this.toastErrorHandler(this.$t('notification:user.roles.error')))
|
||||
.finally(() => {
|
||||
this.loaded.roles = true
|
||||
this.decLoader()
|
||||
@@ -101,7 +95,7 @@ export default {
|
||||
return map
|
||||
}, {})
|
||||
})
|
||||
.then(() => this.fetchRoles())
|
||||
.then(() => this.prepareRoles())
|
||||
.catch(this.toastErrorHandler(this.$t('notification:permissions.fetch.system')))
|
||||
.finally(() => {
|
||||
this.loaded.permissions = true
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
<c-permission-list
|
||||
:roles="sortedRoles"
|
||||
:all-roles="allRoles"
|
||||
:permissions="permissions"
|
||||
:role-permissions="rolePermissions"
|
||||
:can-grant="canGrant"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
<c-permission-list
|
||||
:roles="sortedRoles"
|
||||
:all-roles="allRoles"
|
||||
:permissions="permissions"
|
||||
:role-permissions="rolePermissions"
|
||||
:can-grant="canGrant"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
<c-permission-list
|
||||
:roles="sortedRoles"
|
||||
:all-roles="allRoles"
|
||||
:permissions="permissions"
|
||||
:role-permissions="rolePermissions"
|
||||
:can-grant="canGrant"
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
|
||||
<c-permission-list
|
||||
:roles="sortedRoles"
|
||||
:all-roles="allRoles"
|
||||
:permissions="permissions"
|
||||
:role-permissions="rolePermissions"
|
||||
:can-grant="canGrant"
|
||||
|
||||
@@ -7,19 +7,19 @@
|
||||
</b-form-group>
|
||||
|
||||
<b-form-group
|
||||
v-if="f.options.roles"
|
||||
:label="$t('kind.user.roles.label')"
|
||||
label-class="text-primary"
|
||||
>
|
||||
<c-input-select
|
||||
v-model="f.options.roles"
|
||||
:options="roleOptions"
|
||||
:get-option-key="getOptionKey"
|
||||
:reduce="role => role.roleID"
|
||||
:close-on-select="false"
|
||||
<b-spinner
|
||||
v-if="preloadingRoles"
|
||||
/>
|
||||
|
||||
<c-input-role
|
||||
v-else
|
||||
v-model="currentRoles"
|
||||
:placeholder="$t('kind.user.roles.placeholder')"
|
||||
multiple
|
||||
label="name"
|
||||
@input="f.options.roles = $event.map(r => r.roleID)"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
@@ -52,13 +52,20 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
import base from './base'
|
||||
|
||||
const { CInputRole } = components
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
namespaces: 'field',
|
||||
},
|
||||
|
||||
components: {
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
|
||||
data () {
|
||||
@@ -68,7 +75,9 @@ export default {
|
||||
{ text: this.$t('kind.select.optionType.multiple'), value: 'multiple' },
|
||||
{ text: this.$t('kind.select.optionType.each'), value: 'each', allowDuplicates: true },
|
||||
],
|
||||
roleOptions: [],
|
||||
|
||||
preloadingRoles: true,
|
||||
currentRoles: [],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -82,9 +91,17 @@ export default {
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.$SystemAPI.roleList().then(({ set: roles = [] }) => {
|
||||
this.roleOptions = roles
|
||||
})
|
||||
if (this.f.options.roles.length) {
|
||||
this.preloadingRoles = true
|
||||
|
||||
Promise.all(this.f.options.roles.map(roleID => {
|
||||
return this.$SystemAPI.roleRead({ roleID }).then(role => {
|
||||
this.currentRoles.push(role)
|
||||
})
|
||||
})).finally(() => {
|
||||
this.preloadingRoles = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
|
||||
@@ -291,101 +291,103 @@
|
||||
label-class="text-primary"
|
||||
class="mb-0"
|
||||
>
|
||||
<b-table-simple
|
||||
v-if="recordListModule"
|
||||
borderless
|
||||
small
|
||||
responsive="lg"
|
||||
class="mb-0"
|
||||
>
|
||||
<draggable
|
||||
:list.sync="options.filterPresets"
|
||||
group="sort"
|
||||
handle=".grab"
|
||||
tag="tbody"
|
||||
<b-spinner v-if="fetchingRoles" />
|
||||
|
||||
<template v-else>
|
||||
<b-table-simple
|
||||
v-if="recordListModule"
|
||||
borderless
|
||||
small
|
||||
responsive="lg"
|
||||
class="mb-0"
|
||||
>
|
||||
<b-tr
|
||||
v-for="(filter, index) in options.filterPresets"
|
||||
:key="index"
|
||||
<draggable
|
||||
:list.sync="options.filterPresets"
|
||||
group="sort"
|
||||
handle=".grab"
|
||||
tag="tbody"
|
||||
>
|
||||
<b-td
|
||||
class="grab text-center align-middle"
|
||||
style="width: 40px;"
|
||||
<b-tr
|
||||
v-for="(filter, index) in options.filterPresets"
|
||||
:key="index"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="text-secondary"
|
||||
/>
|
||||
</b-td>
|
||||
|
||||
<b-td
|
||||
class="align-middle"
|
||||
style="min-width: 150px;"
|
||||
>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="filter.name"
|
||||
:placeholder="$t('recordList.filter.name.placeholder')"
|
||||
<b-td
|
||||
class="grab text-center align-middle"
|
||||
style="width: 40px;"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'bars']"
|
||||
class="text-secondary"
|
||||
/>
|
||||
</b-td>
|
||||
|
||||
<b-input-group-append>
|
||||
<record-list-filter
|
||||
class="d-print-none"
|
||||
:target="`record-filter-${index}`"
|
||||
:namespace="namespace"
|
||||
:module="recordListModule"
|
||||
:selected-field="recordListModule.fields[0]"
|
||||
:record-list-filter="filter.filter"
|
||||
variant="extra-light"
|
||||
inactive-icon-class="text-light"
|
||||
button-class="px-2 pt-2"
|
||||
button-style="border-top-left-radius: 0; border-bottom-left-radius: 0;"
|
||||
@filter="(filter) => onFilter(filter, index)"
|
||||
<b-td
|
||||
class="align-middle"
|
||||
style="min-width: 150px;"
|
||||
>
|
||||
<b-input-group>
|
||||
<b-form-input
|
||||
v-model="filter.name"
|
||||
:placeholder="$t('recordList.filter.name.placeholder')"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-td>
|
||||
|
||||
<b-td
|
||||
class="text-center align-middle"
|
||||
style="min-width: 200px;"
|
||||
>
|
||||
<c-input-select
|
||||
v-model="filter.roles"
|
||||
:options="roleOptions"
|
||||
:get-option-label="getRoleLabel"
|
||||
:get-option-key="getOptionKey"
|
||||
:placeholder="$t('recordList.filter.role.placeholder')"
|
||||
:reduce="role => role.roleID"
|
||||
multiple
|
||||
/>
|
||||
</b-td>
|
||||
<b-input-group-append>
|
||||
<record-list-filter
|
||||
class="d-print-none"
|
||||
:target="`record-filter-${index}`"
|
||||
:namespace="namespace"
|
||||
:module="recordListModule"
|
||||
:selected-field="recordListModule.fields[0]"
|
||||
:record-list-filter="filter.filter"
|
||||
variant="extra-light"
|
||||
inactive-icon-class="text-light"
|
||||
button-class="px-2 pt-2"
|
||||
button-style="border-top-left-radius: 0; border-bottom-left-radius: 0;"
|
||||
@filter="(filter) => onFilter(filter, index)"
|
||||
/>
|
||||
</b-input-group-append>
|
||||
</b-input-group>
|
||||
</b-td>
|
||||
|
||||
<b-td
|
||||
class="text-right align-middle"
|
||||
style="min-width: 80px; width: 80px;"
|
||||
>
|
||||
<c-input-confirm
|
||||
show-icon
|
||||
@confirmed="options.filterPresets.splice(index, 1)"
|
||||
/>
|
||||
</b-td>
|
||||
</b-tr>
|
||||
</draggable>
|
||||
</b-table-simple>
|
||||
<b-td
|
||||
class="text-center align-middle"
|
||||
style="min-width: 200px;"
|
||||
>
|
||||
<c-input-role
|
||||
:value="getFilterRoles(filter)"
|
||||
:placeholder="$t('recordList.filter.role.placeholder')"
|
||||
:visible="isRoleVisible"
|
||||
multiple
|
||||
@input="onFilterRoleChange(filter, $event)"
|
||||
/>
|
||||
</b-td>
|
||||
|
||||
<b-button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="mt-1"
|
||||
@click="addFilterPreset"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ $t('general:label.add') }}
|
||||
</b-button>
|
||||
<b-td
|
||||
class="text-right align-middle"
|
||||
style="min-width: 80px; width: 80px;"
|
||||
>
|
||||
<c-input-confirm
|
||||
show-icon
|
||||
@confirmed="options.filterPresets.splice(index, 1)"
|
||||
/>
|
||||
</b-td>
|
||||
</b-tr>
|
||||
</draggable>
|
||||
</b-table-simple>
|
||||
|
||||
<b-button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
class="mt-1"
|
||||
@click="addFilterPreset"
|
||||
>
|
||||
<font-awesome-icon
|
||||
:icon="['fas', 'plus']"
|
||||
class="mr-1"
|
||||
/>
|
||||
{{ $t('general:label.add') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</b-form-group>
|
||||
</div>
|
||||
</b-col>
|
||||
@@ -792,7 +794,7 @@ import AutomationTab from './Shared/AutomationTab'
|
||||
import FieldPicker from 'corteza-webapp-compose/src/components/Common/FieldPicker'
|
||||
import RecordListFilter from 'corteza-webapp-compose/src/components/Common/RecordListFilter'
|
||||
import { components } from '@cortezaproject/corteza-vue'
|
||||
const { CInputPresort } = components
|
||||
const { CInputPresort, CInputRole } = components
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
@@ -807,6 +809,7 @@ export default {
|
||||
CInputPresort,
|
||||
RecordListFilter,
|
||||
Draggable,
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
extends: base,
|
||||
@@ -817,7 +820,9 @@ export default {
|
||||
on: this.$t('general:label.yes'),
|
||||
off: this.$t('general:label.no'),
|
||||
},
|
||||
roleOptions: [],
|
||||
|
||||
fetchingRoles: false,
|
||||
resolvedRoles: {},
|
||||
}
|
||||
},
|
||||
|
||||
@@ -969,14 +974,44 @@ export default {
|
||||
},
|
||||
|
||||
methods: {
|
||||
getRoleLabel ({ name }) {
|
||||
return name
|
||||
async fetchRoles () {
|
||||
this.fetchingRoles = true
|
||||
|
||||
if (this.options.filterPresets.length) {
|
||||
const rolesToResolve = this.options.filterPresets.reduce((acc, { roles }) => {
|
||||
return acc.concat(roles)
|
||||
}, [])
|
||||
|
||||
Promise.all(rolesToResolve.map(roleID => {
|
||||
if (this.resolvedRoles[roleID]) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
return this.$SystemAPI.roleRead({ roleID }).then(role => {
|
||||
this.resolvedRoles[roleID] = role
|
||||
})
|
||||
})).finally(() => {
|
||||
this.fetchingRoles = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
async fetchRoles () {
|
||||
this.$SystemAPI.roleList().then(({ set: roles = [] }) => {
|
||||
this.roleOptions = roles.filter(({ meta }) => !(meta.context && meta.context.resourceTypes))
|
||||
onFilterRoleChange (filter, roles) {
|
||||
roles.forEach(r => {
|
||||
if (!this.resolvedRoles[r.roleID]) {
|
||||
this.resolvedRoles[r.roleID] = r
|
||||
}
|
||||
})
|
||||
|
||||
filter.roles = roles.map(({ roleID }) => roleID)
|
||||
},
|
||||
|
||||
getFilterRoles (filter) {
|
||||
return filter.roles.map(roleID => this.resolvedRoles[roleID])
|
||||
},
|
||||
|
||||
isRoleVisible ({ meta }) {
|
||||
return !(meta.context && meta.context.resourceTypes)
|
||||
},
|
||||
|
||||
onFilter (filter = [], index) {
|
||||
@@ -997,7 +1032,7 @@ export default {
|
||||
|
||||
setDefaultValues () {
|
||||
this.checkboxLabel = {}
|
||||
this.roleOptions = []
|
||||
this.resolvedRoles = {}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -539,15 +539,14 @@
|
||||
:label="$t('page-layout.roles.label')"
|
||||
label-class="text-primary"
|
||||
>
|
||||
<c-input-select
|
||||
v-model="currentLayoutRoles"
|
||||
:options="roles.options"
|
||||
:loading="roles.processing"
|
||||
<b-spinner v-if="resolvingLayoutRoles" />
|
||||
|
||||
<c-input-role
|
||||
v-else
|
||||
:value="getLayoutRoles()"
|
||||
:placeholder="$t('page-layout.roles.placeholder')"
|
||||
:get-option-label="role => role.name"
|
||||
:reduce="role => role.roleID"
|
||||
:selectable="role => !currentLayoutRoles.includes(role.roleID)"
|
||||
multiple
|
||||
@input="onLayoutRoleChange"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
@@ -952,7 +951,8 @@ import pages from 'corteza-webapp-compose/src/mixins/pages'
|
||||
import Uploader from 'corteza-webapp-compose/src/components/Public/Page/Attachment/Uploader'
|
||||
import Draggable from 'vuedraggable'
|
||||
import { compose, NoID } from '@cortezaproject/corteza-js'
|
||||
import { handle } from '@cortezaproject/corteza-vue'
|
||||
import { handle, components } from '@cortezaproject/corteza-vue'
|
||||
const { CInputRole } = components
|
||||
|
||||
export default {
|
||||
i18nOptions: {
|
||||
@@ -967,6 +967,7 @@ export default {
|
||||
PageLayoutTranslator,
|
||||
Uploader,
|
||||
Draggable,
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
mixins: [
|
||||
@@ -1017,19 +1018,15 @@ export default {
|
||||
layout: undefined,
|
||||
},
|
||||
|
||||
removedLayouts: new Set(),
|
||||
resolvingLayoutRoles: false,
|
||||
resolvedRoles: {},
|
||||
|
||||
roles: {
|
||||
processing: false,
|
||||
options: [],
|
||||
},
|
||||
removedLayouts: new Set(),
|
||||
|
||||
checkboxLabel: {
|
||||
on: this.$t('general:label.yes'),
|
||||
off: this.$t('general:label.no'),
|
||||
},
|
||||
|
||||
abortableRequests: [],
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1200,12 +1197,7 @@ export default {
|
||||
},
|
||||
},
|
||||
|
||||
created () {
|
||||
this.fetchRoles()
|
||||
},
|
||||
|
||||
beforeDestroy () {
|
||||
this.abortRequests()
|
||||
this.setDefaultValues()
|
||||
},
|
||||
|
||||
@@ -1229,20 +1221,40 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRoles () {
|
||||
this.roles.processing = true
|
||||
async resolveLayoutRoles () {
|
||||
this.resolvingLayoutRoles = true
|
||||
|
||||
const { response, cancel } = this.$SystemAPI
|
||||
.roleListCancellable({})
|
||||
if (this.currentLayoutRoles.length) {
|
||||
Promise.all(this.currentLayoutRoles.map(roleID => {
|
||||
if (this.resolvedRoles[roleID]) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
|
||||
this.abortableRequests.push(cancel)
|
||||
|
||||
response()
|
||||
.then(({ set: roles = [] }) => {
|
||||
this.roles.options = roles.filter(({ meta }) => !(meta.context && meta.context.resourceTypes))
|
||||
}).finally(() => {
|
||||
this.roles.processing = false
|
||||
return this.$SystemAPI.roleRead({ roleID }).then(role => {
|
||||
this.resolvedRoles[roleID] = role
|
||||
})
|
||||
})).finally(() => {
|
||||
this.resolvingLayoutRoles = false
|
||||
})
|
||||
}
|
||||
},
|
||||
|
||||
getLayoutRoles () {
|
||||
return this.currentLayoutRoles.map(roleID => this.resolvedRoles[roleID])
|
||||
},
|
||||
|
||||
onLayoutRoleChange (roles) {
|
||||
roles.forEach(r => {
|
||||
if (!this.resolvedRoles[r.roleID]) {
|
||||
this.resolvedRoles[r.roleID] = r
|
||||
}
|
||||
})
|
||||
|
||||
this.currentLayoutRoles = roles.map(r => r.roleID)
|
||||
},
|
||||
|
||||
isRoleVisible ({ meta }) {
|
||||
return !(meta.context && meta.context.resourceTypes)
|
||||
},
|
||||
|
||||
addLayout () {
|
||||
@@ -1268,6 +1280,8 @@ export default {
|
||||
configureLayout (index) {
|
||||
this.layoutEditor.index = index
|
||||
this.layoutEditor.layout = new compose.PageLayout(this.layouts[index])
|
||||
|
||||
this.resolveLayoutRoles()
|
||||
},
|
||||
|
||||
async handleSaveLayouts () {
|
||||
@@ -1490,17 +1504,11 @@ export default {
|
||||
this.linkUrl = ''
|
||||
this.layouts = []
|
||||
this.layoutEditor = {}
|
||||
this.resolvedRoles = {}
|
||||
this.removedLayouts.clear()
|
||||
this.roles = {}
|
||||
this.checkboxLabel = {}
|
||||
this.abortableRequests = []
|
||||
},
|
||||
|
||||
abortRequests () {
|
||||
this.abortableRequests.forEach((cancel) => {
|
||||
cancel()
|
||||
})
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
CAceEditor,
|
||||
CButtonSubmit,
|
||||
CInputSelect,
|
||||
CInputRole,
|
||||
} from './input'
|
||||
|
||||
export {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
<template>
|
||||
<c-input-select
|
||||
ref="picker"
|
||||
:value="value"
|
||||
:options="roles"
|
||||
:placeholder="placeholder"
|
||||
:get-option-key="r => r.roleID"
|
||||
:get-option-label="r => getRoleLabel(r)"
|
||||
:filterable="false"
|
||||
:selectable="selectable"
|
||||
:multiple="multiple"
|
||||
:clearable="clearable"
|
||||
:loading="loading"
|
||||
@search="search"
|
||||
@input="updateValue"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { debounce } from 'lodash'
|
||||
|
||||
export default {
|
||||
props: {
|
||||
value: {
|
||||
type: Array | String | Object,
|
||||
default: '',
|
||||
},
|
||||
|
||||
visible: {
|
||||
type: Function,
|
||||
default: () => true,
|
||||
},
|
||||
|
||||
placeholder: {
|
||||
type: String,
|
||||
default: 'Start typing to search for roles',
|
||||
},
|
||||
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
clearOnSelect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
selectable: {
|
||||
type: Function,
|
||||
default: () => true,
|
||||
},
|
||||
|
||||
clearable: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
|
||||
preselect: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
}
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
loading: false,
|
||||
|
||||
roles: [],
|
||||
filter: '',
|
||||
}
|
||||
},
|
||||
|
||||
mounted () {
|
||||
this.fetchRoles()
|
||||
},
|
||||
|
||||
methods: {
|
||||
fetchRoles () {
|
||||
this.loading = true
|
||||
|
||||
return this.$SystemAPI.roleList({ query: this.filter, limit: 20 })
|
||||
.then(({ set }) => {
|
||||
this.roles = set.filter(this.visible)
|
||||
|
||||
if (this.preselect && (!this.value || !this.value.length)) {
|
||||
this.updateValue(this.roles[0])
|
||||
}
|
||||
}).finally(() => {
|
||||
const timeout = this.filter ? 300 : 0
|
||||
|
||||
setTimeout(() => {
|
||||
this.loading = false
|
||||
}, timeout)
|
||||
})
|
||||
},
|
||||
|
||||
search: debounce(function (query = '') {
|
||||
if (query !== this.filter) {
|
||||
this.filter = query
|
||||
}
|
||||
|
||||
this.fetchRoles()
|
||||
}, 400),
|
||||
|
||||
updateValue (role) {
|
||||
// reset picker value for better value presentation
|
||||
if (this.$refs.picker && this.clearOnSelect) {
|
||||
this.$refs.picker._data._value = undefined
|
||||
}
|
||||
|
||||
this.$emit('input', role)
|
||||
},
|
||||
|
||||
getRoleLabel ({ name, handle, roleID }) {
|
||||
return name || handle || roleID
|
||||
},
|
||||
},
|
||||
}
|
||||
</script>
|
||||
@@ -2,12 +2,15 @@
|
||||
<vue-select
|
||||
v-model="_value"
|
||||
v-bind="$attrs"
|
||||
ref="vueSelect"
|
||||
data-test-id="select"
|
||||
:clearable="clearable"
|
||||
:options="options"
|
||||
:searchable="searchable"
|
||||
:disabled="disabled"
|
||||
:selectable="selectable"
|
||||
:multiple="multiple"
|
||||
:loading="loading"
|
||||
:calculate-position="calculateDropdownPosition"
|
||||
:append-to-body="appendToBody"
|
||||
class="bg-white rounded"
|
||||
@@ -40,7 +43,7 @@ export default {
|
||||
|
||||
props: {
|
||||
value: {
|
||||
type: [String, Array],
|
||||
type: [String, Array, Object],
|
||||
default: () => '',
|
||||
},
|
||||
|
||||
@@ -67,7 +70,7 @@ export default {
|
||||
},
|
||||
|
||||
defaultValue: {
|
||||
type: [String, Array],
|
||||
type: [String, Array, Object],
|
||||
default: () => '',
|
||||
},
|
||||
|
||||
@@ -85,12 +88,28 @@ export default {
|
||||
type: Function,
|
||||
default: o => !o.disabled,
|
||||
},
|
||||
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
query: '',
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
_value: {
|
||||
get () {
|
||||
const fallbackValue = this.$attrs.multiple ? [] : ''
|
||||
const fallbackValue = this.multiple ? [] : ''
|
||||
return !!this.defaultValue && (this.value === this.defaultValue) ? fallbackValue : this.value
|
||||
},
|
||||
|
||||
@@ -148,8 +167,20 @@ export default {
|
||||
return () => popper.destroy()
|
||||
},
|
||||
|
||||
onSearch (search, loading) {
|
||||
this.$emit('search', search, loading)
|
||||
onSearch (query, loading) {
|
||||
if (this.loading) {
|
||||
if (this.$refs.vueSelect) {
|
||||
this.$refs.vueSelect._data.search = this.query
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (query !== this.query) {
|
||||
this.query = query
|
||||
}
|
||||
|
||||
this.$emit('search', query, loading)
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -207,10 +238,8 @@ export default {
|
||||
// force this to not use any space
|
||||
// we still need it to be rendered for the focus
|
||||
width: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
border: none;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.vs__dropdown-toggle {
|
||||
@@ -267,6 +296,11 @@ export default {
|
||||
}
|
||||
}
|
||||
|
||||
.vs__spinner {
|
||||
border: .7em solid var(--dark);
|
||||
border-left-color: var(--white);
|
||||
}
|
||||
|
||||
.vs__spinner, .vs__spinner::after {
|
||||
width: 4em;
|
||||
height: 4em;
|
||||
|
||||
@@ -12,3 +12,4 @@ export {
|
||||
CButtonSubmit,
|
||||
} from './button'
|
||||
export { default as CInputSelect } from './CInputSelect.vue'
|
||||
export { default as CInputRole } from './CInputRole.vue'
|
||||
|
||||
@@ -43,16 +43,12 @@
|
||||
:label="labels.edit.label"
|
||||
label-class="text-primary"
|
||||
>
|
||||
<c-input-select
|
||||
v-model="currentRoleID"
|
||||
<c-input-role
|
||||
data-test-id="select-user-list-roles"
|
||||
label="name"
|
||||
:disabled="!currentRoleID"
|
||||
v-model="currentRoleID"
|
||||
:visible="isRoleVisible"
|
||||
:clearable="false"
|
||||
:options="roles"
|
||||
:get-option-key="getOptionRoleKey"
|
||||
:reduce="o => o.roleID"
|
||||
append-to-body
|
||||
preselect
|
||||
@input="onRoleChange"
|
||||
/>
|
||||
</b-form-group>
|
||||
@@ -195,15 +191,12 @@
|
||||
label-class="text-primary"
|
||||
class="mb-0"
|
||||
>
|
||||
<c-input-select
|
||||
<c-input-role
|
||||
data-test-id="select-role"
|
||||
:placeholder="labels.add.role.placeholder"
|
||||
v-model="add.roleID"
|
||||
:options="roles"
|
||||
:get-option-key="getOptionRoleKey"
|
||||
label="name"
|
||||
multiple
|
||||
:disabled="!!add.userID"
|
||||
:placeholder="labels.add.role.placeholder"
|
||||
/>
|
||||
</b-form-group>
|
||||
|
||||
@@ -240,6 +233,7 @@
|
||||
<script lang="js">
|
||||
import { modalOpenEventName, split } from './def.ts'
|
||||
import CInputSelect from '../input/CInputSelect.vue'
|
||||
import CInputRole from '../input/CInputRole.vue'
|
||||
import Rules from './form/Rules.vue'
|
||||
|
||||
export default {
|
||||
@@ -250,6 +244,7 @@ export default {
|
||||
components: {
|
||||
Rules,
|
||||
CInputSelect,
|
||||
CInputRole,
|
||||
},
|
||||
|
||||
props: {
|
||||
@@ -280,9 +275,6 @@ export default {
|
||||
// List of rules for the current role
|
||||
rules: [],
|
||||
|
||||
// List of all available roles
|
||||
roles: [],
|
||||
|
||||
currentRoleID: undefined,
|
||||
|
||||
evaluate: [],
|
||||
@@ -363,10 +355,9 @@ export default {
|
||||
this.backendComponentName = resource.split(':')[2]
|
||||
|
||||
this.fetchPermissions().then(() => {
|
||||
if (!this.roles.length) {
|
||||
return this.fetchRoles()
|
||||
} else if (this.currentRoleID) {
|
||||
return this.reEvaluatePermissions(this.currentRoleID)
|
||||
if (this.currentRoleID) {
|
||||
const { roleID } = this.currentRoleID
|
||||
return this.reEvaluatePermissions(roleID)
|
||||
}
|
||||
}).finally(() => {
|
||||
this.processing = false
|
||||
@@ -379,7 +370,7 @@ export default {
|
||||
this.target = undefined
|
||||
},
|
||||
|
||||
onRoleChange (roleID) {
|
||||
onRoleChange ({ roleID }) {
|
||||
this.processing = true
|
||||
|
||||
this.fetchRules(roleID).finally(() => {
|
||||
@@ -391,7 +382,7 @@ export default {
|
||||
this.submitting = true
|
||||
|
||||
const rules = this.collectChangedRules()
|
||||
const roleID = this.currentRoleID
|
||||
const { roleID } = this.currentRoleID
|
||||
|
||||
this.api.permissionsUpdate({ roleID, rules }).then(() => {
|
||||
this.reEvaluatePermissions(roleID)
|
||||
@@ -420,18 +411,8 @@ export default {
|
||||
})
|
||||
},
|
||||
|
||||
async fetchRoles () {
|
||||
// Roles are always fetched from $SystemAPI.
|
||||
return this.$SystemAPI.roleList().then(({ set }) => {
|
||||
this.roles = set
|
||||
.filter(({ isBypass }) => !isBypass)
|
||||
.sort((a, b) => a.roleID.localeCompare(b.roleID))
|
||||
|
||||
if (this.roles.length > 0) {
|
||||
this.currentRoleID = this.roles[0].roleID
|
||||
this.onRoleChange(this.currentRoleID)
|
||||
}
|
||||
})
|
||||
isRoleVisible ({ isBypass }) {
|
||||
return !isBypass
|
||||
},
|
||||
|
||||
async evaluatePermissions ({ resource = this.resource, roleID, userID }) {
|
||||
@@ -607,7 +588,6 @@ export default {
|
||||
this.userOptions = []
|
||||
this.permissions = []
|
||||
this.rules = []
|
||||
this.roles = []
|
||||
this.currentRoleID = undefined
|
||||
this.evaluate = []
|
||||
this.add = {}
|
||||
|
||||
@@ -8,11 +8,15 @@ ui:
|
||||
set-for: Set permissions for {{target}}
|
||||
loading: Loading permissions
|
||||
|
||||
notification:
|
||||
save:
|
||||
success: Permissions saved
|
||||
failed: Failed to save permissions
|
||||
|
||||
edit:
|
||||
title: Edit permissions
|
||||
label: User roles
|
||||
description: Select role to set permissions
|
||||
edit-or-eval: Edit or evaluate permissions
|
||||
|
||||
evaluate:
|
||||
title: Evaluated permissions
|
||||
@@ -46,6 +50,7 @@ ui:
|
||||
#############################################################################
|
||||
# Inline permission table UI:
|
||||
click-on-cell-to-allow: Click on permission/role cell to allow a specific operation
|
||||
edit-or-eval: Edit or evaluate permissions
|
||||
|
||||
title:
|
||||
automation: Automation permissions
|
||||
@@ -56,8 +61,8 @@ ui:
|
||||
clone:
|
||||
clone: Clone
|
||||
label: Clone permissions
|
||||
pick-role: Pick a role
|
||||
title: Clone permissions to
|
||||
pick-role: Start typing to search for roles
|
||||
title: Apply permissions to
|
||||
description: This will clone all the permissions of the current role and apply them to the selected
|
||||
|
||||
resources:
|
||||
|
||||
@@ -5,6 +5,11 @@ ui:
|
||||
loading: Loading permissions
|
||||
label: Permissions
|
||||
|
||||
notification:
|
||||
save:
|
||||
success: Permissions saved
|
||||
failed: Failed to save permissions
|
||||
|
||||
edit:
|
||||
label: User roles
|
||||
description: Select role to set permissions
|
||||
|
||||
@@ -5,6 +5,11 @@ ui:
|
||||
loading: Loading permissions
|
||||
label: Permissions
|
||||
|
||||
notification:
|
||||
save:
|
||||
success: Permissions saved
|
||||
failed: Failed to save permissions
|
||||
|
||||
edit:
|
||||
label: User roles
|
||||
description: Select role to set permissions
|
||||
|
||||
Reference in New Issue
Block a user