372 lines
11 KiB
Vue
372 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { t } from '@/i18n'
|
|
import { computed, onMounted, ref } from 'vue'
|
|
import DashboardShell from '@/components/DashboardShell.vue'
|
|
import PermissionGrid from '@/components/PermissionGrid.vue'
|
|
import PermissionGroup from '@/components/PermissionGroup.vue'
|
|
import PresetPills from '@/components/PresetPills.vue'
|
|
import RoleDifferencesDialog from '@/components/RoleDifferencesDialog.vue'
|
|
import RoleNotices from '@/components/RoleNotices.vue'
|
|
import RolePreview from '@/components/RolePreview.vue'
|
|
import RoleSaveBar from '@/components/RoleSaveBar.vue'
|
|
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
|
import { useLocalizedOverlay } from '@/i18n/presentation'
|
|
import { useRoleEditor } from '@/composables/useRoleEditor'
|
|
import type { CallerScope } from '@/lib/grantLocks'
|
|
import { usePermissionsStore } from '@/stores/permissions'
|
|
import { useIsDesktop } from '@/composables/useIsDesktop'
|
|
|
|
const CONTENT_GROUP = 'content'
|
|
|
|
const permissions = usePermissionsStore()
|
|
const isDesktop = useIsDesktop('64rem')
|
|
const editor = useRoleEditor()
|
|
const overlay = useLocalizedOverlay()
|
|
|
|
const sheetOpen = ref(false)
|
|
const openGroups = ref<string[]>([])
|
|
|
|
const callerScope: CallerScope = (key, options) => permissions.scopeOf(key, options)
|
|
|
|
const generalGroups = computed(() =>
|
|
editor.catalogue.value.filter((group) => group.key !== CONTENT_GROUP),
|
|
)
|
|
const allOpen = computed(
|
|
() => generalGroups.value.length > 0 && openGroups.value.length === generalGroups.value.length,
|
|
)
|
|
const title = computed(() => (editor.isNew.value ? t('New role') : (editor.role.value?.name ?? '')))
|
|
const warnMembers = computed(() => editor.role.value?.built_in === true && !editor.readOnly.value)
|
|
const canDuplicate = computed(() => permissions.can('roles.manage'))
|
|
const footerVisible = computed(
|
|
() => editor.barVisible.value || (!isDesktop.value && editor.loadState.value === 'ready'),
|
|
)
|
|
const fieldsDisabled = computed(() => editor.readOnly.value || editor.saving.value)
|
|
const typeLabel = computed(() => {
|
|
const role = editor.role.value
|
|
if (!role) {
|
|
return ''
|
|
}
|
|
return role.built_in ? t('Built-in') : t('Custom')
|
|
})
|
|
|
|
function toggleGroup(key: string, open: boolean) {
|
|
openGroups.value = open
|
|
? [...openGroups.value.filter((existing) => existing !== key), key]
|
|
: openGroups.value.filter((existing) => existing !== key)
|
|
}
|
|
|
|
function toggleAll() {
|
|
openGroups.value = allOpen.value ? [] : generalGroups.value.map((group) => group.key)
|
|
}
|
|
|
|
function confirmReset() {
|
|
const target = editor.role.value
|
|
if (!target) {
|
|
return
|
|
}
|
|
overlay
|
|
.create(ConfirmDialog, {
|
|
props: {
|
|
get title() {
|
|
return t('Reset {0} to default?', [target.name])
|
|
},
|
|
get description() {
|
|
return t('Its permissions go back to what RenewCMS ships with.')
|
|
},
|
|
get confirmLabel() {
|
|
return t('Reset to default')
|
|
},
|
|
get cancelLabel() {
|
|
return t('Cancel')
|
|
},
|
|
get pendingLabel() {
|
|
return t('Resetting…')
|
|
},
|
|
tone: 'danger',
|
|
action: editor.resetRole,
|
|
},
|
|
})
|
|
.open()
|
|
}
|
|
|
|
onMounted(editor.load)
|
|
</script>
|
|
|
|
<template>
|
|
<DashboardShell compact-padding sticky-footer>
|
|
<template #breadcrumb>
|
|
<UBreadcrumb
|
|
:items="[
|
|
{ label: t('Settings') },
|
|
{ label: t('Roles'), to: '/settings/roles' },
|
|
{ label: title },
|
|
]"
|
|
color="neutral"
|
|
class="hidden lg:block"
|
|
:ui="{ link: 'text-sm font-medium' }"
|
|
/>
|
|
</template>
|
|
<div
|
|
v-if="editor.loadState.value === 'loading'"
|
|
class="flex flex-col gap-3"
|
|
aria-busy="true"
|
|
>
|
|
<USkeleton class="h-8 w-60 rounded-none bg-accented" />
|
|
<USkeleton class="h-40 w-full rounded-none bg-accented" />
|
|
</div>
|
|
|
|
<UAlert
|
|
v-else-if="editor.loadState.value === 'error'"
|
|
role="alert"
|
|
color="error"
|
|
variant="soft"
|
|
orientation="vertical"
|
|
:close="false"
|
|
icon="i-lucide-circle-x"
|
|
:title="t('Couldn\'t load this role')"
|
|
:description="t('Check your connection and try again.')"
|
|
>
|
|
<template #actions>
|
|
<UButton color="neutral" variant="outline" class="h-8" @click="editor.load">
|
|
{{ t('Try again') }}
|
|
</UButton>
|
|
</template>
|
|
</UAlert>
|
|
|
|
<UEmpty
|
|
v-else-if="editor.loadState.value === 'missing'"
|
|
variant="naked"
|
|
icon="i-lucide-shield"
|
|
:title="t('This role no longer exists')"
|
|
:description="t('It may have been deleted.')"
|
|
:ui="{ root: 'gap-3 border border-default bg-default px-6 py-14' }"
|
|
>
|
|
<template #actions>
|
|
<UButton color="neutral" variant="outline" to="/settings/roles">
|
|
{{ t('Back to Roles') }}
|
|
</UButton>
|
|
</template>
|
|
</UEmpty>
|
|
|
|
<div v-else class="flex flex-col gap-4" :class="{ 'pb-20': !isDesktop }">
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<UButton
|
|
to="/settings/roles"
|
|
color="neutral"
|
|
variant="ghost"
|
|
icon="i-lucide-arrow-left"
|
|
:aria-label="t('Back to Roles')"
|
|
class="size-8 justify-center max-md:size-11"
|
|
/>
|
|
<h1 class="text-xl font-semibold text-highlighted">{{ title }}</h1>
|
|
<template v-if="editor.role.value">
|
|
<UBadge
|
|
v-if="editor.role.value.locked"
|
|
color="neutral"
|
|
variant="outline"
|
|
icon="i-lucide-lock"
|
|
:label="t('Locked')"
|
|
/>
|
|
<UBadge color="neutral" variant="subtle" :label="typeLabel" />
|
|
<span class="text-muted">{{
|
|
editor.role.value.members === 1
|
|
? t('1 member')
|
|
: t('{0} members', [editor.role.value.members])
|
|
}}</span>
|
|
</template>
|
|
<UButton
|
|
v-if="editor.canReset.value"
|
|
color="neutral"
|
|
variant="outline"
|
|
class="ms-auto h-8 px-3"
|
|
@click="confirmReset"
|
|
>
|
|
{{ t('Reset to default') }}
|
|
</UButton>
|
|
</div>
|
|
|
|
<RoleNotices
|
|
:role="editor.role.value"
|
|
:reason="editor.readOnlyReason.value"
|
|
:conflict="editor.conflict.value"
|
|
:refusal="editor.refusal.value"
|
|
:save-failed="editor.saveFailed.value"
|
|
:can-duplicate="canDuplicate"
|
|
:warn-members="warnMembers"
|
|
@duplicate="editor.duplicate"
|
|
@reload-role="editor.reloadRole"
|
|
@reload-page="editor.reloadPage"
|
|
@review="editor.openReview"
|
|
@retry-save="editor.save"
|
|
/>
|
|
|
|
<div class="grid gap-6 lg:grid-cols-[minmax(0,1fr)_22rem]">
|
|
<div
|
|
class="flex min-w-0 flex-col gap-6"
|
|
:class="{ 'opacity-60': editor.saving.value }"
|
|
:aria-busy="editor.saving.value ? 'true' : undefined"
|
|
>
|
|
<section class="flex flex-col gap-4">
|
|
<UFormField
|
|
:label="t('Name')"
|
|
:error="editor.nameError.value || undefined"
|
|
required
|
|
>
|
|
<UInput
|
|
:model-value="editor.form.value.name"
|
|
:placeholder="t('For example, Contributor')"
|
|
:disabled="fieldsDisabled"
|
|
class="w-full"
|
|
@update:model-value="editor.setName(String($event))"
|
|
/>
|
|
</UFormField>
|
|
<UFormField :label="t('Description (optional)')">
|
|
<UInput
|
|
:model-value="editor.form.value.description"
|
|
:placeholder="t('What this role is for')"
|
|
:disabled="fieldsDisabled"
|
|
class="w-full"
|
|
@update:model-value="editor.setDescription(String($event))"
|
|
/>
|
|
</UFormField>
|
|
</section>
|
|
|
|
<section class="flex flex-col gap-3 border border-default bg-default p-4">
|
|
<div class="flex flex-col gap-0.5">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
{{ t('Content permissions') }}
|
|
</h2>
|
|
<p class="text-muted">
|
|
{{
|
|
t(
|
|
'What this role can do with entries of each content type. Own means entries the person created.',
|
|
)
|
|
}}
|
|
</p>
|
|
</div>
|
|
<PresetPills
|
|
:active="editor.preset.value"
|
|
:disabled="editor.readOnly.value"
|
|
@select="editor.choosePreset"
|
|
/>
|
|
<PermissionGrid
|
|
:types="editor.gridTypes.value"
|
|
:grants="editor.form.value.grants"
|
|
:initial="editor.initial.value.grants"
|
|
:caller-scope="callerScope"
|
|
:read-only="editor.readOnly.value"
|
|
:mobile="!isDesktop"
|
|
@change="editor.changeCell"
|
|
@reset="editor.resetType"
|
|
/>
|
|
<UAlert
|
|
v-if="editor.readsOwn.value"
|
|
color="info"
|
|
variant="soft"
|
|
icon="i-lucide-info"
|
|
:close="false"
|
|
:title="t('Read: Own')"
|
|
:description="
|
|
t(
|
|
'Articles show online articles plus the person\'s own drafts. Custom content types show only entries the person created. Status counts follow the same rule.',
|
|
)
|
|
"
|
|
:ui="{
|
|
root: 'items-start gap-2.5 px-3.5 py-3',
|
|
title: 'font-semibold',
|
|
description: 'text-toned',
|
|
}"
|
|
/>
|
|
</section>
|
|
|
|
<section class="flex flex-col gap-3 border border-default bg-default p-4">
|
|
<div class="flex flex-wrap items-end justify-between gap-2">
|
|
<div class="flex flex-col gap-0.5">
|
|
<h2 class="text-base font-semibold text-highlighted">
|
|
{{ t('General permissions') }}
|
|
</h2>
|
|
<p class="text-muted">{{ t('Everything outside entries.') }}</p>
|
|
</div>
|
|
<UButton
|
|
color="neutral"
|
|
variant="outline"
|
|
class="h-8 px-3 max-md:h-11"
|
|
@click="toggleAll"
|
|
>
|
|
{{ allOpen ? t('Collapse all') : t('Expand all') }}
|
|
</UButton>
|
|
</div>
|
|
<div class="border border-default bg-default">
|
|
<PermissionGroup
|
|
v-for="group in generalGroups"
|
|
:key="group.key"
|
|
:open="openGroups.includes(group.key)"
|
|
:group="group"
|
|
:grants="editor.form.value.grants"
|
|
:initial="editor.initial.value.grants"
|
|
:caller-scope="callerScope"
|
|
:read-only="editor.readOnly.value"
|
|
@update:open="toggleGroup(group.key, $event)"
|
|
@change="editor.changeGeneral"
|
|
/>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
|
|
<aside v-if="isDesktop" class="min-w-0">
|
|
<div class="sticky top-4">
|
|
<RolePreview
|
|
:model="editor.preview.value"
|
|
:read-only="editor.readOnly.value"
|
|
/>
|
|
</div>
|
|
</aside>
|
|
</div>
|
|
</div>
|
|
|
|
<div v-if="footerVisible" class="sticky bottom-0 z-10 -mx-4 mt-auto flex flex-col lg:-mx-6">
|
|
<UButton
|
|
v-if="!isDesktop"
|
|
color="neutral"
|
|
variant="outline"
|
|
block
|
|
class="h-11 justify-center"
|
|
@click="sheetOpen = true"
|
|
>
|
|
{{ t('What this role can do') }}
|
|
</UButton>
|
|
<RoleSaveBar
|
|
v-if="editor.barVisible.value"
|
|
:count="editor.unsaved.value"
|
|
:problems="editor.problems.value"
|
|
:saving="editor.saving.value"
|
|
:conflicted="editor.conflict.value !== null"
|
|
:can-save="editor.canSave.value"
|
|
:name-missing="editor.nameMissing.value"
|
|
:is-new="editor.isNew.value"
|
|
@discard="editor.discard"
|
|
@save="editor.save"
|
|
/>
|
|
</div>
|
|
|
|
<RoleDifferencesDialog
|
|
v-if="editor.conflict.value"
|
|
:open="editor.reviewing.value"
|
|
:description="editor.conflictSummary.value"
|
|
:rows="editor.differenceRows.value"
|
|
:pending="editor.keeping.value"
|
|
@update:open="(value: boolean) => !value && editor.closeReview()"
|
|
@use-saved="editor.reloadRole"
|
|
@keep-edits="editor.keepEdits"
|
|
/>
|
|
|
|
<RolePreview
|
|
v-if="!isDesktop"
|
|
v-model:open="sheetOpen"
|
|
mobile
|
|
:model="editor.preview.value"
|
|
:read-only="editor.readOnly.value"
|
|
/>
|
|
</DashboardShell>
|
|
</template>
|