feat: SPA Roles screens

This commit is contained in:
Florian Sylvain committed 2026-10-27 23:05:04 +01:00
1 parent 907949aa12
commit 3db490a615
47 files changed
+6947 -9

No files matched your search

+330
View File
@@ -0,0 +1,330 @@
<script setup lang="ts">
import { t } from '@/i18n'
import { computed, onMounted, ref } from 'vue'
import { useMediaQuery } from '@vueuse/core'
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 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'
const DESKTOP_QUERY = '(min-width: 64rem)'
const CONTENT_GROUP = 'content'
const permissions = usePermissionsStore()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
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>
<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"
:can-duplicate="canDuplicate"
:warn-members="warnMembers"
:reviewing="editor.reviewing.value"
:differences="editor.differenceRows.value"
@duplicate="editor.duplicate"
@reload-role="editor.reloadRole"
@reload-page="editor.reloadPage"
@review="editor.toggleReview"
/>
<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">
<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"
/>
</section>
<section class="flex flex-col gap-3">
<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-4 flex flex-col">
<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>
<RolePreview
v-if="!isDesktop"
v-model:open="sheetOpen"
mobile
:model="editor.preview.value"
:read-only="editor.readOnly.value"
/>
</DashboardShell>
</template>