feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4308
-835
No files matched your search
@@ -8,6 +8,7 @@ import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import { authorsSummary } from '@/lib/articleMeta'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleMenu } from '@/composables/useArticleMenu'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
|
||||
const props = withDefaults(
|
||||
@@ -45,50 +46,14 @@ function pendingFor(article: Article) {
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Edit')
|
||||
},
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => router.push(editPath(article)),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return article.is_online ? t('Unpublish') : t('Publish')
|
||||
},
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
emit('unpublish', article.id)
|
||||
} else {
|
||||
emit('publish', article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Copy API URL')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => emit('copy-url', article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Delete…')
|
||||
},
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete', article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
const { actionsFor, publishHint } = useArticleMenu({
|
||||
isBusy,
|
||||
edit: (article) => router.push(editPath(article)),
|
||||
publish: (id) => emit('publish', id),
|
||||
unpublish: (id) => emit('unpublish', id),
|
||||
copyUrl: (id) => emit('copy-url', id),
|
||||
remove: (article) => emit('delete', article),
|
||||
})
|
||||
|
||||
function onRowClick(article: Article, event: MouseEvent) {
|
||||
if ((event.target as HTMLElement).closest('[data-row-menu]')) {
|
||||
@@ -139,6 +104,12 @@ function onRowClick(article: Article, event: MouseEvent) {
|
||||
size="sm"
|
||||
:pending="pendingFor(article)"
|
||||
/>
|
||||
<span
|
||||
v-if="publishHint(article)"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ publishHint(article) }}</span
|
||||
>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(article)"
|
||||
:chip="seoChipOf(article)!"
|
||||
|
||||
@@ -9,6 +9,7 @@ import AuthorStack from '@/components/AuthorStack.vue'
|
||||
import type { Site } from '@/composables/useSites'
|
||||
import { formatAbsoluteDateFromIso, formatRelativeDate } from '@/lib/relativeDate'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleMenu } from '@/composables/useArticleMenu'
|
||||
import { seoChipOf } from '@/lib/articleSeoStatus'
|
||||
import { DEFAULT_ARTICLE_SORT, type ArticleSortField } from '@/lib/articleListQuery'
|
||||
|
||||
@@ -177,50 +178,14 @@ function pendingFor(article: Article) {
|
||||
return article.is_online ? ('unpublish' as const) : ('publish' as const)
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
return [
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Edit')
|
||||
},
|
||||
icon: 'i-lucide-pencil',
|
||||
onSelect: () => router.push(editPath(article)),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return article.is_online ? t('Unpublish') : t('Publish')
|
||||
},
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
emit('unpublish', article.id)
|
||||
} else {
|
||||
emit('publish', article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
return t('Copy API URL')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => emit('copy-url', article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Delete…')
|
||||
},
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
onSelect: () => emit('delete', article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
const { actionsFor, publishHint } = useArticleMenu({
|
||||
isBusy,
|
||||
edit: (article) => router.push(editPath(article)),
|
||||
publish: (id) => emit('publish', id),
|
||||
unpublish: (id) => emit('unpublish', id),
|
||||
copyUrl: (id) => emit('copy-url', id),
|
||||
remove: (article) => emit('delete', article),
|
||||
})
|
||||
|
||||
function onSelect(_event: Event, row: { original: Article }) {
|
||||
if (props.loading) {
|
||||
@@ -364,6 +329,12 @@ function onSelect(_event: Event, row: { original: Article }) {
|
||||
:pending="pendingFor(row.original)"
|
||||
:aria-busy="isBusy(row.original) ? 'true' : undefined"
|
||||
/>
|
||||
<span
|
||||
v-if="publishHint(row.original)"
|
||||
class="sr-only"
|
||||
data-testid="publish-hint"
|
||||
>{{ publishHint(row.original) }}</span
|
||||
>
|
||||
<SeoStatusChip
|
||||
v-if="seoChipOf(row.original)"
|
||||
:chip="seoChipOf(row.original)!"
|
||||
|
||||
@@ -11,10 +11,12 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { baseSlugOf } from '@/lib/authorProfile'
|
||||
import { copy } from '@/lib/directoryCopy'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const SLUG_LOOKUP_SIZE = 25
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const profile = useMyAuthorProfile()
|
||||
const { listAuthors } = useAuthors()
|
||||
const toast = useLocalizedToast()
|
||||
@@ -25,7 +27,7 @@ const editing = ref<Author | null>(null)
|
||||
const loadingEdit = ref(false)
|
||||
|
||||
const author = computed(() => session.session?.author)
|
||||
const canManage = computed(() => session.canManageContent)
|
||||
const canManage = computed(() => permissions.scopeOf('authors.edit') === 'all')
|
||||
const nameLine = computed(() => {
|
||||
const displayName = session.session?.displayName
|
||||
return displayName
|
||||
|
||||
@@ -7,11 +7,15 @@ import ArticleListNotice from '@/components/ArticleListNotice.vue'
|
||||
import ContentEmpty from '@/components/ContentEmpty.vue'
|
||||
import ContentState from '@/components/ContentState.vue'
|
||||
import ContentToolbar from '@/components/ContentToolbar.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import EntryCardList from '@/components/EntryCardList.vue'
|
||||
import EntrySkeleton from '@/components/EntrySkeleton.vue'
|
||||
import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue'
|
||||
import EntryTable from '@/components/EntryTable.vue'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEntryRowDelete } from '@/composables/useEntryRowDelete'
|
||||
import type { ContentListPage } from '@/composables/useContentListPage'
|
||||
import { entriesLoadErrorLine, typeLoadErrorLine } from '@/lib/entryMessages'
|
||||
|
||||
@@ -40,6 +44,18 @@ const {
|
||||
retry,
|
||||
} = props.page
|
||||
|
||||
const access = useContentAccess(typeKey, 'entries')
|
||||
const rowDelete = useEntryRowDelete(
|
||||
typeKey,
|
||||
() => name.value,
|
||||
() => list.start(),
|
||||
)
|
||||
const createNote = computed(() =>
|
||||
access.canCreate.value && access.updatesOwnOnly.value
|
||||
? t('You can create entries and edit your own.')
|
||||
: '',
|
||||
)
|
||||
|
||||
const singlePageLabel = computed(() =>
|
||||
list.total.value === 1 ? t('1 entry') : t('{0} entries', [formatNumber(list.total.value)]),
|
||||
)
|
||||
@@ -66,11 +82,11 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ name }}
|
||||
</h1>
|
||||
<p v-if="type" class="text-muted">{{ subtitle }}</p>
|
||||
<p v-if="type" class="text-muted">{{ createNote || subtitle }}</p>
|
||||
</template>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
v-if="isDesktop && access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5"
|
||||
:disabled="view === 'loading'"
|
||||
@@ -80,6 +96,12 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="access.updatesOwnOnly.value && !failed"
|
||||
:title="t('Entries by others are view only')"
|
||||
:description="t('Your role lets you edit entries you created.')"
|
||||
/>
|
||||
|
||||
<ContentToolbar
|
||||
v-if="!failed"
|
||||
:sort="list.desired.value.sort"
|
||||
@@ -126,6 +148,7 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
:sort="list.shown.value.sort"
|
||||
:names="list.names.value"
|
||||
:caption="caption"
|
||||
@delete="rowDelete.open"
|
||||
/>
|
||||
<EntryCardList
|
||||
v-else
|
||||
@@ -153,10 +176,20 @@ const entriesErrorLine = computed(() => entriesLoadErrorLine(typeKey))
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !centered && !failed && view !== 'loading'"
|
||||
v-if="!isDesktop && !centered && !failed && view !== 'loading' && access.canCreate.value"
|
||||
pinned
|
||||
@click="createEntry"
|
||||
>
|
||||
{{ t('New entry') }}
|
||||
</DirectoryAddButton>
|
||||
|
||||
<EntryDeleteDialog
|
||||
v-if="rowDelete.target.value"
|
||||
:title="rowDelete.titleOf(rowDelete.target.value)"
|
||||
:type-name="name"
|
||||
:pending="rowDelete.pending.value"
|
||||
:failed="rowDelete.failed.value"
|
||||
@confirm="rowDelete.confirm"
|
||||
@close="rowDelete.close"
|
||||
/>
|
||||
</template>
|
||||
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { contentLink, selectedContentKey, visibleContentTypes } from '@/lib/contentNav'
|
||||
import { contentTypeIcon } from '@/lib/contentTypeIcon'
|
||||
|
||||
@@ -18,15 +19,19 @@ const SIDEBAR_QUERY = '(min-width: 64rem)'
|
||||
|
||||
const route = useRoute()
|
||||
const store = useContentTypesStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const isSidebar = useMediaQuery(SIDEBAR_QUERY)
|
||||
const expanded = ref(false)
|
||||
|
||||
const selectedKey = computed(() => selectedContentKey(route.path))
|
||||
const total = computed(() => store.items.length)
|
||||
const readable = computed(() =>
|
||||
store.items.filter((type) => permissions.can('content.read', { typeKey: type.key })),
|
||||
)
|
||||
const total = computed(() => readable.value.length)
|
||||
const showGroup = computed(() => store.status === 'ready' && total.value > 0)
|
||||
const rows = computed(() =>
|
||||
isSidebar.value || expanded.value
|
||||
? visibleContentTypes(store.items, selectedKey.value, expanded.value)
|
||||
? visibleContentTypes(readable.value, selectedKey.value, expanded.value)
|
||||
: [],
|
||||
)
|
||||
const hiddenCount = computed(() => total.value - rows.value.length)
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import DirectoryRowMenu from '@/components/DirectoryRowMenu.vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import type { RowAccess } from '@/composables/useContentAccess'
|
||||
|
||||
const props = defineProps<{
|
||||
title: string
|
||||
desktop: boolean
|
||||
access: RowAccess
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ edit: []; delete: [] }>()
|
||||
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
|
||||
const items = computed<DirectoryMenuItem[]>(() => [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('Edit'),
|
||||
icon: 'i-lucide-pencil',
|
||||
unavailable: !props.access.canEdit,
|
||||
hint: props.access.canEdit ? undefined : props.access.editReason,
|
||||
onSelect: () => emit('edit'),
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('Delete'),
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: true,
|
||||
unavailable: !props.access.canDelete,
|
||||
hint: props.access.canDelete ? undefined : props.access.deleteReason,
|
||||
onSelect: () => emit('delete'),
|
||||
},
|
||||
])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DirectoryRowMenu v-model:open="open" :items="items" :title="title" :desktop="desktop" />
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { t, translate } from '@/i18n'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
import { useContentTypesPreload, useShellLogout } from '@/composables/useShellSession'
|
||||
import { useSidebarCollapsed } from '@/composables/useSidebarCollapsed'
|
||||
@@ -68,11 +69,12 @@ const bodyUi = computed(() => {
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const update = useUpdateStore()
|
||||
const { loggingOut, logout } = useShellLogout()
|
||||
|
||||
const wantsBuiltin = computed(
|
||||
() => session.canManageContent && route.meta.needsBuiltinTypes === true,
|
||||
() => permissions.can('schema.view') && route.meta.needsBuiltinTypes === true,
|
||||
)
|
||||
|
||||
useContentTypesPreload(() => wantsBuiltin.value)
|
||||
@@ -87,7 +89,7 @@ const publishingChildren = computed(() => [
|
||||
},
|
||||
])
|
||||
|
||||
const settingsChildren = computed(() => [
|
||||
const mediaSettingsChildren = computed(() => [
|
||||
{ label: t('Media'), type: 'label' as const },
|
||||
{
|
||||
label: t('Variants'),
|
||||
@@ -101,11 +103,15 @@ const settingsChildren = computed(() => [
|
||||
icon: 'i-lucide-database',
|
||||
active: !props.noCurrentNav && route.path === '/settings/media/cache',
|
||||
},
|
||||
...(session.isOwner ? publishingChildren.value : []),
|
||||
])
|
||||
|
||||
const settingsChildren = computed(() => [
|
||||
...(permissions.can('settings.view') ? mediaSettingsChildren.value : []),
|
||||
...(permissions.can('settings.manage_sites') ? publishingChildren.value : []),
|
||||
])
|
||||
|
||||
const schemaItem = computed(() =>
|
||||
session.canManageContent
|
||||
permissions.can('schema.view')
|
||||
? [
|
||||
{
|
||||
get label() {
|
||||
@@ -113,6 +119,7 @@ const schemaItem = computed(() =>
|
||||
},
|
||||
to: SCHEMA_PATH,
|
||||
icon: 'i-lucide-shapes',
|
||||
show: true,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
@@ -126,6 +133,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: HOME_PATH,
|
||||
icon: 'i-lucide-home',
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -133,6 +141,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/article',
|
||||
icon: 'i-lucide-file-text',
|
||||
show: permissions.can('content.read', { typeKey: 'article' }),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -140,6 +149,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/media',
|
||||
icon: 'i-lucide-images',
|
||||
show: permissions.can('media.view'),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -147,6 +157,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/authors',
|
||||
icon: 'i-lucide-feather',
|
||||
show: permissions.can('authors.view'),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -154,6 +165,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/users',
|
||||
icon: 'i-lucide-users',
|
||||
show: permissions.can('users.view'),
|
||||
},
|
||||
...schemaItem.value,
|
||||
{
|
||||
@@ -162,6 +174,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/activity',
|
||||
icon: 'i-lucide-history',
|
||||
show: permissions.can('activity.view'),
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -169,6 +182,7 @@ const navItems = computed(() =>
|
||||
},
|
||||
to: '/integration',
|
||||
icon: 'i-lucide-plug',
|
||||
show: true,
|
||||
},
|
||||
{
|
||||
get label() {
|
||||
@@ -177,24 +191,29 @@ const navItems = computed(() =>
|
||||
to: '/settings',
|
||||
icon: 'i-lucide-settings',
|
||||
slot: 'settings',
|
||||
show: permissions.can('settings.view') || permissions.can('settings.manage_sites'),
|
||||
},
|
||||
].map((item) => {
|
||||
const isSettings = item.to === '/settings'
|
||||
const parentActive = isSettings
|
||||
? route.path === item.to
|
||||
: route.path === item.to || route.path.startsWith(`${item.to}/`)
|
||||
return {
|
||||
...item,
|
||||
active: !props.noCurrentNav && parentActive,
|
||||
...(isSettings && session.canManageContent
|
||||
? {
|
||||
children: settingsChildren.value,
|
||||
defaultOpen: route.path.startsWith(item.to),
|
||||
trailingIcon: 'i-lucide-chevron-right',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
]
|
||||
.filter((item) => item.show)
|
||||
.map((entry) => {
|
||||
const { show, ...item } = entry
|
||||
void show
|
||||
const isSettings = item.to === '/settings'
|
||||
const parentActive = isSettings
|
||||
? route.path === item.to
|
||||
: route.path === item.to || route.path.startsWith(`${item.to}/`)
|
||||
return {
|
||||
...item,
|
||||
active: !props.noCurrentNav && parentActive,
|
||||
...(isSettings && settingsChildren.value.length > 0
|
||||
? {
|
||||
children: settingsChildren.value,
|
||||
defaultOpen: route.path.startsWith(item.to),
|
||||
trailingIcon: 'i-lucide-chevron-right',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
}),
|
||||
)
|
||||
|
||||
const routeTitle = computed(() => translate((route.meta.title as string) ?? ''))
|
||||
|
||||
@@ -3,6 +3,7 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, useId } from 'vue'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
items: DirectoryMenuItem[]
|
||||
@@ -15,7 +16,11 @@ const open = defineModel<boolean>('open', { default: false })
|
||||
const hintId = useId()
|
||||
const trigger = ref<{ $el: HTMLElement } | null>(null)
|
||||
const label = computed(() => t('Actions for {0}', [props.title]))
|
||||
const hint = computed(() => props.items.find((item) => item.unavailable)?.hint ?? '')
|
||||
const hinted = computed(() => props.items.filter((item) => item.unavailable && item.hint))
|
||||
|
||||
function hintIdOf(item: DirectoryMenuItem) {
|
||||
return `${hintId}-${item.key}`
|
||||
}
|
||||
|
||||
const groups = computed(() => {
|
||||
const result: DirectoryMenuItem[][] = []
|
||||
@@ -40,12 +45,16 @@ function select(item: DirectoryMenuItem, event?: Event) {
|
||||
const menuItems = computed(() =>
|
||||
groups.value.map((group) =>
|
||||
group.map((item) => ({
|
||||
key: item.key,
|
||||
hint: item.hint ?? '',
|
||||
destructive: item.destructive,
|
||||
label: item.label,
|
||||
icon: item.icon,
|
||||
color: item.destructive ? ('error' as const) : undefined,
|
||||
ownerOnly: item.ownerOnly,
|
||||
slot: item.unavailable ? ('unavailable' as const) : undefined,
|
||||
'aria-disabled': item.unavailable ? 'true' : undefined,
|
||||
'aria-describedby': item.unavailable ? hintId : undefined,
|
||||
'aria-describedby': item.unavailable && item.hint ? hintIdOf(item) : undefined,
|
||||
class: item.unavailable ? 'opacity-40' : undefined,
|
||||
onSelect: (event: Event) => select(item, event),
|
||||
})),
|
||||
@@ -80,11 +89,20 @@ const TRIGGER_CLASS =
|
||||
@click.stop
|
||||
/>
|
||||
<template #unavailable="{ item }">
|
||||
<UTooltip :text="hint" :content="{ side: 'left' }">
|
||||
<UTooltip :text="item.hint" :content="{ side: 'left' }">
|
||||
<span class="flex items-center gap-2">
|
||||
<UIcon :name="item.icon" class="size-4 shrink-0 text-error" />
|
||||
<span class="truncate text-error">{{ item.label }}</span>
|
||||
<span :id="hintId" aria-hidden="true" class="sr-only">{{ hint }}</span>
|
||||
<UIcon
|
||||
:name="item.icon"
|
||||
class="size-4 shrink-0"
|
||||
:class="item.destructive ? 'text-error' : 'text-muted'"
|
||||
/>
|
||||
<span class="truncate" :class="item.destructive ? 'text-error' : ''">{{
|
||||
item.label
|
||||
}}</span>
|
||||
<OwnerOnlyTag v-if="item.ownerOnly" />
|
||||
<span :id="`${hintId}-${item.key}`" aria-hidden="true" class="sr-only">{{
|
||||
item.hint
|
||||
}}</span>
|
||||
</span>
|
||||
</UTooltip>
|
||||
</template>
|
||||
@@ -127,7 +145,9 @@ const TRIGGER_CLASS =
|
||||
block
|
||||
:icon="item.icon"
|
||||
:aria-disabled="item.unavailable ? 'true' : undefined"
|
||||
:aria-describedby="item.unavailable ? hintId : undefined"
|
||||
:aria-describedby="
|
||||
item.unavailable && item.hint ? hintIdOf(item) : undefined
|
||||
"
|
||||
class="min-h-12 justify-start gap-3 px-4 text-start text-md"
|
||||
:class="[
|
||||
item.destructive ? 'text-error' : 'text-highlighted',
|
||||
@@ -137,10 +157,16 @@ const TRIGGER_CLASS =
|
||||
@click="select(item, $event)"
|
||||
>
|
||||
{{ item.label }}
|
||||
<OwnerOnlyTag v-if="item.ownerOnly" />
|
||||
</UButton>
|
||||
</template>
|
||||
<p v-if="hint" :id="hintId" class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted">
|
||||
{{ hint }}
|
||||
<p
|
||||
v-for="item in hinted"
|
||||
:id="hintIdOf(item)"
|
||||
:key="item.key"
|
||||
class="px-4 pt-1 pl-11.5 text-xs/4.5 text-muted"
|
||||
>
|
||||
{{ item.hint }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import MediaThumb from '@/components/MediaThumb.vue'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import type { DuplicateGroup } from '@/lib/mediaDuplicates'
|
||||
import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
@@ -9,6 +11,8 @@ import { formatMediaDate, pluralArticles } from '@/lib/mediaFormat'
|
||||
const FOOTER =
|
||||
'Suggested: the most used copy, then the oldest. Choosing another copy switches articles to it.'
|
||||
|
||||
const canMerge = computed(() => usePermissionsStore().scopeOf('media.delete') === 'all')
|
||||
|
||||
defineProps<{
|
||||
groups: DuplicateGroup[]
|
||||
offline: boolean
|
||||
@@ -98,6 +102,7 @@ function uploaded(copy: MediaItem) {
|
||||
</div>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="canMerge"
|
||||
icon="i-lucide-merge"
|
||||
:color="copy.id === group.suggested.id ? 'primary' : 'neutral'"
|
||||
:variant="copy.id === group.suggested.id ? 'solid' : 'outline'"
|
||||
|
||||
@@ -8,15 +8,21 @@ import SaveStatus from '@/components/SaveStatus.vue'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
countText: string
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
article: Article
|
||||
state: ArticleDraftState
|
||||
savedAt: Date | null
|
||||
countText: string
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
canPublish?: boolean
|
||||
canDelete?: boolean
|
||||
deleteReason?: string
|
||||
}>(),
|
||||
{ canPublish: true, canDelete: true, deleteReason: '' },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
back: []
|
||||
@@ -52,6 +58,9 @@ const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
{{ article.title }}
|
||||
</h1>
|
||||
<ArticleStatusBadge :online="article.is_online" size="lg" />
|
||||
<span v-if="!canPublish && !article.is_online" class="text-xs text-muted">{{
|
||||
t('Ask an editor to publish.')
|
||||
}}</span>
|
||||
</div>
|
||||
<SaveStatus :state="state" :saved-at="savedAt" @retry="emit('retry')" />
|
||||
<span class="ml-auto font-mono text-xs text-muted lg:hidden">{{ countText }}</span>
|
||||
@@ -74,9 +83,10 @@ const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
v-if="canPublish"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:online="article.is_online"
|
||||
:dirty="state === 'dirty'"
|
||||
@@ -90,6 +100,8 @@ const previewOn = defineModel<boolean>('preview', { required: true })
|
||||
color="error"
|
||||
variant="ghost"
|
||||
class="h-8 gap-1.5 px-3"
|
||||
:disabled="!canDelete"
|
||||
:title="canDelete ? undefined : deleteReason"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
{{ t('Delete') }}
|
||||
|
||||
@@ -4,13 +4,17 @@ import { t } from '@/i18n'
|
||||
import PublishControl from '@/components/PublishControl.vue'
|
||||
import type { ArticleDraftState } from '@/composables/useArticleDraft'
|
||||
|
||||
defineProps<{
|
||||
online: boolean
|
||||
state: ArticleDraftState
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
}>()
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
online: boolean
|
||||
state: ArticleDraftState
|
||||
saveEnabled: boolean
|
||||
publishPending: string | undefined
|
||||
publishDisabled: boolean
|
||||
canPublish?: boolean
|
||||
}>(),
|
||||
{ canPublish: true },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
details: []
|
||||
@@ -38,9 +42,10 @@ const emit = defineEmits<{
|
||||
:disabled="!saveEnabled"
|
||||
@click="emit('save')"
|
||||
>
|
||||
{{ t('Save') }}
|
||||
{{ canPublish ? t('Save') : t('Save draft') }}
|
||||
</UButton>
|
||||
<PublishControl
|
||||
v-if="canPublish"
|
||||
class="h-11 flex-1 justify-center"
|
||||
hide-icon
|
||||
:online="online"
|
||||
|
||||
@@ -10,6 +10,7 @@ defineProps<{
|
||||
creating: boolean
|
||||
saving: boolean
|
||||
canSave: boolean
|
||||
readOnly?: boolean
|
||||
bar?: boolean
|
||||
}>()
|
||||
|
||||
@@ -24,7 +25,7 @@ defineEmits<{ remove: []; cancel: [] }>()
|
||||
>
|
||||
<BuilderStatusChip v-if="!bar" :status="chip" :saved-at="savedClock" />
|
||||
<UButton
|
||||
v-if="!bar && !creating"
|
||||
v-if="!bar && !creating && !readOnly"
|
||||
type="button"
|
||||
color="error"
|
||||
variant="outline"
|
||||
@@ -47,6 +48,7 @@ defineEmits<{ remove: []; cancel: [] }>()
|
||||
{{ t('Cancel') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="!readOnly"
|
||||
type="submit"
|
||||
form="entry-form"
|
||||
:disabled="!canSave"
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { RouterLink } from 'vue-router'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import {
|
||||
EMPTY_TEXT,
|
||||
@@ -26,6 +28,7 @@ const props = defineProps<{
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const access = useContentAccess(props.typeKey, 'entries')
|
||||
const shown = computed(() => cardFields(props.fields))
|
||||
const lead = computed(() => thumbnailColumn(props.columns))
|
||||
const relationColumns = computed(() =>
|
||||
@@ -117,6 +120,7 @@ function nameOf(entry: ContentEntry) {
|
||||
class="shrink-0"
|
||||
/>
|
||||
<span class="truncate">· {{ nameOf(entry) ?? EMPTY_TEXT }}</span>
|
||||
<ViewOnlyMark v-if="!access.rowAccess(entry.created_by).canEdit" />
|
||||
</span>
|
||||
</span>
|
||||
<span class="flex h-11 w-9 shrink-0 items-center justify-center text-muted">
|
||||
|
||||
@@ -10,6 +10,7 @@ import EntryDeleteDialog from '@/components/EntryDeleteDialog.vue'
|
||||
import EntryEditorSkeleton from '@/components/EntryEditorSkeleton.vue'
|
||||
import EntryForm from '@/components/EntryForm.vue'
|
||||
import EntryNotices from '@/components/EntryNotices.vue'
|
||||
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
|
||||
import type { EntryEditorPage } from '@/composables/useEntryEditorPage'
|
||||
import { typeEndpoint } from '@/lib/contentEndpoints'
|
||||
import { entryLoadErrorLine } from '@/lib/entryMessages'
|
||||
@@ -35,6 +36,7 @@ const actionProps = computed(() => ({
|
||||
creating: props.page.creating.value,
|
||||
saving: props.page.saving.value,
|
||||
canSave: props.page.canSave.value,
|
||||
readOnly: props.page.readOnly.value,
|
||||
}))
|
||||
|
||||
function goHome() {
|
||||
@@ -71,7 +73,10 @@ function backToList() {
|
||||
<h1 class="text-xl font-semibold text-highlighted max-lg:sr-only">
|
||||
{{ page.title.value }}
|
||||
</h1>
|
||||
<p class="text-muted">{{ page.subtitle.value }}</p>
|
||||
<p class="flex items-center gap-2 text-muted">
|
||||
{{ page.subtitle.value }}
|
||||
<ViewOnlyMark v-if="page.readOnly.value" />
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<EntryActions
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, ref, watchPostEffect } from 'vue'
|
||||
import { RouterLink } from 'vue-router'
|
||||
import { RouterLink, useRouter } from 'vue-router'
|
||||
import ContentRowMenu from '@/components/ContentRowMenu.vue'
|
||||
import FieldCell from '@/components/FieldCell.vue'
|
||||
import ViewOnlyMark from '@/components/ViewOnlyMark.vue'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { BuiltinField, ContentEntry } from '@/lib/contentTypes'
|
||||
import { EMPTY_TEXT, formatEntryDate } from '@/lib/entryCells'
|
||||
import { ENTRY_SORT_FIELDS, sortParts } from '@/lib/entryListQuery'
|
||||
@@ -19,6 +22,7 @@ const WIDE_WIDTH = 'w-50'
|
||||
const CREATED_WIDTH = 'w-30.5'
|
||||
const UPDATED_WIDTH = 'w-40.5'
|
||||
const UPDATED_BY_WIDTH = 'w-37'
|
||||
const ACTIONS_WIDTH = 'w-12'
|
||||
const FIELD_PREFIX = 'field:'
|
||||
|
||||
const props = defineProps<{
|
||||
@@ -30,6 +34,10 @@ const props = defineProps<{
|
||||
caption: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ delete: [entry: ContentEntry] }>()
|
||||
|
||||
const router = useRouter()
|
||||
const access = useContentAccess(props.typeKey, 'entries')
|
||||
const root = ref<HTMLElement | null>(null)
|
||||
const sorted = computed(() => sortParts(props.sort))
|
||||
|
||||
@@ -61,7 +69,8 @@ const columns = computed(() => [
|
||||
),
|
||||
column('created_at', t('Created'), CREATED_WIDTH),
|
||||
column('updated_at', t('Updated'), UPDATED_WIDTH),
|
||||
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH, 'pr-3 pl-1.5'),
|
||||
column('updated_by', t('Updated by'), UPDATED_BY_WIDTH),
|
||||
column('actions', '', ACTIONS_WIDTH, 'pr-3 pl-1.5'),
|
||||
])
|
||||
|
||||
function fieldOf(columnId: string) {
|
||||
@@ -148,20 +157,22 @@ watchPostEffect(() => {
|
||||
:key="field.key"
|
||||
#[`${FIELD_PREFIX}${field.key}-cell`]="{ row }"
|
||||
>
|
||||
<RouterLink
|
||||
v-if="index === 0"
|
||||
:to="entryPath(typeKey, row.original.id)"
|
||||
data-testid="entry-link"
|
||||
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
:relations="summariesOf(row.original, field.key)"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
<div v-if="index === 0" class="flex min-w-0 items-center gap-2">
|
||||
<RouterLink
|
||||
:to="entryPath(typeKey, row.original.id)"
|
||||
data-testid="entry-link"
|
||||
class="flex min-w-0 font-medium text-highlighted focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
|
||||
>
|
||||
<FieldCell
|
||||
:field="field"
|
||||
:value="row.original.data[field.key]"
|
||||
:image="mediaOf(row.original, field.key)"
|
||||
:relations="summariesOf(row.original, field.key)"
|
||||
class="min-w-0"
|
||||
/>
|
||||
</RouterLink>
|
||||
<ViewOnlyMark v-if="!access.rowAccess(row.original.created_by).canEdit" />
|
||||
</div>
|
||||
<FieldCell
|
||||
v-else
|
||||
:field="field"
|
||||
@@ -179,6 +190,16 @@ watchPostEffect(() => {
|
||||
<span class="text-muted">{{ formatEntryDate(row.original.updated_at) }}</span>
|
||||
</template>
|
||||
|
||||
<template #actions-cell="{ row }">
|
||||
<ContentRowMenu
|
||||
:title="`#${row.original.id}`"
|
||||
:desktop="true"
|
||||
:access="access.rowAccess(row.original.created_by)"
|
||||
@edit="router.push(entryPath(typeKey, row.original.id))"
|
||||
@delete="emit('delete', row.original)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template #updated_by-cell="{ row }">
|
||||
<span v-if="nameOf(row.original)" class="block truncate">{{
|
||||
nameOf(row.original)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -13,6 +14,9 @@ const props = withDefaults(
|
||||
|
||||
const emit = defineEmits<{ toggle: [] }>()
|
||||
|
||||
const permissions = usePermissionsStore()
|
||||
const selectable = computed(() => permissions.scopeOf('media.delete') === 'all')
|
||||
|
||||
const modelValue = computed(() => {
|
||||
if (props.state === 'partial') {
|
||||
return 'indeterminate' as const
|
||||
@@ -26,6 +30,7 @@ const boxClass = computed(() =>
|
||||
|
||||
<template>
|
||||
<UCheckbox
|
||||
v-if="selectable"
|
||||
:model-value="modelValue"
|
||||
:aria-label="label"
|
||||
:tabindex="tabbable ? undefined : -1"
|
||||
|
||||
@@ -18,23 +18,23 @@ import {
|
||||
purgedToast,
|
||||
} from '@/lib/mediaPurge'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SAVE_FAILED_COPY = "Couldn't save. Try again in a moment."
|
||||
const INVALID_DAYS_COPY = 'Enter a whole number of days between 1 and 365.'
|
||||
const READ_ONLY_COPY = 'Only owners and admins can change this.'
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
const OUTLINE_BUTTON =
|
||||
'h-11 justify-center gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 max-md:w-full md:h-8'
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const toast = useLocalizedToast()
|
||||
const router = useRouter()
|
||||
const online = useOnline()
|
||||
|
||||
const isAdmin = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const isAdmin = computed(() => permissions.can('settings.edit_general'))
|
||||
const readOnly = computed(() => !isAdmin.value)
|
||||
const offline = computed(() => !online.value)
|
||||
|
||||
@@ -256,14 +256,12 @@ function reviewDuplicates() {
|
||||
</UAlert>
|
||||
|
||||
<template v-else-if="showBody">
|
||||
<UAlert
|
||||
<ReadOnlyBanner
|
||||
v-if="readOnly"
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
:close="false"
|
||||
icon="i-lucide-info"
|
||||
:description="t(READ_ONLY_COPY)"
|
||||
:title="t('You can read these settings but not change them')"
|
||||
:description="
|
||||
t('Ask the owner to change them, or ask for a role that can edit settings.')
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
import { usageSplitOf } from '@/lib/mediaUsage'
|
||||
import { PANEL_TAB_TRIGGER } from '@/lib/tabStyles'
|
||||
import { useCanManageVariants } from '@/composables/useCanManageVariants'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const ACTION_BUTTON =
|
||||
@@ -42,6 +43,9 @@ const emit = defineEmits<{
|
||||
const open = defineModel<boolean>('open', { default: false })
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const canManageVariants = useCanManageVariants()
|
||||
const permissions = usePermissionsStore()
|
||||
const canRename = computed(() => permissions.can('media.upload'))
|
||||
const canDelete = computed(() => permissions.scopeOf('media.delete') === 'all')
|
||||
const panelTab = ref<'details' | 'variants'>('details')
|
||||
const variantsVisited = ref(false)
|
||||
|
||||
@@ -276,6 +280,7 @@ const duplicateAlert = computed(() => {
|
||||
{{ t('Copy URL') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="canRename"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-pencil"
|
||||
@@ -286,6 +291,7 @@ const duplicateAlert = computed(() => {
|
||||
{{ t('Rename') }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-if="canDelete"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-trash-2"
|
||||
|
||||
@@ -11,6 +11,7 @@ const props = defineProps<{
|
||||
filterLabel?: string
|
||||
offline?: boolean
|
||||
limited?: boolean
|
||||
readOnly?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ action: [] }>()
|
||||
@@ -89,7 +90,7 @@ const current = computed(() => {
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
v-if="variant === 'first-run'"
|
||||
v-if="variant === 'first-run' && !readOnly"
|
||||
icon="i-lucide-upload"
|
||||
:disabled="offline || limited"
|
||||
class="h-11 px-4 md:h-8"
|
||||
@@ -98,7 +99,7 @@ const current = computed(() => {
|
||||
{{ current.action }}
|
||||
</UButton>
|
||||
<UButton
|
||||
v-else
|
||||
v-else-if="variant !== 'first-run'"
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-4 text-highlighted ring-0 md:h-8"
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { Site } from '@/composables/useSites'
|
||||
import { metaSummary } from '@/lib/articleMeta'
|
||||
import { readHintDismissed, storeHintDismissed } from '@/lib/metaSectionMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
@@ -33,6 +34,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const profile = useMyAuthorProfile()
|
||||
const bodyId = useId()
|
||||
|
||||
@@ -48,7 +50,7 @@ const siteName = computed(() => props.sites.find((site) => site.id === props.sit
|
||||
const summary = computed(() =>
|
||||
metaSummary(props.authors, severalSites.value ? siteName.value : null),
|
||||
)
|
||||
const canCreate = computed(() => session.canManageContent)
|
||||
const canCreate = computed(() => permissions.can('authors.create'))
|
||||
const showHint = computed(
|
||||
() =>
|
||||
session.session !== null &&
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const router = useRouter()
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const canReadArticles = computed(() => permissions.can('content.read', { typeKey: 'article' }))
|
||||
const actionLabel = computed(() => (canReadArticles.value ? t('Go to Articles') : t('Go to Home')))
|
||||
|
||||
function goToSafePage() {
|
||||
router.push(canReadArticles.value ? '/article' : '/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to this page')"
|
||||
:description="
|
||||
t('Your role, {0}, doesn\'t include it. Ask an owner or admin if you need it.', [
|
||||
permissions.roleName,
|
||||
])
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-14',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md font-semibold',
|
||||
description: 'max-w-85 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
|
||||
@click="goToSafePage"
|
||||
>
|
||||
{{ actionLabel }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -0,0 +1,14 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UBadge
|
||||
color="neutral"
|
||||
variant="subtle"
|
||||
icon="i-lucide-lock"
|
||||
:label="t('Owner only')"
|
||||
data-testid="owner-only-tag"
|
||||
:ui="{ base: 'h-5 rounded-none px-1.75 py-0 text-xs font-medium', leadingIcon: 'size-3' }"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
title: string
|
||||
description?: string
|
||||
}>(),
|
||||
{ description: undefined },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UAlert
|
||||
role="status"
|
||||
color="info"
|
||||
variant="soft"
|
||||
icon="i-lucide-lock"
|
||||
:title="title"
|
||||
:description="description"
|
||||
:close="false"
|
||||
data-testid="read-only-banner"
|
||||
:ui="{
|
||||
root: 'items-start gap-2.5 px-3.5 py-2.5',
|
||||
icon: 'size-4',
|
||||
description: 'text-toned',
|
||||
}"
|
||||
/>
|
||||
</template>
|
||||
@@ -3,12 +3,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import ActivityFeedCard from '@/components/ActivityFeedCard.vue'
|
||||
import { useActivity } from '@/composables/useActivity'
|
||||
import type { ActivityItem } from '@/lib/activity'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
const RECENT_SIZE = 5
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const { listRecentActivity } = useActivity()
|
||||
|
||||
const items = ref<ActivityItem[]>([])
|
||||
@@ -17,7 +17,7 @@ const error = ref(false)
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const own = computed(() => session.session?.role !== 'owner' && session.session?.role !== 'admin')
|
||||
const own = computed(() => permissions.scopeOf('activity.view') === 'own')
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
if (skeletonTimer !== null) {
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
sizeOptions,
|
||||
} from '@/lib/activityRetention'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
|
||||
|
||||
const UNREACHABLE_COPY = "Couldn't reach RenewCMS. Check your connection and try again."
|
||||
const SKELETON_BAR = 'rounded-none bg-accented'
|
||||
@@ -46,6 +48,8 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const canEdit = computed(() => permissions.can('system.edit_retention'))
|
||||
const { updateRetention } = useSettings()
|
||||
const online = useOnline()
|
||||
|
||||
@@ -68,7 +72,9 @@ const controls = computed(() => [
|
||||
key: 'days' as const,
|
||||
labelId: 'retention-label',
|
||||
label: t('Keep activity for'),
|
||||
helper: helperCopy(selectedDays.value, selectedGb.value),
|
||||
helper: canEdit.value
|
||||
? helperCopy(selectedDays.value, selectedGb.value)
|
||||
: t('Admins can read the log. Only the owner can change how long it is kept.'),
|
||||
items: retentionOptions(),
|
||||
value: selectedDays.value,
|
||||
},
|
||||
@@ -246,12 +252,17 @@ onBeforeUnmount(clearSavedTimer)
|
||||
</h2>
|
||||
<p class="text-pretty text-muted">
|
||||
{{
|
||||
t(
|
||||
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.',
|
||||
)
|
||||
canEdit
|
||||
? t(
|
||||
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events. Only owners can change it.',
|
||||
)
|
||||
: t(
|
||||
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events.',
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<OwnerOnlyTag v-if="!canEdit" />
|
||||
<span
|
||||
aria-live="polite"
|
||||
data-testid="retention-save-status"
|
||||
@@ -352,7 +363,7 @@ onBeforeUnmount(clearSavedTimer)
|
||||
:items="control.items"
|
||||
value-key="value"
|
||||
:search-input="false"
|
||||
:disabled="offline"
|
||||
:disabled="offline || !canEdit"
|
||||
:aria-labelledby="control.labelId"
|
||||
:data-testid="`retention-select-${control.key}`"
|
||||
color="neutral"
|
||||
|
||||
@@ -1,13 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
import { computed } from 'vue'
|
||||
import type { RoleRef } from '@/lib/permissions'
|
||||
|
||||
const props = defineProps<{
|
||||
role: 'owner' | 'admin'
|
||||
role: RoleRef | null | undefined
|
||||
}>()
|
||||
|
||||
const BUILT_IN_LABELS: Record<string, () => string> = {
|
||||
Owner: () => t('owner'),
|
||||
Admin: () => t('admin'),
|
||||
Writer: () => t('writer'),
|
||||
}
|
||||
|
||||
const label = computed(() => {
|
||||
if (!props.role) {
|
||||
return '—'
|
||||
}
|
||||
const builtIn = props.role.built_in ? BUILT_IN_LABELS[props.role.name] : undefined
|
||||
return builtIn ? builtIn() : props.role.name
|
||||
})
|
||||
|
||||
const roleClass = computed(() =>
|
||||
props.role === 'owner'
|
||||
props.role?.locked
|
||||
? 'border border-primary bg-primary text-(--ui-bg)'
|
||||
: 'border border-accented bg-transparent text-toned',
|
||||
)
|
||||
@@ -22,6 +37,6 @@ const roleClass = computed(() =>
|
||||
base: 'h-5 rounded-none px-1.75 py-0 font-mono text-[0.6875rem] font-medium',
|
||||
}"
|
||||
>
|
||||
{{ t(role) }}
|
||||
{{ label }}
|
||||
</UBadge>
|
||||
</template>
|
||||
@@ -0,0 +1,38 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { isRouteAllowed, type RouteAccess } from '@/lib/routeAccess'
|
||||
import AppBoot from '@/components/AppBoot.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
type GateState = 'open' | 'waiting' | 'denied'
|
||||
|
||||
const state = computed<GateState>(() => {
|
||||
const access = route.meta.access as RouteAccess | undefined
|
||||
if (access === undefined) {
|
||||
return 'open'
|
||||
}
|
||||
if (session.status === 'idle' || session.status === 'checking') {
|
||||
return 'waiting'
|
||||
}
|
||||
if (!session.session?.verified) {
|
||||
return 'open'
|
||||
}
|
||||
return isRouteAllowed(access, route.params, permissions.can) ? 'open' : 'denied'
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppBoot v-if="state === 'waiting'" />
|
||||
<DashboardShell v-else-if="state === 'denied'">
|
||||
<NoAccessState />
|
||||
</DashboardShell>
|
||||
<slot v-else />
|
||||
</template>
|
||||
@@ -2,7 +2,6 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import ApiPreview from '@/components/ApiPreview.vue'
|
||||
import ApiPreviewSheet from '@/components/ApiPreviewSheet.vue'
|
||||
@@ -13,14 +12,13 @@ import BuilderTypeBar from '@/components/BuilderTypeBar.vue'
|
||||
import FieldList from '@/components/FieldList.vue'
|
||||
import FieldSheet from '@/components/FieldSheet.vue'
|
||||
import RetryAlert from '@/components/RetryAlert.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import SchemaDeleteControls from '@/components/SchemaDeleteControls.vue'
|
||||
import SchemaRefusalDialog from '@/components/SchemaRefusalDialog.vue'
|
||||
import SchemaReviewDialog from '@/components/SchemaReviewDialog.vue'
|
||||
import { useSchemaBuilder, type EditSource } from '@/composables/useSchemaBuilder'
|
||||
import { useSchemaDelete } from '@/composables/useSchemaDelete'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { HOME_PATH } from '@/lib/routePaths'
|
||||
import { sampleJson } from '@/lib/schemaBuilderSample'
|
||||
import { entriesNotice } from '@/lib/schemaEntries'
|
||||
import { formatClock } from '@/lib/updateFormat'
|
||||
@@ -29,7 +27,6 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
|
||||
const props = defineProps<{ edit?: EditSource }>()
|
||||
|
||||
const router = useRouter()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
const builder = useSchemaBuilder(props.edit)
|
||||
const { form } = builder
|
||||
@@ -70,10 +67,6 @@ const selectedName = computed(() => builder.selected.value?.label.trim() || t('U
|
||||
const sheetTitle = computed(() => t('Field: {0}', [selectedName.value]))
|
||||
const reviewName = computed(() => builder.baseline.value?.name ?? '')
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
|
||||
function onSelect(uid: number) {
|
||||
builder.selectField(uid)
|
||||
if (!isDesktop.value) {
|
||||
@@ -113,7 +106,7 @@ async function restoreRowFocus() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<template v-else>
|
||||
<div
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex min-h-0 flex-1 items-center justify-center px-4 py-6">
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to Schema')"
|
||||
:description="
|
||||
t(
|
||||
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
|
||||
)
|
||||
"
|
||||
:ui="{
|
||||
root: 'w-full max-w-105 gap-3 p-0 sm:p-0 lg:p-0',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-title font-semibold text-wrap',
|
||||
description: 'mt-1 w-full max-w-none px-0 text-base/5 text-wrap text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton class="h-8 px-4 max-md:h-11" @click="emit('back')">
|
||||
{{ t('Go to Home') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,33 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ back: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access to Sites')"
|
||||
:description="
|
||||
t('Only the owner can manage sites. Ask the owner of this site if you need a change.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-14',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md font-semibold',
|
||||
description: 'max-w-85 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
class="h-11 border border-accented bg-default px-4 text-sm text-highlighted ring-0 sm:h-8"
|
||||
@click="emit('back')"
|
||||
>
|
||||
{{ t('Back to Settings') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -7,7 +7,7 @@ import ServerCommand from '@/components/ServerCommand.vue'
|
||||
import UpdateProgress from '@/components/UpdateProgress.vue'
|
||||
import UpdateStatusBlock, { type UpdateTone } from '@/components/UpdateStatusBlock.vue'
|
||||
import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import type { UserRole } from '@/composables/useApi'
|
||||
import OwnerOnlyTag from '@/components/OwnerOnlyTag.vue'
|
||||
import type { UpdateApplyPhase, UpdateCheckPhase } from '@/stores/update'
|
||||
import {
|
||||
CONTAINER_UPDATE_COMMAND,
|
||||
@@ -77,7 +77,7 @@ const ZERO_PROGRESS = { percent: 0, downloaded_bytes: 0, total_bytes: 0 }
|
||||
|
||||
const props = defineProps<{
|
||||
status: UpdateStatus
|
||||
role: UserRole | undefined
|
||||
canApply: boolean
|
||||
phase: UpdateCheckPhase
|
||||
applyPhase: UpdateApplyPhase
|
||||
retryLabel: string
|
||||
@@ -103,13 +103,13 @@ function focusRestart() {
|
||||
|
||||
defineExpose({ focusHeading, focusRestart })
|
||||
|
||||
const isOwner = computed(() => props.role === 'owner')
|
||||
const canApplyUpdates = computed(() => props.canApply)
|
||||
const newVersion = computed(() => displayVersion(props.status.latest))
|
||||
const lastChecked = computed(() =>
|
||||
props.status.checked_at ? formatLastChecked(props.status.checked_at) : undefined,
|
||||
)
|
||||
|
||||
const canApplyView = computed(() => isOwner.value && props.status.can_apply)
|
||||
const canApplyView = computed(() => canApplyUpdates.value && props.status.can_apply)
|
||||
|
||||
const applyView = computed<ApplyView | null>(() => {
|
||||
if (!canApplyView.value || props.phase !== 'idle') {
|
||||
@@ -339,7 +339,7 @@ function applyButton(view: ApplyView): ActionButton {
|
||||
}
|
||||
|
||||
const block = computed<Block>(() => {
|
||||
if (isOwner.value && props.phase === 'checking') {
|
||||
if (canApplyUpdates.value && props.phase === 'checking') {
|
||||
return {
|
||||
tone: 'neutral',
|
||||
icon: 'i-lucide-loader-circle',
|
||||
@@ -351,10 +351,10 @@ const block = computed<Block>(() => {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (isOwner.value && props.phase === 'limited') {
|
||||
if (canApplyUpdates.value && props.phase === 'limited') {
|
||||
return LIMITED_BLOCK
|
||||
}
|
||||
if (isOwner.value && props.phase === 'failed') {
|
||||
if (canApplyUpdates.value && props.phase === 'failed') {
|
||||
return {
|
||||
tone: 'warning',
|
||||
icon: 'i-lucide-wifi-off',
|
||||
@@ -400,7 +400,7 @@ const block = computed<Block>(() => {
|
||||
},
|
||||
}
|
||||
}
|
||||
if (props.status.available && isOwner.value) {
|
||||
if (props.status.available && canApplyUpdates.value) {
|
||||
return {
|
||||
tone: 'info',
|
||||
icon: 'i-lucide-info',
|
||||
@@ -423,10 +423,7 @@ const block = computed<Block>(() => {
|
||||
return t('Version {0} is available', [newVersion.value])
|
||||
},
|
||||
get text() {
|
||||
return t(
|
||||
'You have {0}. Only the owner can update RenewCMS. Ask the owner of this site.',
|
||||
[props.status.current],
|
||||
)
|
||||
return t('Only the owner can apply updates.')
|
||||
},
|
||||
meta: lastChecked.value,
|
||||
}
|
||||
@@ -438,11 +435,11 @@ const block = computed<Block>(() => {
|
||||
return t("You're up to date")
|
||||
},
|
||||
get text() {
|
||||
return isOwner.value
|
||||
return canApplyUpdates.value
|
||||
? t('You have the latest version of RenewCMS.')
|
||||
: t('Only the owner can update or restart the site.')
|
||||
},
|
||||
meta: isOwner.value ? lastChecked.value : undefined,
|
||||
meta: canApplyUpdates.value ? lastChecked.value : undefined,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -456,12 +453,15 @@ const restartAvailable = computed(
|
||||
|
||||
const showContainerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value && props.status.container && props.status.available && props.phase === 'idle',
|
||||
canApplyUpdates.value &&
|
||||
props.status.container &&
|
||||
props.status.available &&
|
||||
props.phase === 'idle',
|
||||
)
|
||||
|
||||
const showServerGuidance = computed(
|
||||
() =>
|
||||
isOwner.value &&
|
||||
canApplyUpdates.value &&
|
||||
!props.status.container &&
|
||||
!props.status.can_apply &&
|
||||
props.status.available &&
|
||||
@@ -480,7 +480,7 @@ const plainRestartButton: ActionButton = {
|
||||
}
|
||||
|
||||
const actionButtons = computed<ActionButton[]>(() => {
|
||||
if (!isOwner.value || isRestarting.value) {
|
||||
if (!canApplyUpdates.value || isRestarting.value) {
|
||||
return []
|
||||
}
|
||||
if (props.phase === 'checking') {
|
||||
@@ -533,6 +533,8 @@ const inBlockButton = computed(() =>
|
||||
|
||||
const rowButtons = computed(() => (inBlockButton.value ? [] : actionButtons.value))
|
||||
|
||||
const showOwnerOnlyApply = computed(() => !props.canApply && props.status.available)
|
||||
|
||||
const showRestartHint = computed(() => actionButtons.value.includes(plainRestartButton))
|
||||
|
||||
function withPlainRestart(button: ActionButton) {
|
||||
@@ -681,6 +683,24 @@ const notes = computed(() => {
|
||||
</UButton>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="showOwnerOnlyApply"
|
||||
data-testid="owner-only-apply"
|
||||
class="flex flex-col gap-2 md:flex-row md:items-center"
|
||||
>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-download"
|
||||
disabled
|
||||
class="h-11 justify-center gap-1.5 px-3.5 md:h-8"
|
||||
:ui="{ leadingIcon: 'size-4' }"
|
||||
>
|
||||
{{ t('Apply update') }}
|
||||
</UButton>
|
||||
<OwnerOnlyTag />
|
||||
</div>
|
||||
|
||||
<span v-if="hint" class="text-xs/4.5 text-pretty text-muted">{{ hint }}</span>
|
||||
|
||||
<a
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UTooltip :text="t('You can only edit content you created.')">
|
||||
<span
|
||||
tabindex="0"
|
||||
data-testid="view-only-mark"
|
||||
:aria-label="t('View only')"
|
||||
class="inline-flex shrink-0 items-center gap-1 text-xs text-muted"
|
||||
>
|
||||
<UIcon name="i-lucide-lock" class="size-3" />
|
||||
{{ t('View only') }}
|
||||
</span>
|
||||
</UTooltip>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import DashboardShell from '../DashboardShell.vue'
|
||||
import { MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -24,6 +25,7 @@ async function mountMobileShell() {
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: '',
|
||||
|
||||
@@ -6,6 +6,7 @@ import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import RootComponent from '@/App.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@nuxt/ui/composables', () => ({
|
||||
useOverlay: () => ({
|
||||
@@ -33,7 +34,7 @@ let currentWrapper: Awaited<ReturnType<typeof mount>> | null = null
|
||||
async function mountArticleEditor(body = '<p>Hello</p>') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
global.fetch = vi.fn(() =>
|
||||
Promise.resolve(
|
||||
|
||||
@@ -13,6 +13,7 @@ import RootComponent from '@/App.vue'
|
||||
import ArticleEditor from '@/components/ArticleEditor.vue'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
class TestPointerEvent extends MouseEvent {
|
||||
pointerType: string
|
||||
@@ -91,7 +92,7 @@ async function mountEditor(body = SENTENCE, media: Media = DESKTOP) {
|
||||
mockMedia(media)
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
fetchMock = vi.fn((_url: string, init?: RequestInit) => {
|
||||
const sent = init?.method === 'PATCH' ? JSON.parse(String(init.body)).body : body
|
||||
|
||||
@@ -54,7 +54,7 @@ describe('The Schema entry', () => {
|
||||
it.each([
|
||||
['owner', true],
|
||||
['admin', true],
|
||||
['unknown', false],
|
||||
['noschema', false],
|
||||
])('role %s: shown %s, after Users, linking to /schema', async (role, shown) => {
|
||||
await mountAt('/home', { role }, true)
|
||||
|
||||
@@ -88,7 +88,7 @@ describe('The Schema entry', () => {
|
||||
|
||||
describe('The Content group', () => {
|
||||
it('lists one link per type in API order and marks the current route', async () => {
|
||||
await mountAt('/content/recipes', { role: 'unknown' }, true)
|
||||
await mountAt('/content/recipes', { role: 'noschema' }, true)
|
||||
|
||||
expect(contentLinks().map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/content/events',
|
||||
|
||||
@@ -9,6 +9,8 @@ import DashboardShell from '../DashboardShell.vue'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
import { accessFor, customAccess, writerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -23,6 +25,7 @@ interface ShellOptions {
|
||||
displayName?: string
|
||||
email?: string
|
||||
role?: string
|
||||
access?: Access
|
||||
width?: number
|
||||
}
|
||||
|
||||
@@ -33,7 +36,8 @@ async function mountShell(path: string, options: ShellOptions = {}) {
|
||||
username = 'marta',
|
||||
displayName = '',
|
||||
email = 'marta@renew.example',
|
||||
role = undefined,
|
||||
role = 'noschema',
|
||||
access,
|
||||
width = DESKTOP_WIDTH,
|
||||
} = options
|
||||
localStorage.clear()
|
||||
@@ -45,7 +49,7 @@ async function mountShell(path: string, options: ShellOptions = {}) {
|
||||
username,
|
||||
email,
|
||||
displayName,
|
||||
role,
|
||||
access: access ?? accessFor(role),
|
||||
}),
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
})
|
||||
@@ -106,6 +110,10 @@ describe('DashboardShell renders the eight navigation destinations', () => {
|
||||
|
||||
const links = Array.from(
|
||||
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
||||
).filter(
|
||||
(link) =>
|
||||
link.getAttribute('href') === '/settings' ||
|
||||
!link.getAttribute('href')?.startsWith('/settings/'),
|
||||
)
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/home',
|
||||
@@ -784,3 +792,87 @@ describe('DashboardShell mobile drawer rows', () => {
|
||||
expectUnfilled(navLink(drawer, '/settings/media/cache'))
|
||||
})
|
||||
})
|
||||
|
||||
function navHrefs(wrapper: VueWrapper) {
|
||||
return wrapper.findAll('nav[aria-label="Main"] a').map((link) => link.attributes('href'))
|
||||
}
|
||||
|
||||
describe('DashboardShell lists items per permission', () => {
|
||||
it('shows Home, Articles, Media, Authors, Activity and Integration for a role with those grants only', async () => {
|
||||
const wrapper = await mountShell('/home', {
|
||||
access: customAccess(
|
||||
'content.read, media.view, authors.view, activity.view=own',
|
||||
'Contributor',
|
||||
),
|
||||
})
|
||||
|
||||
expect(navHrefs(wrapper)).toEqual([
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/activity',
|
||||
'/integration',
|
||||
])
|
||||
expect(wrapper.find('nav[aria-label="Main"]').text()).not.toContain('Roles')
|
||||
})
|
||||
|
||||
it('shows the same items in the mobile drawer', async () => {
|
||||
await mountShell('/home', {
|
||||
width: MOBILE_WIDTH,
|
||||
access: customAccess('content.read, media.view, authors.view, activity.view=own'),
|
||||
})
|
||||
|
||||
const toggle = document.body.querySelector('[data-slot="toggle"]') as HTMLButtonElement
|
||||
toggle.click()
|
||||
await flushPromises()
|
||||
|
||||
const links = Array.from(
|
||||
document.body.querySelectorAll('[role="dialog"] nav[aria-label="Main"] a'),
|
||||
)
|
||||
expect(links.map((link) => link.getAttribute('href'))).toEqual([
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/activity',
|
||||
'/integration',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the Writer built-in menu with Schema and no Users or Settings', async () => {
|
||||
const wrapper = await mountShell('/home', { access: writerAccess() })
|
||||
|
||||
expect(navHrefs(wrapper)).toEqual([
|
||||
'/home',
|
||||
'/article',
|
||||
'/media',
|
||||
'/authors',
|
||||
'/schema',
|
||||
'/activity',
|
||||
'/integration',
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the Sites child under Settings with settings.manage_sites', async () => {
|
||||
const wrapper = await mountShell('/settings', {
|
||||
access: customAccess('settings.view, settings.manage_sites'),
|
||||
})
|
||||
|
||||
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hides the Sites child for the built-in Admin without settings.manage_sites', async () => {
|
||||
const wrapper = await mountShell('/settings', {
|
||||
access: customAccess('settings.view'),
|
||||
})
|
||||
|
||||
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('lists Sites for the built-in Admin', async () => {
|
||||
const wrapper = await mountShell('/settings', { role: 'admin' })
|
||||
|
||||
expect(wrapper.find('a[href="/settings/sites"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UIcon from '@nuxt/ui/components/Icon.vue'
|
||||
import router from '@/router'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { customAccess } from '@/test/access'
|
||||
import NoAccessState from '../NoAccessState.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
async function mountState(grants: string, roleName: string) {
|
||||
setActivePinia(createPinia())
|
||||
useSessionStore().session = {
|
||||
id: 4,
|
||||
verified: true,
|
||||
displayName: '',
|
||||
access: customAccess(grants, roleName),
|
||||
}
|
||||
useSessionStore().status = 'ready'
|
||||
await router.push('/schema')
|
||||
wrapper = mount(NoAccessState, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [router, uiPlugin] },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
setLocale('en')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('NoAccessState', () => {
|
||||
it('names the role and offers one action to Articles when articles can be read', async () => {
|
||||
const state = await mountState('content.read=all', 'Contributor')
|
||||
|
||||
expect(state.text()).toContain("You don't have access to this page")
|
||||
expect(state.text()).toContain(
|
||||
"Your role, Contributor, doesn't include it. Ask an owner or admin if you need it.",
|
||||
)
|
||||
const buttons = state.findAll('button')
|
||||
expect(buttons.map((button) => button.text())).toEqual(['Go to Articles'])
|
||||
expect(state.text()).not.toContain('See what I can do')
|
||||
})
|
||||
|
||||
it('sends a role that cannot read articles to Home', async () => {
|
||||
const state = await mountState('media.view', 'Media')
|
||||
|
||||
expect(state.findAll('button').map((button) => button.text())).toEqual(['Go to Home'])
|
||||
await state.find('button').trigger('click')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
|
||||
})
|
||||
|
||||
it('goes to /article from the Articles action', async () => {
|
||||
const state = await mountState('content.read=all', 'Contributor')
|
||||
|
||||
await state.find('button').trigger('click')
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/article'))
|
||||
})
|
||||
|
||||
it('speaks French', async () => {
|
||||
setLocale('fr')
|
||||
const state = await mountState('content.read=all', 'Contributor')
|
||||
|
||||
expect(state.text()).toContain('Vous n’avez pas accès à cette page')
|
||||
expect(state.text()).toContain('Aller aux articles')
|
||||
})
|
||||
|
||||
it('renders the lock icon', async () => {
|
||||
const state = await mountState('content.read=all', 'Contributor')
|
||||
|
||||
const icons = state.findAllComponents(UIcon).map((icon) => icon.props('name'))
|
||||
|
||||
expect(icons).toContain('i-lucide-lock')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,83 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { h } from 'vue'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import UIcon from '@nuxt/ui/components/Icon.vue'
|
||||
import { setLocale } from '@/i18n'
|
||||
import OwnerOnlyTag from '../OwnerOnlyTag.vue'
|
||||
import ReadOnlyBanner from '../ReadOnlyBanner.vue'
|
||||
import ViewOnlyMark from '../ViewOnlyMark.vue'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
function render(component: object, props: Record<string, unknown> = {}) {
|
||||
wrapper = mount(() => h(UApp, null, () => h(component, props)), {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [uiPlugin] },
|
||||
})
|
||||
return wrapper
|
||||
}
|
||||
|
||||
function iconsOf(target: VueWrapper) {
|
||||
return target.findAllComponents(UIcon).map((icon) => icon.props('name'))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
setLocale('en')
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('OwnerOnlyTag', () => {
|
||||
it('reads Owner only with a lock icon', () => {
|
||||
const tag = render(OwnerOnlyTag)
|
||||
|
||||
expect(tag.text()).toBe('Owner only')
|
||||
expect(iconsOf(tag)).toContain('i-lucide-lock')
|
||||
})
|
||||
|
||||
it('reads in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(render(OwnerOnlyTag).text()).toBe('Propriétaire uniquement')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ReadOnlyBanner', () => {
|
||||
it('shows the title and the description with a lock icon', () => {
|
||||
const banner = render(ReadOnlyBanner, {
|
||||
title: 'You can read these settings but not change them',
|
||||
description: 'Ask the owner to change them, or ask for a role that can edit settings.',
|
||||
})
|
||||
|
||||
expect(banner.text()).toContain('You can read these settings but not change them')
|
||||
expect(banner.text()).toContain('Ask the owner to change them')
|
||||
expect(iconsOf(banner)).toContain('i-lucide-lock')
|
||||
})
|
||||
})
|
||||
|
||||
describe('ViewOnlyMark', () => {
|
||||
it('shows View only with a lock icon and an accessible name', () => {
|
||||
const mark = render(ViewOnlyMark)
|
||||
|
||||
expect(mark.text()).toBe('View only')
|
||||
expect(mark.find('[data-testid="view-only-mark"]').attributes('aria-label')).toBe(
|
||||
'View only',
|
||||
)
|
||||
expect(iconsOf(mark)).toContain('i-lucide-lock')
|
||||
})
|
||||
|
||||
it('is focusable so the tooltip opens on focus', () => {
|
||||
expect(
|
||||
render(ViewOnlyMark).find('[data-testid="view-only-mark"]').attributes('tabindex'),
|
||||
).toBe('0')
|
||||
})
|
||||
|
||||
it('reads Lecture seule in French', () => {
|
||||
setLocale('fr')
|
||||
|
||||
expect(render(ViewOnlyMark).text()).toBe('Lecture seule')
|
||||
})
|
||||
})
|
||||
@@ -7,6 +7,7 @@ import router from '@/router'
|
||||
import UserMenu from '../UserMenu.vue'
|
||||
import { useColorMode } from '@/composables/useColorMode'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
interface MenuProps {
|
||||
username: string
|
||||
@@ -26,7 +27,7 @@ const baseProps: MenuProps = {
|
||||
async function mountMenu(props: Partial<MenuProps> = {}, path = '/home') {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta' }
|
||||
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
return mount(UserMenu, {
|
||||
|
||||
@@ -14,6 +14,7 @@ import { initializeLocale, locale, setLocale, t } from '@/i18n'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import router from '@/router'
|
||||
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from './viewport'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
const { ref } = await import('vue')
|
||||
@@ -39,7 +40,7 @@ const SIGNED_IN = {
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
email: 'marta@renew.example',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
|
||||
let wrapper: VueWrapper | undefined
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useApi } from '@/composables/useApi'
|
||||
import type { MediaItem } from '@/composables/useMedia'
|
||||
import { DESKTOP_WIDTH, setViewportWidth } from './viewport'
|
||||
import { fixture, textOf } from '@/views/__tests__/mediaTestKit'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
export { textOf }
|
||||
|
||||
@@ -50,7 +51,9 @@ export function installApi(routes: PickerRoutes = {}) {
|
||||
return (routes.list ?? (() => pageBody()))(url.searchParams)
|
||||
})
|
||||
vi.mocked(useApi).mockReturnValue({
|
||||
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
|
||||
getMe: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ verified: true, access: ownerAccess(), username: 'marta', id: 1 }),
|
||||
request: apiKit.request,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { t } from '@/i18n'
|
||||
|
||||
const emit = defineEmits<{ home: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<UEmpty
|
||||
variant="naked"
|
||||
icon="i-lucide-lock"
|
||||
:title="t('You don\'t have access')"
|
||||
:description="
|
||||
t('Only admins can manage image variants. Ask an admin if you need one changed.')
|
||||
"
|
||||
:ui="{
|
||||
root: 'gap-3 border border-default bg-default px-6 py-18',
|
||||
avatar: 'mb-0 size-11 rounded-none bg-elevated text-toned',
|
||||
title: 'text-md font-semibold',
|
||||
description: 'max-w-95 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template #actions>
|
||||
<UButton
|
||||
color="neutral"
|
||||
variant="outline"
|
||||
icon="i-lucide-arrow-left"
|
||||
class="h-11 gap-1.5 border border-accented bg-default px-3 text-sm text-highlighted ring-0 sm:h-8"
|
||||
@click="emit('home')"
|
||||
>
|
||||
{{ t('Back to Home') }}
|
||||
</UButton>
|
||||
</template>
|
||||
</UEmpty>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError, MissingTotalError, NetworkError, isOffline, useApi } from '@/composables/useApi'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
|
||||
return {
|
||||
@@ -58,7 +59,7 @@ describe('useApi global 401/403/429/offline handling', () => {
|
||||
|
||||
it('A 401 received mid-session redirects to login with the expired reason and clears the session', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true }
|
||||
session.session = { verified: true, access: ownerAccess() }
|
||||
session.status = 'ready'
|
||||
await router.push('/article/12/edit')
|
||||
fetchMock.mockResolvedValue(mockResponse(401))
|
||||
@@ -207,6 +208,7 @@ describe('getMe maps the account fields', () => {
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
is_verified: true,
|
||||
access: ownerAccess(),
|
||||
display_name: 'Marta Alves',
|
||||
}),
|
||||
)
|
||||
@@ -214,6 +216,7 @@ describe('getMe maps the account fields', () => {
|
||||
await expect(useApi().getMe()).resolves.toEqual({
|
||||
id: 1,
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
displayName: 'Marta Alves',
|
||||
@@ -230,6 +233,7 @@ describe('getMe maps the account fields', () => {
|
||||
username: 'marta',
|
||||
email: 'marta@renew.example',
|
||||
is_verified: true,
|
||||
access: ownerAccess(),
|
||||
display_name: value,
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import router from '@/router'
|
||||
import { useSessionStore, refreshSessionWhenVisible } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { ApiError, useApi } from '@/composables/useApi'
|
||||
import { ownerAccess, writerAccess, customAccess } from '@/test/access'
|
||||
|
||||
const toastAdd = vi.fn()
|
||||
|
||||
vi.mock('@/i18n/presentation', () => ({
|
||||
useLocalizedToast: () => ({
|
||||
add: (content: unknown) => toastAdd(typeof content === 'function' ? content() : content),
|
||||
}),
|
||||
}))
|
||||
|
||||
function reply(
|
||||
status: number,
|
||||
body: unknown = null,
|
||||
headers: Record<string, string> = {},
|
||||
text?: string,
|
||||
) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: (name: string) => headers[name] ?? null },
|
||||
json: async () => body,
|
||||
text: async () => text ?? '',
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
const fetchMock = vi.fn()
|
||||
|
||||
function answer(refusal: Response) {
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
url === '/v1/auth/me'
|
||||
? reply(200, { id: 4, is_verified: true, access: ownerAccess() })
|
||||
: refusal,
|
||||
)
|
||||
}
|
||||
|
||||
function signIn(access = ownerAccess()) {
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 4, verified: true, displayName: '', access }
|
||||
session.status = 'ready'
|
||||
return session
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
fetchMock.mockReset()
|
||||
toastAdd.mockReset()
|
||||
global.fetch = fetchMock as unknown as typeof fetch
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
describe('getMe maps the access object', () => {
|
||||
it('keeps the role and the permission entries in order and drops the role string', async () => {
|
||||
const writer = writerAccess()
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(200, {
|
||||
id: 4,
|
||||
is_verified: true,
|
||||
username: 'wren',
|
||||
email: 'wren@example.com',
|
||||
display_name: 'Wren',
|
||||
access: writer,
|
||||
}),
|
||||
)
|
||||
|
||||
const me = await useApi().getMe()
|
||||
|
||||
expect(me.access?.role).toEqual({ id: 3, name: 'Writer', built_in: true, locked: false })
|
||||
expect(me.access?.permissions).toHaveLength(9)
|
||||
expect(me.access?.permissions).toEqual(writer.permissions)
|
||||
expect('role' in me).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('403 with a code', () => {
|
||||
it('rejects with the code and the missing permission and shows the toast naming it', async () => {
|
||||
signIn()
|
||||
answer(reply(403, null, { 'X-Missing-Permission': 'content.delete' }, 'missing_permission'))
|
||||
|
||||
const call = useApi().request('/v1/article/1', { method: 'DELETE' })
|
||||
|
||||
await expect(call).rejects.toBeInstanceOf(ApiError)
|
||||
await expect(call).rejects.toMatchObject({
|
||||
status: 403,
|
||||
code: 'missing_permission',
|
||||
permission: 'content.delete',
|
||||
})
|
||||
expect(toastAdd).toHaveBeenCalledTimes(1)
|
||||
expect(toastAdd.mock.calls[0]![0]).toMatchObject({
|
||||
color: 'error',
|
||||
title: "You don't have permission to do that",
|
||||
description: "Your role doesn't include Delete entries. Ask an owner or admin.",
|
||||
})
|
||||
})
|
||||
|
||||
it('names Invite users for users.invite', async () => {
|
||||
signIn()
|
||||
answer(reply(403, null, { 'X-Missing-Permission': 'users.invite' }, 'missing_permission'))
|
||||
|
||||
await expect(
|
||||
useApi().request('/v1/user/invite', { method: 'POST' }),
|
||||
).rejects.toBeInstanceOf(ApiError)
|
||||
|
||||
expect(toastAdd.mock.calls[0]![0].description).toContain('Invite users')
|
||||
})
|
||||
|
||||
it('uses the generic sentence without the header', async () => {
|
||||
signIn()
|
||||
answer(reply(403, null, {}, 'missing_permission'))
|
||||
|
||||
await expect(useApi().request('/v1/x')).rejects.toMatchObject({
|
||||
code: 'missing_permission',
|
||||
permission: undefined,
|
||||
})
|
||||
|
||||
expect(toastAdd.mock.calls[0]![0].description).toBe(
|
||||
"Your role doesn't include this action. Ask an owner or admin.",
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps the escalation key and shows no global toast', async () => {
|
||||
signIn()
|
||||
fetchMock.mockResolvedValue(
|
||||
reply(403, null, { 'X-Escalation-Key': 'content.publish:events' }, 'escalation'),
|
||||
)
|
||||
|
||||
await expect(useApi().request('/v1/role/3', { method: 'PUT' })).rejects.toMatchObject({
|
||||
code: 'escalation',
|
||||
escalationKey: 'content.publish:events',
|
||||
})
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each([
|
||||
['role_locked', 'role_locked'],
|
||||
['own_role', 'own_role'],
|
||||
['role_built_in', 'role_built_in'],
|
||||
['built_in_name', 'built_in_name'],
|
||||
['This action needs a real session.', 'session_required'],
|
||||
])('keeps %s as the code without a global toast', async (body, code) => {
|
||||
signIn()
|
||||
fetchMock.mockResolvedValue(reply(403, null, {}, body))
|
||||
|
||||
await expect(useApi().request('/v1/role/1', { method: 'PUT' })).rejects.toMatchObject({
|
||||
code,
|
||||
})
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it.each(['role_conflict', 'user_role_changed'])(
|
||||
'maps the 409 %s without a toast',
|
||||
async (body) => {
|
||||
signIn()
|
||||
fetchMock.mockResolvedValue(reply(409, null, {}, body))
|
||||
|
||||
await expect(useApi().request('/v1/role/3', { method: 'PUT' })).rejects.toMatchObject({
|
||||
status: 409,
|
||||
code: body,
|
||||
})
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
|
||||
it('lets a caller opt out of the toast and still refreshes the session', async () => {
|
||||
signIn()
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
reply(
|
||||
403,
|
||||
null,
|
||||
{ 'X-Missing-Permission': 'content.delete' },
|
||||
'missing_permission',
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
reply(200, { id: 4, is_verified: true, access: customAccess('content.read=all') }),
|
||||
)
|
||||
|
||||
await expect(
|
||||
useApi().request('/v1/article/1', {
|
||||
method: 'DELETE',
|
||||
onMissingPermission: 'passthrough',
|
||||
}),
|
||||
).rejects.toMatchObject({ code: 'missing_permission', permission: 'content.delete' })
|
||||
await flushPromises()
|
||||
|
||||
expect(toastAdd).not.toHaveBeenCalled()
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2)
|
||||
expect(fetchMock.mock.calls[1]![0]).toBe('/v1/auth/me')
|
||||
})
|
||||
|
||||
it('still sends an unverified session to the pending page', async () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: false, displayName: '' }
|
||||
session.status = 'ready'
|
||||
fetchMock.mockResolvedValue(reply(403))
|
||||
|
||||
await expect(useApi().request('/v1/article/')).rejects.toMatchObject({ status: 403 })
|
||||
|
||||
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/register/pending'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('live refresh', () => {
|
||||
it('refreshes the session once after a refusal and drops the lost grant', async () => {
|
||||
const session = signIn(customAccess('content.delete=all, content.read=all'))
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(
|
||||
reply(
|
||||
403,
|
||||
null,
|
||||
{ 'X-Missing-Permission': 'content.delete' },
|
||||
'missing_permission',
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
reply(200, { id: 4, is_verified: true, access: customAccess('content.read=all') }),
|
||||
)
|
||||
expect(usePermissionsStore().can('content.delete')).toBe(true)
|
||||
|
||||
await expect(
|
||||
useApi().request('/v1/article/1', { method: 'DELETE' }),
|
||||
).rejects.toBeInstanceOf(ApiError)
|
||||
await flushPromises()
|
||||
|
||||
expect(fetchMock.mock.calls.filter(([url]) => url === '/v1/auth/me')).toHaveLength(1)
|
||||
expect(usePermissionsStore().can('content.delete')).toBe(false)
|
||||
expect(session.session?.verified).toBe(true)
|
||||
})
|
||||
|
||||
it('refreshes at most once every 5 seconds', async () => {
|
||||
vi.useFakeTimers()
|
||||
signIn()
|
||||
fetchMock.mockImplementation(async (url: string) =>
|
||||
url === '/v1/auth/me'
|
||||
? reply(200, { id: 4, is_verified: true, access: ownerAccess() })
|
||||
: reply(
|
||||
403,
|
||||
null,
|
||||
{ 'X-Missing-Permission': 'content.delete' },
|
||||
'missing_permission',
|
||||
),
|
||||
)
|
||||
const refuse = () =>
|
||||
useApi()
|
||||
.request('/v1/article/1', { method: 'DELETE' })
|
||||
.catch(() => undefined)
|
||||
const refreshes = () => fetchMock.mock.calls.filter(([url]) => url === '/v1/auth/me').length
|
||||
|
||||
await refuse()
|
||||
await refuse()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(refreshes()).toBe(1)
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5001)
|
||||
await refuse()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
expect(refreshes()).toBe(2)
|
||||
})
|
||||
|
||||
it('keeps the previous grants and shows no error when the refresh fails', async () => {
|
||||
signIn(customAccess('content.read=all'))
|
||||
fetchMock
|
||||
.mockResolvedValueOnce(reply(403, null, {}, 'missing_permission'))
|
||||
.mockRejectedValueOnce(new TypeError('offline'))
|
||||
|
||||
await expect(useApi().request('/v1/x')).rejects.toBeInstanceOf(ApiError)
|
||||
await flushPromises()
|
||||
|
||||
expect(usePermissionsStore().can('content.read')).toBe(true)
|
||||
expect(useSessionStore().status).toBe('ready')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refresh when the tab comes back', () => {
|
||||
function showTab() {
|
||||
Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' })
|
||||
document.dispatchEvent(new Event('visibilitychange'))
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
refreshSessionWhenVisible()
|
||||
fetchMock.mockResolvedValue(reply(200, { id: 4, is_verified: true, access: ownerAccess() }))
|
||||
})
|
||||
|
||||
it('refreshes when the last answer is 31 seconds old', async () => {
|
||||
const session = signIn()
|
||||
await session.refresh()
|
||||
fetchMock.mockClear()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(31000)
|
||||
showTab()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not refresh when the last answer is 10 seconds old', async () => {
|
||||
const session = signIn()
|
||||
await session.refresh()
|
||||
fetchMock.mockClear()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(10000)
|
||||
showTab()
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
expect(fetchMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -4,6 +4,7 @@ import { ApiError } from '@/composables/useApi'
|
||||
import { useContentTypes } from '@/composables/useContentTypes'
|
||||
import { useSchemaGate, type SchemaGateScope } from '@/composables/useSchemaGate'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { noAccess, ownerAccess } from '@/test/access'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
vi.mock('@/composables/useContentTypes', () => ({ useContentTypes: vi.fn() }))
|
||||
@@ -17,7 +18,10 @@ beforeEach(() => {
|
||||
listBuiltinTypes.mockReset()
|
||||
vi.mocked(useContentTypes).mockReturnValue({ listContentTypes, listBuiltinTypes })
|
||||
const session = useSessionStore()
|
||||
session.$patch({ status: 'ready', session: { role: 'owner' } })
|
||||
session.$patch({
|
||||
status: 'ready',
|
||||
session: { verified: true, displayName: '', access: ownerAccess() },
|
||||
})
|
||||
})
|
||||
|
||||
async function gate(scope: SchemaGateScope) {
|
||||
@@ -52,7 +56,9 @@ describe('schema gate forbidden', () => {
|
||||
it('is forbidden for a session that cannot manage content, whatever the scope', async () => {
|
||||
listContentTypes.mockResolvedValue([])
|
||||
listBuiltinTypes.mockResolvedValue([])
|
||||
useSessionStore().$patch({ session: { role: 'user' } })
|
||||
useSessionStore().$patch({
|
||||
session: { verified: true, displayName: '', access: noAccess() },
|
||||
})
|
||||
|
||||
expect((await gate('custom')).forbidden.value).toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { roleRefFor } from '@/test/access'
|
||||
import { userRole, type UserListItem } from '@/composables/useUsers'
|
||||
|
||||
describe('userRole', () => {
|
||||
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (role) => {
|
||||
it.each([['owner' as const], ['admin' as const]])('passes a user role %s through', (name) => {
|
||||
const role = roleRefFor(name)
|
||||
const item: UserListItem = {
|
||||
kind: 'user',
|
||||
id: 1,
|
||||
@@ -14,21 +16,22 @@ describe('userRole', () => {
|
||||
created_at: '2026-03-12T00:00:00Z',
|
||||
}
|
||||
|
||||
expect(userRole(item)).toBe(role)
|
||||
expect(userRole(item)).toEqual(role)
|
||||
})
|
||||
|
||||
it.each([['pending' as const], ['expired' as const]])(
|
||||
'returns admin for a %s invite',
|
||||
'reads the role of a %s invite and falls back to null without one',
|
||||
(status) => {
|
||||
const item: UserListItem = {
|
||||
kind: 'invite',
|
||||
const base = {
|
||||
kind: 'invite' as const,
|
||||
id: 2,
|
||||
email: 'i@renew.example',
|
||||
status,
|
||||
created_at: '2026-03-12T00:00:00Z',
|
||||
}
|
||||
|
||||
expect(userRole(item)).toBe('admin')
|
||||
expect(userRole({ ...base, role: roleRefFor('member') })).toEqual(roleRefFor('member'))
|
||||
expect(userRole(base)).toBeNull()
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -3,6 +3,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useActivity, type PersonOptions } from '@/composables/useActivity'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { ACTIVITY_PAGE_SIZE_KEY, resolvePageSize, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import {
|
||||
activeChips,
|
||||
@@ -48,6 +49,7 @@ function delayedFlag() {
|
||||
|
||||
export function useActivityPage() {
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { listActivity, getSummary, listPeople } = useActivity()
|
||||
@@ -77,8 +79,8 @@ export function useActivityPage() {
|
||||
let peopleRequested = false
|
||||
let requestedPage = 1
|
||||
|
||||
const role = computed(() => session.session?.role as string | undefined)
|
||||
const isFullView = computed(() => role.value === 'owner' || role.value === 'admin')
|
||||
const isFullView = computed(() => permissions.scopeOf('activity.view') === 'all')
|
||||
const canExport = computed(() => permissions.can('activity.export'))
|
||||
const chips = computed(() => activeChips(filters.value))
|
||||
const showListSkeleton = computed(
|
||||
() => listState.value === 'loading' && listSkeleton.flag.value,
|
||||
@@ -280,6 +282,7 @@ export function useActivityPage() {
|
||||
people,
|
||||
chips,
|
||||
isFullView,
|
||||
canExport,
|
||||
isEmpty,
|
||||
exportDisabled,
|
||||
showListSkeleton,
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { ref } from 'vue'
|
||||
import router from '@/router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ApiError } from '@/lib/apiError'
|
||||
import { ApiError, errorCodeOf } from '@/lib/apiError'
|
||||
import { missingPermissionCopy } from '@/lib/permissionCopy'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import type { Access, Grant, RoleRef } from '@/lib/permissions'
|
||||
|
||||
export { ApiError }
|
||||
|
||||
@@ -22,8 +25,6 @@ export interface PagedResponse<T> {
|
||||
total: number
|
||||
}
|
||||
|
||||
export type UserRole = 'owner' | 'admin'
|
||||
|
||||
export interface MeAuthor {
|
||||
id: number
|
||||
name: string
|
||||
@@ -36,16 +37,18 @@ export interface MeResult {
|
||||
username?: string
|
||||
email?: string
|
||||
displayName: string
|
||||
role?: UserRole
|
||||
access?: Access
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
export type OnUnauthorized = 'redirect' | 'passthrough'
|
||||
export type OnForbidden = 'redirect' | 'passthrough'
|
||||
export type OnMissingPermission = 'toast' | 'passthrough'
|
||||
|
||||
export interface RequestOptions extends RequestInit {
|
||||
onUnauthorized?: OnUnauthorized
|
||||
onForbidden?: OnForbidden
|
||||
onMissingPermission?: OnMissingPermission
|
||||
}
|
||||
|
||||
export function isAbort(error: unknown) {
|
||||
@@ -115,9 +118,52 @@ export function redirectUnverified(onForbidden: OnForbidden = 'redirect') {
|
||||
}
|
||||
}
|
||||
|
||||
const MISSING_PERMISSION_TOAST_ID = 'missing-permission'
|
||||
|
||||
function showMissingPermissionToast(permission: string | undefined) {
|
||||
useLocalizedToast().add(() => {
|
||||
const copy = missingPermissionCopy(permission)
|
||||
return {
|
||||
id: MISSING_PERMISSION_TOAST_ID,
|
||||
color: 'error',
|
||||
icon: 'i-lucide-circle-x',
|
||||
title: copy.title,
|
||||
description: copy.description,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function headerOrUndefined(response: Response, name: string) {
|
||||
return response.headers.get(name) ?? undefined
|
||||
}
|
||||
|
||||
async function failForbidden(
|
||||
response: Response,
|
||||
onForbidden: OnForbidden,
|
||||
onMissingPermission: OnMissingPermission,
|
||||
): Promise<never> {
|
||||
redirectUnverified(onForbidden)
|
||||
const body = await readErrorBody(response)
|
||||
const code = errorCodeOf(body)
|
||||
const permission = headerOrUndefined(response, 'X-Missing-Permission')
|
||||
const escalationKey = headerOrUndefined(response, 'X-Escalation-Key')
|
||||
if (code === 'missing_permission') {
|
||||
void useSessionStore().refreshAfterRefusal()
|
||||
if (onMissingPermission !== 'passthrough') {
|
||||
showMissingPermissionToast(permission)
|
||||
}
|
||||
}
|
||||
throw new ApiError(403, undefined, body, { code, permission, escalationKey })
|
||||
}
|
||||
|
||||
export function useApi() {
|
||||
async function send(path: string, options: RequestOptions = {}): Promise<Response> {
|
||||
const { onUnauthorized = 'redirect', onForbidden = 'redirect', ...init } = options
|
||||
const {
|
||||
onUnauthorized = 'redirect',
|
||||
onForbidden = 'redirect',
|
||||
onMissingPermission = 'toast',
|
||||
...init
|
||||
} = options
|
||||
|
||||
let response: Response
|
||||
try {
|
||||
@@ -139,15 +185,15 @@ export function useApi() {
|
||||
}
|
||||
|
||||
if (response.status === 403) {
|
||||
redirectUnverified(onForbidden)
|
||||
throw new ApiError(403, undefined, await readErrorBody(response))
|
||||
return failForbidden(response, onForbidden, onMissingPermission)
|
||||
}
|
||||
|
||||
if (response.status === 429) {
|
||||
throw new ApiError(429, parseRetryAfterSeconds(response))
|
||||
}
|
||||
|
||||
throw new ApiError(response.status, undefined, await readErrorBody(response))
|
||||
const body = await readErrorBody(response)
|
||||
throw new ApiError(response.status, undefined, body, { code: errorCodeOf(body) })
|
||||
}
|
||||
|
||||
return response
|
||||
@@ -187,7 +233,7 @@ export function useApi() {
|
||||
username?: string
|
||||
email?: string
|
||||
display_name?: string | null
|
||||
role?: string
|
||||
access?: { role: RoleRef; permissions: Grant[] }
|
||||
author?: MeAuthor | null
|
||||
}>('/v1/auth/me', { onUnauthorized: 'passthrough' })
|
||||
return {
|
||||
@@ -196,7 +242,9 @@ export function useApi() {
|
||||
username: body.username,
|
||||
email: body.email,
|
||||
displayName: body.display_name ?? '',
|
||||
role: body.role as UserRole | undefined,
|
||||
...(body.access
|
||||
? { access: { role: body.access.role, permissions: body.access.permissions } }
|
||||
: {}),
|
||||
...(body.author ? { author: body.author } : {}),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { t } from '@/i18n'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
interface ArticleMenuHandlers {
|
||||
isBusy: (article: Article) => boolean
|
||||
edit: (article: Article) => void
|
||||
publish: (id: number) => void
|
||||
unpublish: (id: number) => void
|
||||
copyUrl: (id: number) => void
|
||||
remove: (article: Article) => void
|
||||
}
|
||||
|
||||
export function useArticleMenu(handlers: ArticleMenuHandlers) {
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
|
||||
function publishHint(article: Article) {
|
||||
return !article.is_online && !access.canPublish(article.created_by)
|
||||
? t('Ask an editor to publish.')
|
||||
: ''
|
||||
}
|
||||
|
||||
function actionsFor(article: Article) {
|
||||
const row = access.rowAccess(article.created_by)
|
||||
const toggle = access.canPublish(article.created_by)
|
||||
? [
|
||||
{
|
||||
get label() {
|
||||
return article.is_online ? t('Unpublish') : t('Publish')
|
||||
},
|
||||
icon: article.is_online ? 'i-lucide-globe-lock' : 'i-lucide-globe',
|
||||
disabled: handlers.isBusy(article),
|
||||
onSelect: () => {
|
||||
if (article.is_online) {
|
||||
handlers.unpublish(article.id)
|
||||
} else {
|
||||
handlers.publish(article.id)
|
||||
}
|
||||
},
|
||||
},
|
||||
]
|
||||
: []
|
||||
return [
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Edit')
|
||||
},
|
||||
icon: 'i-lucide-pencil',
|
||||
disabled: !row.canEdit,
|
||||
description: row.canEdit ? undefined : row.editReason,
|
||||
onSelect: () => handlers.edit(article),
|
||||
},
|
||||
...toggle,
|
||||
{
|
||||
get label() {
|
||||
return t('Copy API URL')
|
||||
},
|
||||
icon: 'i-lucide-link',
|
||||
onSelect: () => handlers.copyUrl(article.id),
|
||||
},
|
||||
],
|
||||
[
|
||||
{
|
||||
get label() {
|
||||
return t('Delete…')
|
||||
},
|
||||
icon: 'i-lucide-trash-2',
|
||||
color: 'error' as const,
|
||||
disabled: !row.canDelete,
|
||||
description: row.canDelete ? undefined : row.deleteReason,
|
||||
onSelect: () => handlers.remove(article),
|
||||
},
|
||||
],
|
||||
]
|
||||
}
|
||||
|
||||
return { actionsFor, publishHint, access }
|
||||
}
|
||||
@@ -12,6 +12,7 @@ export interface Article extends Partial<ArticleSeo> {
|
||||
body: string
|
||||
is_online: boolean
|
||||
site_id?: number
|
||||
created_by?: number
|
||||
authors?: Byline[]
|
||||
created_at: string
|
||||
updated_at: string
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { computed } from 'vue'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
export function useCanManageVariants() {
|
||||
const session = useSessionStore()
|
||||
return computed(() => session.session?.role === 'owner' || session.session?.role === 'admin')
|
||||
const permissions = usePermissionsStore()
|
||||
return computed(() => permissions.can('settings.edit_general'))
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { computed } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
export type ContentNoun = 'entries' | 'articles'
|
||||
|
||||
export interface RowAccess {
|
||||
canEdit: boolean
|
||||
canDelete: boolean
|
||||
editReason: string
|
||||
deleteReason: string
|
||||
}
|
||||
|
||||
const NO_REASON = ''
|
||||
|
||||
export function useContentAccess(typeKey: string, noun: ContentNoun) {
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const canCreate = computed(() => permissions.can('content.create', { typeKey }))
|
||||
const updateScope = computed(() => permissions.scopeOf('content.update', { typeKey }))
|
||||
const deleteScope = computed(() => permissions.scopeOf('content.delete', { typeKey }))
|
||||
const publishScope = computed(() => permissions.scopeOf('content.publish', { typeKey }))
|
||||
const updatesOwnOnly = computed(() => updateScope.value === 'own')
|
||||
|
||||
function noneReason(action: 'edit' | 'delete') {
|
||||
if (noun === 'articles') {
|
||||
return action === 'edit'
|
||||
? t("Your role can't edit articles.")
|
||||
: t("Your role can't delete articles.")
|
||||
}
|
||||
return action === 'edit'
|
||||
? t("Your role can't edit entries.")
|
||||
: t("Your role can't delete entries.")
|
||||
}
|
||||
|
||||
function reasonFor(action: 'edit' | 'delete', allowed: boolean, scope: string) {
|
||||
if (allowed) {
|
||||
return NO_REASON
|
||||
}
|
||||
return scope === 'none' ? noneReason(action) : t('You can only edit content you created.')
|
||||
}
|
||||
|
||||
function rowAccess(createdBy: number | undefined): RowAccess {
|
||||
const ownerId = createdBy ?? 0
|
||||
const canEdit = permissions.can('content.update', { typeKey, ownerId })
|
||||
const canDelete = permissions.can('content.delete', { typeKey, ownerId })
|
||||
return {
|
||||
canEdit,
|
||||
canDelete,
|
||||
editReason: reasonFor('edit', canEdit, updateScope.value),
|
||||
deleteReason: reasonFor('delete', canDelete, deleteScope.value),
|
||||
}
|
||||
}
|
||||
|
||||
function canPublish(createdBy: number | undefined) {
|
||||
return permissions.can('content.publish', { typeKey, ownerId: createdBy ?? 0 })
|
||||
}
|
||||
|
||||
return {
|
||||
canCreate,
|
||||
updateScope,
|
||||
deleteScope,
|
||||
publishScope,
|
||||
updatesOwnOnly,
|
||||
rowAccess,
|
||||
canPublish,
|
||||
}
|
||||
}
|
||||
@@ -46,15 +46,6 @@ export function useDirectoryToasts() {
|
||||
)
|
||||
}
|
||||
|
||||
function ownerOnly() {
|
||||
show(
|
||||
'site-owner-only',
|
||||
'error',
|
||||
() => t('Only the owner can manage sites'),
|
||||
() => t('Ask the owner of this site.'),
|
||||
)
|
||||
}
|
||||
|
||||
function defaultSiteBlocked() {
|
||||
show('site-default-blocked', 'error', () => copy('defaultSiteBlocked'))
|
||||
}
|
||||
@@ -122,7 +113,7 @@ export function useDirectoryToasts() {
|
||||
|
||||
function report(kind: FailureKind, context: FailureContext) {
|
||||
const handlers: Partial<Record<FailureKind, () => void>> = {
|
||||
forbidden: ownerOnly,
|
||||
permission: () => undefined,
|
||||
defaultSite: defaultSiteBlocked,
|
||||
rights: authorRights,
|
||||
gone: context === 'site' ? siteGone : authorGone,
|
||||
@@ -136,7 +127,6 @@ export function useDirectoryToasts() {
|
||||
report,
|
||||
siteGone,
|
||||
authorGone,
|
||||
ownerOnly,
|
||||
defaultSiteBlocked,
|
||||
authorRights,
|
||||
siteDefaultChanged,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { useEntryDraft } from '@/composables/useEntryDraft'
|
||||
import { useEntryNames } from '@/composables/useEntryNames'
|
||||
import { useEntryRemoval } from '@/composables/useEntryRemoval'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { entryTitle } from '@/lib/entryForm'
|
||||
import type { EntryTarget } from '@/lib/entryEditorState'
|
||||
@@ -29,6 +30,17 @@ export function useEntryEditorPage(
|
||||
const editor = useEntryDraft(typeKey, target)
|
||||
const { detail, load, form } = editor
|
||||
const names = useEntryNames(() => load.entry.value)
|
||||
const permissions = usePermissionsStore()
|
||||
const readOnly = computed(() => {
|
||||
const entry = load.entry.value
|
||||
return (
|
||||
!editor.creating.value &&
|
||||
entry != null &&
|
||||
!permissions.can('content.update', { typeKey, ownerId: entry.created_by })
|
||||
)
|
||||
})
|
||||
const locked = computed(() => editor.locked.value || readOnly.value)
|
||||
const canSave = computed(() => editor.canSave.value && !readOnly.value)
|
||||
|
||||
const typeName = computed(() => detail.type.value?.name ?? typeKey)
|
||||
const title = computed(() => entryTitle(editor.fields.value, load.entry.value))
|
||||
@@ -131,6 +143,9 @@ export function useEntryEditorPage(
|
||||
|
||||
return {
|
||||
...editor,
|
||||
readOnly,
|
||||
locked,
|
||||
canSave,
|
||||
isDesktop,
|
||||
names,
|
||||
typeName,
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ref } from 'vue'
|
||||
import { t } from '@/i18n'
|
||||
import { useLocalizedToast } from '@/i18n/presentation'
|
||||
import { ApiError } from '@/composables/useApi'
|
||||
import { useEntries } from '@/composables/useEntries'
|
||||
import type { ContentEntry } from '@/lib/contentTypes'
|
||||
|
||||
const UNAUTHORIZED = 401
|
||||
const TOAST_ID = 'entry-deleted'
|
||||
|
||||
export function useEntryRowDelete(
|
||||
typeKey: string,
|
||||
typeName: () => string,
|
||||
reload: () => Promise<unknown>,
|
||||
) {
|
||||
const { deleteEntry } = useEntries()
|
||||
const toast = useLocalizedToast()
|
||||
|
||||
const target = ref<ContentEntry | null>(null)
|
||||
const pending = ref(false)
|
||||
const failed = ref(false)
|
||||
|
||||
function open(entry: ContentEntry) {
|
||||
target.value = entry
|
||||
failed.value = false
|
||||
}
|
||||
|
||||
function close() {
|
||||
if (!pending.value) {
|
||||
target.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function titleOf(entry: ContentEntry) {
|
||||
return t('Entry {0}', [entry.id])
|
||||
}
|
||||
|
||||
async function confirm() {
|
||||
const entry = target.value
|
||||
if (!entry || pending.value) {
|
||||
return
|
||||
}
|
||||
pending.value = true
|
||||
try {
|
||||
await deleteEntry(typeKey, entry.id)
|
||||
} catch (error) {
|
||||
pending.value = false
|
||||
if (!(error instanceof ApiError && error.status === UNAUTHORIZED)) {
|
||||
failed.value = true
|
||||
}
|
||||
return
|
||||
}
|
||||
pending.value = false
|
||||
target.value = null
|
||||
toast.add(() => ({
|
||||
id: TOAST_ID,
|
||||
color: 'success',
|
||||
icon: 'i-lucide-circle-check',
|
||||
title: t('Entry deleted'),
|
||||
description: t('"{0}" was deleted from {1}.', [titleOf(entry), typeName()]),
|
||||
}))
|
||||
await reload()
|
||||
}
|
||||
|
||||
return { target, pending, failed, open, close, confirm, titleOf }
|
||||
}
|
||||
@@ -2,11 +2,13 @@ import { computed, watch } from 'vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useContentTypesStore } from '@/stores/contentTypes'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
export type SchemaGateScope = 'all' | 'builtin' | 'custom'
|
||||
|
||||
export function useSchemaGate(scope: SchemaGateScope) {
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const store = useContentTypesStore()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
const withCustom = scope !== 'builtin'
|
||||
@@ -15,7 +17,7 @@ export function useSchemaGate(scope: SchemaGateScope) {
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const forbidden = computed(
|
||||
() =>
|
||||
(sessionReady.value && !session.canManageContent) ||
|
||||
(sessionReady.value && !permissions.can('schema.view')) ||
|
||||
(withCustom && store.status === 'forbidden') ||
|
||||
(withBuiltin && store.builtinStatus === 'forbidden'),
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useApi } from '@/composables/useApi'
|
||||
import type { LinkableUser } from '@/lib/directoryTypes'
|
||||
import type { RoleRef } from '@/lib/permissions'
|
||||
|
||||
export type UserListItem =
|
||||
| {
|
||||
@@ -8,7 +9,7 @@ export type UserListItem =
|
||||
username: string
|
||||
display_name: string
|
||||
email: string
|
||||
role: 'owner' | 'admin'
|
||||
role: RoleRef | null
|
||||
status: 'accepted'
|
||||
created_at: string
|
||||
}
|
||||
@@ -17,6 +18,7 @@ export type UserListItem =
|
||||
id: number
|
||||
email: string
|
||||
display_name?: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending' | 'expired'
|
||||
created_at: string
|
||||
}
|
||||
@@ -31,14 +33,15 @@ export function userDisplayName(item: UserListItem) {
|
||||
return item.display_name ?? ''
|
||||
}
|
||||
|
||||
export function userRole(item: UserListItem): 'owner' | 'admin' {
|
||||
return item.kind === 'user' ? item.role : 'admin'
|
||||
export function userRole(item: UserListItem): RoleRef | null {
|
||||
return item.role ?? null
|
||||
}
|
||||
|
||||
export interface CreatedInvite {
|
||||
id: number
|
||||
kind: 'invite'
|
||||
email: string
|
||||
role?: RoleRef | null
|
||||
status: 'pending'
|
||||
created_at: string
|
||||
expires_at: string
|
||||
|
||||
@@ -200,7 +200,7 @@ const USER_LIST_KEYS = [
|
||||
'status',
|
||||
'created_at',
|
||||
]
|
||||
const INVITE_KEYS = ['id', 'kind', 'email', 'status', 'created_at', 'expires_at']
|
||||
const INVITE_KEYS = ['id', 'kind', 'email', 'role', 'status', 'created_at', 'expires_at']
|
||||
const ME_KEYS = [
|
||||
'id',
|
||||
'username',
|
||||
@@ -209,8 +209,8 @@ const ME_KEYS = [
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'display_name',
|
||||
'role',
|
||||
]
|
||||
const AUTH_ME_KEYS = [...ME_KEYS, 'author', 'access']
|
||||
|
||||
function sampleKey(path: string) {
|
||||
if (path.startsWith('/v1/content-type')) {
|
||||
@@ -538,7 +538,7 @@ describe('Response samples use the real domain JSON keys', () => {
|
||||
})
|
||||
|
||||
it('uses the public user keys for the profile routes', () => {
|
||||
expectOnlyKeys(parsed(find('GET /v1/auth/me')), ME_KEYS)
|
||||
expectOnlyKeys(parsed(find('GET /v1/auth/me')), AUTH_ME_KEYS)
|
||||
expectOnlyKeys(parsed(find('PATCH /v1/user/me')), ME_KEYS)
|
||||
})
|
||||
|
||||
@@ -608,7 +608,10 @@ describe('Request bodies and query parameters', () => {
|
||||
])
|
||||
expect(bodyOf('PATCH /v1/user/me')).toEqual([['display_name', 'string', true]])
|
||||
expect(find('PATCH /v1/user/me').body[0]!.rule).toContain('60')
|
||||
expect(bodyOf('POST /v1/user/invite')).toEqual([['email', 'string', true]])
|
||||
expect(bodyOf('POST /v1/user/invite')).toEqual([
|
||||
['email', 'string', true],
|
||||
['role_id', 'number', false],
|
||||
])
|
||||
})
|
||||
|
||||
it('limits the SEO request fields like the handler validate tags', () => {
|
||||
@@ -1042,3 +1045,31 @@ describe('Custom content types round', () => {
|
||||
expect(created.curl).toContain('-d \'{\n "key": "events"')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Role and permission shapes', () => {
|
||||
it('shows access.role and access.permissions and no role string in the /auth/me sample', () => {
|
||||
const me = parsed(find('GET /v1/auth/me'))
|
||||
|
||||
expect(me.role).toBeUndefined()
|
||||
expect(me.access.role).toEqual({ id: 1, name: 'Owner', built_in: true, locked: true })
|
||||
expect(me.access.permissions[0]).toEqual({ key: 'content.read', scope: 'all' })
|
||||
expect(
|
||||
me.access.permissions.some((grant: { key: string }) => grant.key.includes(':')),
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('shows role {id, name, built_in, locked} on the user and invite samples', () => {
|
||||
const list = parsed(find('GET /v1/user/'))
|
||||
|
||||
expect(list[0].role).toEqual({ id: 1, name: 'Owner', built_in: true, locked: true })
|
||||
expect(list[1].role).toEqual({ id: 3, name: 'Writer', built_in: true, locked: false })
|
||||
expect(parsed(find('POST /v1/user/invite')).role.name).toBe('Writer')
|
||||
})
|
||||
|
||||
it('documents the optional role_id of the invite request', () => {
|
||||
const roleId = find('POST /v1/user/invite').body.find((field) => field.name === 'role_id')
|
||||
|
||||
expect(roleId?.required).toBe(false)
|
||||
expect(roleId?.rule).toContain('Writer')
|
||||
})
|
||||
})
|
||||
@@ -605,13 +605,34 @@ const me = {
|
||||
created_at: '2026-01-05T08:30:00Z',
|
||||
updated_at: '2026-09-10T07:00:00Z',
|
||||
display_name: 'Marta Alves',
|
||||
role: 'owner',
|
||||
}
|
||||
|
||||
const ownerRole = { id: 1, name: 'Owner', built_in: true, locked: true }
|
||||
const writerRole = { id: 3, name: 'Writer', built_in: true, locked: false }
|
||||
|
||||
const meWithAccess = {
|
||||
...me,
|
||||
author: null,
|
||||
access: {
|
||||
role: ownerRole,
|
||||
permissions: [
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'all' },
|
||||
{ key: 'content.delete', scope: 'all' },
|
||||
{ key: 'content.publish', scope: 'all' },
|
||||
{ key: 'settings.view', scope: 'all' },
|
||||
{ key: 'users.view', scope: 'all' },
|
||||
{ key: 'content.update:article', scope: 'own' },
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
const invite = (overrides: Record<string, unknown> = {}) => ({
|
||||
id: 7,
|
||||
kind: 'invite',
|
||||
email: 'lea.martin@renew.example',
|
||||
role: writerRole,
|
||||
status: 'pending',
|
||||
created_at: '2026-09-10T07:00:00Z',
|
||||
expires_at: '2026-09-13T07:00:00Z',
|
||||
@@ -1606,7 +1627,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
username: 'marta',
|
||||
display_name: 'Marta Alves',
|
||||
email: 'marta@renew.example',
|
||||
role: 'owner',
|
||||
role: ownerRole,
|
||||
status: 'accepted',
|
||||
created_at: '2026-01-05T08:30:00Z',
|
||||
},
|
||||
@@ -1614,6 +1635,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
id: 7,
|
||||
kind: 'invite',
|
||||
email: 'lea.martin@renew.example',
|
||||
role: writerRole,
|
||||
status: 'pending',
|
||||
created_at: '2026-09-10T07:00:00Z',
|
||||
},
|
||||
@@ -1666,8 +1688,14 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
required: true,
|
||||
rule: 'A valid email address.',
|
||||
},
|
||||
{
|
||||
name: 'role_id',
|
||||
type: 'number',
|
||||
required: false,
|
||||
rule: 'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.',
|
||||
},
|
||||
],
|
||||
bodySample: { email: 'lea.martin@renew.example' },
|
||||
bodySample: { email: 'lea.martin@renew.example', role_id: 3 },
|
||||
},
|
||||
),
|
||||
ownerOnly(
|
||||
@@ -1698,7 +1726,7 @@ export const API_GROUPS: ApiGroup[] = [
|
||||
"Get the current user. With a key, the key's owner.",
|
||||
'Verified user',
|
||||
[e(401, UNAUTHORIZED)],
|
||||
json(me),
|
||||
json(meWithAccess),
|
||||
),
|
||||
],
|
||||
},
|
||||
|
||||
@@ -23,7 +23,6 @@ const SOURCES = [
|
||||
'components/PublishingLinksCard.vue',
|
||||
'components/ReassignDialog.vue',
|
||||
'components/SiteFormPanel.vue',
|
||||
'components/SitesForbidden.vue',
|
||||
'components/SlugField.vue',
|
||||
'components/SocialLinksRepeater.vue',
|
||||
'components/UserSelect.vue',
|
||||
|
||||
@@ -2831,6 +2831,92 @@ export const messages = {
|
||||
'Value in an entry': 'Valeur dans une entrée',
|
||||
'1 or higher. Default 1.': '1 ou plus. 1 par défaut.',
|
||||
'One of 5, 25, 50 or 100. Default 25.': '5, 25, 50 ou 100. 25 par défaut.',
|
||||
'Read entries': 'Lire les entrées',
|
||||
'Create entries': 'Créer des entrées',
|
||||
'Update entries': 'Modifier des entrées',
|
||||
'Delete entries': 'Supprimer des entrées',
|
||||
'Publish entries': 'Publier des entrées',
|
||||
'View content types': 'Voir les types de contenu',
|
||||
'Create and edit content types': 'Créer et modifier des types de contenu',
|
||||
'Delete content types': 'Supprimer des types de contenu',
|
||||
'View the media library': 'Voir la médiathèque',
|
||||
'Upload files': 'Téléverser des fichiers',
|
||||
'Delete files': 'Supprimer des fichiers',
|
||||
'View authors': 'Voir les auteurs',
|
||||
'Create authors': 'Créer des auteurs',
|
||||
'Edit authors': 'Modifier des auteurs',
|
||||
'Delete authors': 'Supprimer des auteurs',
|
||||
'See activity': 'Voir l’activité',
|
||||
'Export activity': 'Exporter l’activité',
|
||||
'View users': 'Voir les utilisateurs',
|
||||
'Invite users': 'Inviter des utilisateurs',
|
||||
"Change a user's role": 'Changer le rôle d’un utilisateur',
|
||||
'Remove users': 'Retirer des utilisateurs',
|
||||
'View settings': 'Voir les paramètres',
|
||||
'Edit general settings': 'Modifier les paramètres généraux',
|
||||
'Delete a site': 'Supprimer un site',
|
||||
'See API keys': 'Voir les clés d’API',
|
||||
'Create API keys': 'Créer des clés d’API',
|
||||
'Revoke API keys': 'Révoquer des clés d’API',
|
||||
'View roles': 'Voir les rôles',
|
||||
'Manage roles': 'Gérer les rôles',
|
||||
'Edit roles with owner-only power': 'Modifier les rôles à pouvoir réservé au propriétaire',
|
||||
'Apply software updates': 'Appliquer les mises à jour du logiciel',
|
||||
'Edit activity retention': 'Modifier la durée de conservation de l’activité',
|
||||
'Transfer ownership': 'Transférer la propriété',
|
||||
"You don't have permission to do that": 'Vous n’avez pas la permission de faire cela',
|
||||
"Your role doesn't include {0}. Ask an owner or admin.":
|
||||
'Votre rôle n’inclut pas « {0} ». Demandez à un propriétaire ou à un administrateur.',
|
||||
"Your role doesn't include this action. Ask an owner or admin.":
|
||||
'Votre rôle n’inclut pas cette action. Demandez à un propriétaire ou à un administrateur.',
|
||||
"You can't grant that": 'Vous ne pouvez pas accorder cela',
|
||||
'You can only grant what your role has.':
|
||||
'Vous ne pouvez accorder que ce que votre rôle possède.',
|
||||
'This role is locked': 'Ce rôle est verrouillé',
|
||||
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.":
|
||||
'Le rôle Propriétaire ne peut être ni modifié ni supprimé. Transférez plutôt la propriété depuis Utilisateurs.',
|
||||
"You can't change your own role": 'Vous ne pouvez pas changer votre propre rôle',
|
||||
'Ask another owner or admin.': 'Demandez à un autre propriétaire ou administrateur.',
|
||||
"Built-in roles can't be deleted": 'Les rôles intégrés ne peuvent pas être supprimés',
|
||||
'Duplicate it to make a custom role.': 'Dupliquez-le pour créer un rôle personnalisé.',
|
||||
'Built-in roles keep their name.': 'Les rôles intégrés gardent leur nom.',
|
||||
'This changed somewhere else. Reload and try again.':
|
||||
'Cela a changé ailleurs. Rechargez la page et réessayez.',
|
||||
'View only': 'Lecture seule',
|
||||
'You can only edit content you created.':
|
||||
'Vous ne pouvez modifier que le contenu que vous avez créé.',
|
||||
"You don't have access to this page": 'Vous n’avez pas accès à cette page',
|
||||
"Your role, {0}, doesn't include it. Ask an owner or admin if you need it.":
|
||||
'Votre rôle, {0}, ne l’inclut pas. Demandez à un propriétaire ou à un administrateur si vous en avez besoin.',
|
||||
'Go to Articles': 'Aller aux articles',
|
||||
writer: 'rédacteur',
|
||||
'Only the owner can delete a site.': 'Seul le propriétaire peut supprimer un site.',
|
||||
'Only the owner can apply updates.': 'Seul le propriétaire peut appliquer les mises à jour.',
|
||||
'Apply update': 'Appliquer la mise à jour',
|
||||
'Admins can read the log. Only the owner can change how long it is kept.':
|
||||
'Les administrateurs peuvent lire le journal. Seul le propriétaire peut changer sa durée de conservation.',
|
||||
'How long activity is kept, and how much log data to keep. Whichever limit is reached first removes the oldest events.':
|
||||
'Durée de conservation de l’activité et volume de journal à garder. La première limite atteinte supprime les événements les plus anciens.',
|
||||
'You can read these settings but not change them':
|
||||
'Vous pouvez lire ces paramètres mais pas les modifier',
|
||||
'Ask the owner to change them, or ask for a role that can edit settings.':
|
||||
'Demandez au propriétaire de les modifier, ou demandez un rôle qui peut modifier les paramètres.',
|
||||
'You can create entries and edit your own.':
|
||||
'Vous pouvez créer des entrées et modifier les vôtres.',
|
||||
'Entries by others are view only': 'Les entrées des autres sont en lecture seule',
|
||||
'Your role lets you edit entries you created.':
|
||||
'Votre rôle vous permet de modifier les entrées que vous avez créées.',
|
||||
"Your role can't edit entries.": 'Votre rôle ne permet pas de modifier des entrées.',
|
||||
"Your role can't delete entries.": 'Votre rôle ne permet pas de supprimer des entrées.',
|
||||
"Your role can't edit articles.": 'Votre rôle ne permet pas de modifier des articles.',
|
||||
"Your role can't delete articles.": 'Votre rôle ne permet pas de supprimer des articles.',
|
||||
'Ask an editor to publish.': 'Demandez à un éditeur de publier.',
|
||||
'You can write and edit all articles. Publishing is done by an editor.':
|
||||
'Vous pouvez rédiger et modifier tous les articles. La publication est faite par un éditeur.',
|
||||
"Your role can't delete files.": 'Votre rôle ne permet pas de supprimer des fichiers.',
|
||||
'Save draft': 'Enregistrer le brouillon',
|
||||
'The id of the role to give. Absent or 0 means the Writer role. The Owner role is refused.':
|
||||
'L’identifiant du rôle à donner. Absent ou 0, c’est le rôle Rédacteur. Le rôle Propriétaire est refusé.',
|
||||
} as const
|
||||
|
||||
export type MessageKey = keyof typeof messages
|
||||
@@ -1,4 +1,4 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { API_GROUPS, SESSION_GROUPS } from '@/data/apiCatalogue'
|
||||
@@ -108,32 +108,16 @@ describe('Catalogue groups of Sites and Authors in French', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('The owner_only copy of the design board', () => {
|
||||
it.each([
|
||||
"You don't have access to Sites",
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
|
||||
'Only the owner can manage sites',
|
||||
'Ask the owner of this site.',
|
||||
])('"%s" has a French entry', (text) => {
|
||||
expect(frenchOf(text)).toMatch(/\S/)
|
||||
expect(frenchOf(text)).not.toBe(text)
|
||||
})
|
||||
|
||||
it('is used by the 403 page and by the action toast', () => {
|
||||
const page = readFileSync(
|
||||
join(import.meta.dirname, '..', 'components/SitesForbidden.vue'),
|
||||
'utf8',
|
||||
)
|
||||
const toast = readFileSync(
|
||||
describe('The retired owner-only sites copy', () => {
|
||||
it('shows nowhere any more: the page-level state and the generic toast replace it', () => {
|
||||
const toasts = readFileSync(
|
||||
join(import.meta.dirname, '..', 'composables/useDirectoryToasts.ts'),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
expect(page).toContain("You don\\'t have access to Sites")
|
||||
expect(page).toContain(
|
||||
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
|
||||
expect(toasts).not.toContain('Only the owner can manage sites')
|
||||
expect(existsSync(join(import.meta.dirname, '..', 'components/SitesForbidden.vue'))).toBe(
|
||||
false,
|
||||
)
|
||||
expect(toast).toContain('Only the owner can manage sites')
|
||||
expect(toast).toContain('Ask the owner of this site.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { messages } from './messages'
|
||||
|
||||
const KEYS = [
|
||||
"You don't have permission to do that",
|
||||
"Your role doesn't include {0}. Ask an owner or admin.",
|
||||
"Your role doesn't include this action. Ask an owner or admin.",
|
||||
"You can't grant that",
|
||||
'This role is locked',
|
||||
"You can't change your own role",
|
||||
"Built-in roles can't be deleted",
|
||||
'Built-in roles keep their name.',
|
||||
'This changed somewhere else. Reload and try again.',
|
||||
"You don't have access to this page",
|
||||
"Your role, {0}, doesn't include it. Ask an owner or admin if you need it.",
|
||||
'Go to Articles',
|
||||
'View only',
|
||||
'Owner only',
|
||||
'Only the owner can apply updates.',
|
||||
'Admins can read the log. Only the owner can change how long it is kept.',
|
||||
'You can read these settings but not change them',
|
||||
'Ask the owner to change them, or ask for a role that can edit settings.',
|
||||
'You can create entries and edit your own.',
|
||||
'Entries by others are view only',
|
||||
"Your role can't delete articles.",
|
||||
'Ask an editor to publish.',
|
||||
'You can write and edit all articles. Publishing is done by an editor.',
|
||||
'Save draft',
|
||||
] as const
|
||||
|
||||
describe('Permission copy', () => {
|
||||
it.each(KEYS)('"%s" has a French entry', (key) => {
|
||||
const french = (messages as Record<string, string>)[key]
|
||||
expect(french).toMatch(/\S/)
|
||||
})
|
||||
})
|
||||
@@ -14,6 +14,7 @@ import { messages } from './messages'
|
||||
import { formatAbsoluteDateFromIso } from '@/lib/relativeDate'
|
||||
import { formatSize, pluralArticles } from '@/lib/mediaFormat'
|
||||
import { articleEnvelope, articleListUrl } from '@/views/__tests__/articleEnvelope'
|
||||
import { accessFor, ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async () => {
|
||||
const { ref } = await import('vue')
|
||||
@@ -70,7 +71,7 @@ async function mountRoute(path: string) {
|
||||
username: 'marta',
|
||||
displayName: 'Marta',
|
||||
email: 'marta@example.com',
|
||||
role: 'owner',
|
||||
access: accessFor('owner'),
|
||||
}
|
||||
session.status = 'ready'
|
||||
await router.push(path)
|
||||
@@ -315,7 +316,7 @@ describe('Hardener localization outcomes', () => {
|
||||
async (authenticated) => {
|
||||
const { default: NotFoundView } = await import('@/views/NotFoundView.vue')
|
||||
useSessionStore().session = authenticated
|
||||
? { verified: true, username: 'marta', displayName: '' }
|
||||
? { verified: true, access: ownerAccess(), username: 'marta', displayName: '' }
|
||||
: null
|
||||
wrapper = mount(NotFoundView, {
|
||||
attachTo: document.body,
|
||||
@@ -389,6 +390,7 @@ describe('Hardener localization outcomes', () => {
|
||||
setLocale('fr')
|
||||
useSessionStore().session = {
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import { classifyFailure, type FailureContext, type FailureKind } from '@/lib/di
|
||||
const CASES: [FailureContext, number, string | undefined, FailureKind][] = [
|
||||
['site', 401, undefined, 'session'],
|
||||
['site', 403, 'default_site', 'defaultSite'],
|
||||
['site', 403, 'Only the owner can do this.', 'forbidden'],
|
||||
['site', 403, 'Only the owner can do this.', 'permission'],
|
||||
['site', 404, 'site_not_found', 'gone'],
|
||||
['site', 409, 'site_key_taken', 'keyTaken'],
|
||||
['site', 409, 'site_has_articles', 'hasArticles'],
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { setLocale } from '@/i18n'
|
||||
import { messages } from '@/i18n/messages'
|
||||
import { PERMISSION_KEYS, permissionName } from '@/lib/permissionNames'
|
||||
import { CATALOGUE_KEYS } from '@/test/access'
|
||||
|
||||
afterEach(() => setLocale('en'))
|
||||
|
||||
describe('permissionName', () => {
|
||||
it('knows every key of the catalogue', () => {
|
||||
expect([...PERMISSION_KEYS].sort()).toEqual([...CATALOGUE_KEYS].sort())
|
||||
expect(PERMISSION_KEYS).toHaveLength(34)
|
||||
})
|
||||
|
||||
it.each(CATALOGUE_KEYS)('%s has an English and a French name', (key) => {
|
||||
const english = permissionName(key)
|
||||
expect(english).toBeTruthy()
|
||||
expect(messages[english as keyof typeof messages]).toBeTruthy()
|
||||
setLocale('fr')
|
||||
expect(permissionName(key)).not.toBe(english)
|
||||
})
|
||||
|
||||
it('names content.delete Delete entries', () => {
|
||||
expect(permissionName('content.delete')).toBe('Delete entries')
|
||||
})
|
||||
|
||||
it('shows the base-key name for a typed key', () => {
|
||||
expect(permissionName('content.publish:events')).toBe('Publish entries')
|
||||
})
|
||||
|
||||
it('has no name for an unknown key', () => {
|
||||
expect(permissionName('content.explode')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
+36
-1
@@ -1,12 +1,47 @@
|
||||
export const KNOWN_ERROR_CODES = [
|
||||
'missing_permission',
|
||||
'escalation',
|
||||
'role_locked',
|
||||
'own_role',
|
||||
'role_built_in',
|
||||
'built_in_name',
|
||||
'role_conflict',
|
||||
'user_role_changed',
|
||||
'session_required',
|
||||
] as const
|
||||
|
||||
export type ApiErrorCode = (typeof KNOWN_ERROR_CODES)[number]
|
||||
|
||||
export interface ApiErrorDetails {
|
||||
code?: ApiErrorCode
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
}
|
||||
|
||||
const SESSION_REQUIRED_MESSAGE = 'This action needs a real session.'
|
||||
|
||||
export function errorCodeOf(body: string | undefined): ApiErrorCode | undefined {
|
||||
if (body === SESSION_REQUIRED_MESSAGE) {
|
||||
return 'session_required'
|
||||
}
|
||||
return KNOWN_ERROR_CODES.find((code) => code === body)
|
||||
}
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
retryAfter?: number
|
||||
body?: string
|
||||
code?: ApiErrorCode
|
||||
permission?: string
|
||||
escalationKey?: string
|
||||
|
||||
constructor(status: number, retryAfter?: number, body?: string) {
|
||||
constructor(status: number, retryAfter?: number, body?: string, details: ApiErrorDetails = {}) {
|
||||
super(`Request failed with status ${status}`)
|
||||
this.status = status
|
||||
this.retryAfter = retryAfter
|
||||
this.body = body
|
||||
this.code = details.code
|
||||
this.permission = details.permission
|
||||
this.escalationKey = details.escalationKey
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export type FailureContext = 'site' | 'author'
|
||||
|
||||
export type FailureKind =
|
||||
| 'session'
|
||||
| 'forbidden'
|
||||
| 'permission'
|
||||
| 'defaultSite'
|
||||
| 'rights'
|
||||
| 'gone'
|
||||
@@ -64,10 +64,16 @@ export function classifyFailure(error: unknown, context: FailureContext): Failur
|
||||
return 'gone'
|
||||
}
|
||||
if (status === 403) {
|
||||
if (
|
||||
code === 'missing_permission' ||
|
||||
(error instanceof ApiError && error.code === 'missing_permission')
|
||||
) {
|
||||
return 'permission'
|
||||
}
|
||||
if (context === 'author') {
|
||||
return 'rights'
|
||||
}
|
||||
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'forbidden'
|
||||
return code === SITE_CODES.defaultSite ? 'defaultSite' : 'permission'
|
||||
}
|
||||
const table: Record<FailureContext, Record<string, FailureKind>> = {
|
||||
site: {
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface DirectoryMenuItem {
|
||||
icon: string
|
||||
destructive?: boolean
|
||||
unavailable?: boolean
|
||||
ownerOnly?: boolean
|
||||
hint?: string
|
||||
separated?: boolean
|
||||
onSelect: (trigger: HTMLElement | null) => void
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { t } from '@/i18n'
|
||||
import { permissionName } from '@/lib/permissionNames'
|
||||
import type { ApiErrorCode } from '@/lib/apiError'
|
||||
|
||||
export interface RefusalCopy {
|
||||
title: string
|
||||
description?: string
|
||||
}
|
||||
|
||||
export function missingPermissionCopy(permission?: string): RefusalCopy {
|
||||
const name = permission === undefined ? undefined : permissionName(permission)
|
||||
return {
|
||||
title: t("You don't have permission to do that"),
|
||||
description:
|
||||
name === undefined
|
||||
? t("Your role doesn't include this action. Ask an owner or admin.")
|
||||
: t("Your role doesn't include {0}. Ask an owner or admin.", [name]),
|
||||
}
|
||||
}
|
||||
|
||||
export function refusalCopy(code: ApiErrorCode, permission?: string): RefusalCopy | undefined {
|
||||
switch (code) {
|
||||
case 'missing_permission':
|
||||
return missingPermissionCopy(permission)
|
||||
case 'escalation':
|
||||
return {
|
||||
title: t("You can't grant that"),
|
||||
description: t('You can only grant what your role has.'),
|
||||
}
|
||||
case 'role_locked':
|
||||
return {
|
||||
title: t('This role is locked'),
|
||||
description: t(
|
||||
"The Owner role can't be changed or deleted. Transfer ownership from Users instead.",
|
||||
),
|
||||
}
|
||||
case 'own_role':
|
||||
return {
|
||||
title: t("You can't change your own role"),
|
||||
description: t('Ask another owner or admin.'),
|
||||
}
|
||||
case 'role_built_in':
|
||||
return {
|
||||
title: t("Built-in roles can't be deleted"),
|
||||
description: t('Duplicate it to make a custom role.'),
|
||||
}
|
||||
case 'built_in_name':
|
||||
return { title: t('Built-in roles keep their name.') }
|
||||
case 'role_conflict':
|
||||
case 'user_role_changed':
|
||||
return { title: t('This changed somewhere else. Reload and try again.') }
|
||||
case 'session_required':
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { t } from '@/i18n'
|
||||
import type { MessageKey } from '@/i18n/messages'
|
||||
|
||||
const NAMES = {
|
||||
'content.read': 'Read entries',
|
||||
'content.create': 'Create entries',
|
||||
'content.update': 'Update entries',
|
||||
'content.delete': 'Delete entries',
|
||||
'content.publish': 'Publish entries',
|
||||
'schema.view': 'View content types',
|
||||
'schema.edit': 'Create and edit content types',
|
||||
'schema.delete': 'Delete content types',
|
||||
'media.view': 'View the media library',
|
||||
'media.upload': 'Upload files',
|
||||
'media.delete': 'Delete files',
|
||||
'authors.view': 'View authors',
|
||||
'authors.create': 'Create authors',
|
||||
'authors.edit': 'Edit authors',
|
||||
'authors.delete': 'Delete authors',
|
||||
'activity.view': 'See activity',
|
||||
'activity.export': 'Export activity',
|
||||
'users.view': 'View users',
|
||||
'users.invite': 'Invite users',
|
||||
'users.change_role': "Change a user's role",
|
||||
'users.remove': 'Remove users',
|
||||
'settings.view': 'View settings',
|
||||
'settings.edit_general': 'Edit general settings',
|
||||
'settings.manage_sites': 'Manage sites',
|
||||
'settings.delete_site': 'Delete a site',
|
||||
'apikeys.view': 'See API keys',
|
||||
'apikeys.create': 'Create API keys',
|
||||
'apikeys.revoke': 'Revoke API keys',
|
||||
'roles.view': 'View roles',
|
||||
'roles.manage': 'Manage roles',
|
||||
'roles.edit_owner_only': 'Edit roles with owner-only power',
|
||||
'system.apply_updates': 'Apply software updates',
|
||||
'system.edit_retention': 'Edit activity retention',
|
||||
'system.transfer_ownership': 'Transfer ownership',
|
||||
} as const satisfies Record<string, MessageKey>
|
||||
|
||||
export const PERMISSION_KEYS = Object.keys(NAMES)
|
||||
|
||||
export function baseKey(key: string) {
|
||||
return key.split(':')[0]!
|
||||
}
|
||||
|
||||
export function permissionName(key: string): string | undefined {
|
||||
const name = NAMES[baseKey(key) as keyof typeof NAMES]
|
||||
return name === undefined ? undefined : t(name)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type Scope = 'none' | 'own' | 'all'
|
||||
|
||||
export interface Grant {
|
||||
key: string
|
||||
scope: Scope
|
||||
}
|
||||
|
||||
export interface RoleRef {
|
||||
id: number
|
||||
name: string
|
||||
built_in: boolean
|
||||
locked: boolean
|
||||
}
|
||||
|
||||
export interface Access {
|
||||
role: RoleRef
|
||||
permissions: Grant[]
|
||||
}
|
||||
|
||||
export interface CanOptions {
|
||||
ownerId?: number
|
||||
typeKey?: string
|
||||
scope?: 'own' | 'all'
|
||||
}
|
||||
|
||||
export interface ScopeOptions {
|
||||
typeKey?: string
|
||||
}
|
||||
|
||||
const TYPE_SEPARATOR = ':'
|
||||
|
||||
function scopeOfGrant(grants: readonly Grant[], key: string): Scope | undefined {
|
||||
return grants.find((grant) => grant.key === key)?.scope
|
||||
}
|
||||
|
||||
export function effectiveScope(
|
||||
grants: readonly Grant[],
|
||||
key: string,
|
||||
options: ScopeOptions = {},
|
||||
): Scope {
|
||||
if (options.typeKey) {
|
||||
const typed = scopeOfGrant(grants, `${key}${TYPE_SEPARATOR}${options.typeKey}`)
|
||||
if (typed !== undefined) {
|
||||
return typed
|
||||
}
|
||||
}
|
||||
return scopeOfGrant(grants, key) ?? 'none'
|
||||
}
|
||||
|
||||
export function allows(
|
||||
grants: readonly Grant[],
|
||||
userId: number | undefined,
|
||||
key: string,
|
||||
options: CanOptions = {},
|
||||
): boolean {
|
||||
const scope = effectiveScope(grants, key, options)
|
||||
if (scope === 'none') {
|
||||
return false
|
||||
}
|
||||
if (scope === 'all') {
|
||||
return true
|
||||
}
|
||||
if (options.scope === 'all') {
|
||||
return false
|
||||
}
|
||||
if (options.ownerId === undefined) {
|
||||
return true
|
||||
}
|
||||
return userId !== undefined && options.ownerId !== 0 && options.ownerId === userId
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { CanOptions } from '@/lib/permissions'
|
||||
|
||||
export interface RouteAccess {
|
||||
key: string
|
||||
typeKey?: string
|
||||
typeParam?: string
|
||||
}
|
||||
|
||||
export type CanCheck = (key: string, options?: CanOptions) => boolean
|
||||
|
||||
export const ARTICLE_TYPE_KEY = 'article'
|
||||
|
||||
function typeKeyOf(access: RouteAccess, params: Record<string, unknown>) {
|
||||
if (access.typeKey !== undefined) {
|
||||
return access.typeKey
|
||||
}
|
||||
if (access.typeParam === undefined) {
|
||||
return undefined
|
||||
}
|
||||
const value = params[access.typeParam]
|
||||
return typeof value === 'string' ? value : undefined
|
||||
}
|
||||
|
||||
export function isRouteAllowed(
|
||||
access: RouteAccess | undefined,
|
||||
params: Record<string, unknown>,
|
||||
can: CanCheck,
|
||||
): boolean {
|
||||
if (access === undefined) {
|
||||
return true
|
||||
}
|
||||
const typeKey = typeKeyOf(access, params)
|
||||
return can(access.key, typeKey === undefined ? {} : { typeKey })
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import lucide from 'virtual:lucide-subset'
|
||||
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import { refreshSessionWhenVisible } from '@/stores/session'
|
||||
|
||||
import '@/assets/fonts/fonts.css'
|
||||
import '@/assets/main.css'
|
||||
@@ -19,3 +20,5 @@ app.use(ui)
|
||||
app.use(router)
|
||||
|
||||
app.mount('#app')
|
||||
|
||||
refreshSessionWhenVisible()
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { h } from 'vue'
|
||||
import uiPlugin from '@nuxt/ui/vue-plugin'
|
||||
import UApp from '@nuxt/ui/components/App.vue'
|
||||
import router from '@/router'
|
||||
import RouteAccessGate from '@/components/RouteAccessGate.vue'
|
||||
import { useSessionStore, type SessionStatus } from '@/stores/session'
|
||||
import { customAccess, noAccess } from '@/test/access'
|
||||
import { setLocale } from '@/i18n'
|
||||
|
||||
let wrapper: VueWrapper | null = null
|
||||
|
||||
interface Setup {
|
||||
grants: string
|
||||
role?: string
|
||||
path: string
|
||||
status?: SessionStatus
|
||||
verified?: boolean
|
||||
}
|
||||
|
||||
async function open({
|
||||
grants,
|
||||
role = 'Contributor',
|
||||
path,
|
||||
status = 'ready',
|
||||
verified = true,
|
||||
}: Setup) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { id: 4, verified, displayName: '', access: customAccess(grants, role) }
|
||||
session.status = status
|
||||
await router.push(path).catch(() => undefined)
|
||||
wrapper = mount(
|
||||
() =>
|
||||
h(UApp, null, () =>
|
||||
h(RouteAccessGate, null, () => h('div', { 'data-testid': 'page' }, 'PAGE')),
|
||||
),
|
||||
{ attachTo: document.body, global: { plugins: [router, uiPlugin] } },
|
||||
)
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
setLocale('en')
|
||||
document.body.innerHTML = ''
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
const NO_ACCESS_TITLE = "You don't have access to this page"
|
||||
|
||||
describe('A direct link to a page the role lacks', () => {
|
||||
it.each([
|
||||
['content.read=all', 'Contributor', '/schema', 'Go to Articles'],
|
||||
['content.read=all', 'Contributor', '/schema/new', 'Go to Articles'],
|
||||
['content.read=all', 'Contributor', '/users', 'Go to Articles'],
|
||||
['content.read=all', 'Contributor', '/settings/sites', 'Go to Articles'],
|
||||
['content.read=all', 'Contributor', '/integration/api-keys', 'Go to Articles'],
|
||||
[
|
||||
'content.read=all, content.read:events=none',
|
||||
'Contributor',
|
||||
'/content/events',
|
||||
'Go to Articles',
|
||||
],
|
||||
['media.view', 'Media', '/schema', 'Go to Home'],
|
||||
])(
|
||||
'%s opening %s shows the page-level 403 and keeps the URL',
|
||||
async (grants, role, path, action) => {
|
||||
const view = await open({ grants, role, path })
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe(path)
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(false)
|
||||
expect(view.text()).toContain(NO_ACCESS_TITLE)
|
||||
expect(view.text()).toContain(
|
||||
`Your role, ${role}, doesn't include it. Ask an owner or admin if you need it.`,
|
||||
)
|
||||
const buttons = view
|
||||
.findAll('[data-slot="root"] button')
|
||||
.filter((button) => !button.element.closest('nav'))
|
||||
expect(buttons.map((button) => button.text()).filter((label) => label !== '')).toEqual([
|
||||
action,
|
||||
])
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
describe('A page the role can use', () => {
|
||||
it.each([
|
||||
['schema.view', '/schema'],
|
||||
['schema.edit', '/schema/new'],
|
||||
['users.view', '/users'],
|
||||
['settings.manage_sites', '/settings/sites'],
|
||||
['content.read:events=own', '/content/events'],
|
||||
['content.create:events=all', '/content/events/new'],
|
||||
['apikeys.view=own', '/integration/api-keys'],
|
||||
['settings.view', '/settings/media/cache'],
|
||||
])('%s opens %s without a 403', async (grants, path) => {
|
||||
const view = await open({ grants, path })
|
||||
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(true)
|
||||
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
|
||||
})
|
||||
})
|
||||
|
||||
describe('The guard', () => {
|
||||
it('shows neither the page nor the 403 while the session is checking', async () => {
|
||||
const view = await open({ grants: 'media.view', path: '/schema', status: 'checking' })
|
||||
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(false)
|
||||
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
|
||||
|
||||
useSessionStore().status = 'ready'
|
||||
await flushPromises()
|
||||
|
||||
expect(view.text()).toContain(NO_ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('leaves an unverified session to the pending redirect', async () => {
|
||||
const view = await open({ grants: '', path: '/schema', verified: false })
|
||||
|
||||
expect(view.text()).not.toContain(NO_ACCESS_TITLE)
|
||||
})
|
||||
|
||||
it('switches to the 403 state when grants are lowered while the page is open', async () => {
|
||||
const view = await open({ grants: 'schema.view', path: '/schema' })
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(true)
|
||||
|
||||
const session = useSessionStore()
|
||||
session.session = { ...session.session!, access: noAccess('Contributor') }
|
||||
await flushPromises()
|
||||
|
||||
expect(view.find('[data-testid="page"]').exists()).toBe(false)
|
||||
expect(view.text()).toContain(NO_ACCESS_TITLE)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/schema')
|
||||
})
|
||||
|
||||
it('shows the gate copy in French', async () => {
|
||||
setLocale('fr')
|
||||
const view = await open({ grants: 'content.read=all', path: '/users' })
|
||||
|
||||
expect(view.text()).toContain('Vous n’avez pas accès à cette page')
|
||||
})
|
||||
})
|
||||
@@ -6,6 +6,7 @@ import { defineComponent, h } from 'vue'
|
||||
import { RouterView } from 'vue-router'
|
||||
import router from '@/router'
|
||||
import { useApi, ApiError } from '@/composables/useApi'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -86,7 +87,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
})
|
||||
|
||||
it('A second navigation reuses the cached session instead of calling GET /v1/auth/me again', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -102,7 +103,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
})
|
||||
|
||||
it('A verified-only route admits a verified session', async () => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -341,7 +342,7 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
])(
|
||||
'Placeholder verified route $path renders inside DashboardShell with title $title',
|
||||
async ({ path, title }) => {
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
vi.mocked(useApi).mockReturnValue({ getMe })
|
||||
setUpSession()
|
||||
|
||||
@@ -360,9 +361,13 @@ describe('Nuxt UI shell foundation and router guards', () => {
|
||||
|
||||
it('The /media route is verified, titled Media and renders MediaView inside DashboardShell', async () => {
|
||||
const route = router.getRoutes().find((candidate) => candidate.path === '/media')
|
||||
expect(route?.meta).toEqual({ auth: 'verified', title: 'Media' })
|
||||
expect(route?.meta).toEqual({
|
||||
auth: 'verified',
|
||||
title: 'Media',
|
||||
access: { key: 'media.view' },
|
||||
})
|
||||
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true })
|
||||
const getMe = vi.fn().mockResolvedValue({ verified: true, access: ownerAccess() })
|
||||
const request = vi.fn().mockResolvedValue({
|
||||
items: [],
|
||||
total: 0,
|
||||
|
||||
+108
-36
@@ -1,3 +1,4 @@
|
||||
import { defineComponent, h, type Component } from 'vue'
|
||||
import {
|
||||
createRouter,
|
||||
createWebHistory,
|
||||
@@ -8,9 +9,34 @@ import {
|
||||
} from 'vue-router'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { installDocumentTitle } from '@/composables/documentTitle'
|
||||
import { ARTICLE_TYPE_KEY, type RouteAccess } from '@/lib/routeAccess'
|
||||
|
||||
export type AuthRequirement = 'public' | 'session' | 'verified' | 'any'
|
||||
|
||||
export function routeAccessOf(route: RouteLocationNormalized): RouteAccess | undefined {
|
||||
return route.meta.access as RouteAccess | undefined
|
||||
}
|
||||
|
||||
const gatedViews = new Map<string, Promise<Component>>()
|
||||
|
||||
function gated(key: string, load: () => Promise<{ default: Component }>) {
|
||||
return () => {
|
||||
let resolved = gatedViews.get(key)
|
||||
if (resolved === undefined) {
|
||||
resolved = Promise.all([load(), import('@/components/RouteAccessGate.vue')]).then(
|
||||
([view, gate]) =>
|
||||
defineComponent({
|
||||
name: 'GatedRoute',
|
||||
setup: () => () =>
|
||||
h(gate.default, null, { default: () => h(view.default) }),
|
||||
}),
|
||||
)
|
||||
gatedViews.set(key, resolved)
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
}
|
||||
|
||||
const routes: RouteRecordRaw[] = [
|
||||
{
|
||||
path: '/',
|
||||
@@ -59,70 +85,116 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/article',
|
||||
component: () => import('@/views/ArticleView.vue'),
|
||||
meta: { auth: 'verified', title: 'Articles' },
|
||||
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Articles',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/article/new',
|
||||
component: () => import('@/views/ArticleView.vue'),
|
||||
meta: { auth: 'verified', title: 'Articles' },
|
||||
component: gated('@/views/ArticleView.vue', () => import('@/views/ArticleView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Articles',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/article/:id/edit',
|
||||
component: () => import('@/views/EditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Editor' },
|
||||
component: gated('@/views/EditorView.vue', () => import('@/views/EditorView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Editor',
|
||||
access: { key: 'content.read', typeKey: ARTICLE_TYPE_KEY },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/media',
|
||||
component: () => import('@/views/MediaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Media' },
|
||||
component: gated('@/views/MediaView.vue', () => import('@/views/MediaView.vue')),
|
||||
meta: { auth: 'verified', title: 'Media', access: { key: 'media.view' } },
|
||||
},
|
||||
{
|
||||
path: '/authors',
|
||||
component: () => import('@/views/AuthorsView.vue'),
|
||||
meta: { auth: 'verified', title: 'Authors' },
|
||||
component: gated('@/views/AuthorsView.vue', () => import('@/views/AuthorsView.vue')),
|
||||
meta: { auth: 'verified', title: 'Authors', access: { key: 'authors.view' } },
|
||||
},
|
||||
{
|
||||
path: '/users',
|
||||
component: () => import('@/views/UsersView.vue'),
|
||||
meta: { auth: 'verified', title: 'Users' },
|
||||
component: gated('@/views/UsersView.vue', () => import('@/views/UsersView.vue')),
|
||||
meta: { auth: 'verified', title: 'Users', access: { key: 'users.view' } },
|
||||
},
|
||||
{
|
||||
path: '/schema',
|
||||
component: () => import('@/views/SchemaView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
component: gated('@/views/SchemaView.vue', () => import('@/views/SchemaView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Schema',
|
||||
needsBuiltinTypes: true,
|
||||
access: { key: 'schema.view' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/schema/new',
|
||||
component: () => import('@/views/SchemaBuilderView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema' },
|
||||
component: gated(
|
||||
'@/views/SchemaBuilderView.vue',
|
||||
() => import('@/views/SchemaBuilderView.vue'),
|
||||
),
|
||||
meta: { auth: 'verified', title: 'Schema', access: { key: 'schema.edit' } },
|
||||
},
|
||||
{
|
||||
path: '/schema/:key',
|
||||
component: () => import('@/views/SchemaTypeView.vue'),
|
||||
meta: { auth: 'verified', title: 'Schema', needsBuiltinTypes: true },
|
||||
component: gated('@/views/SchemaTypeView.vue', () => import('@/views/SchemaTypeView.vue')),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Schema',
|
||||
needsBuiltinTypes: true,
|
||||
access: { key: 'schema.view' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key',
|
||||
component: () => import('@/views/ContentListView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/ContentListView.vue',
|
||||
() => import('@/views/ContentListView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.read', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key/new',
|
||||
name: 'entry-new',
|
||||
component: () => import('@/views/EntryEditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/EntryEditorView.vue',
|
||||
() => import('@/views/EntryEditorView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.create', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/content/:key/:id',
|
||||
name: 'entry-edit',
|
||||
component: () => import('@/views/EntryEditorView.vue'),
|
||||
meta: { auth: 'verified', title: 'Content' },
|
||||
component: gated(
|
||||
'@/views/EntryEditorView.vue',
|
||||
() => import('@/views/EntryEditorView.vue'),
|
||||
),
|
||||
meta: {
|
||||
auth: 'verified',
|
||||
title: 'Content',
|
||||
access: { key: 'content.read', typeParam: 'key' },
|
||||
},
|
||||
},
|
||||
{
|
||||
path: '/activity',
|
||||
component: () => import('@/views/ActivityView.vue'),
|
||||
meta: { auth: 'verified', title: 'Activity' },
|
||||
component: gated('@/views/ActivityView.vue', () => import('@/views/ActivityView.vue')),
|
||||
meta: { auth: 'verified', title: 'Activity', access: { key: 'activity.view' } },
|
||||
},
|
||||
{
|
||||
path: '/profile',
|
||||
@@ -136,28 +208,28 @@ const routes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: '/integration/api-keys',
|
||||
component: () => import('@/views/ApiKeysView.vue'),
|
||||
meta: { auth: 'verified', title: 'Integration' },
|
||||
component: gated('@/views/ApiKeysView.vue', () => import('@/views/ApiKeysView.vue')),
|
||||
meta: { auth: 'verified', title: 'Integration', access: { key: 'apikeys.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings',
|
||||
component: () => import('@/views/SettingsView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/SettingsView.vue', () => import('@/views/SettingsView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/variants',
|
||||
component: () => import('@/views/VariantsPage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/VariantsPage.vue', () => import('@/views/VariantsPage.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/sites',
|
||||
component: () => import('@/views/SitesView.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/SitesView.vue', () => import('@/views/SitesView.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.manage_sites' } },
|
||||
},
|
||||
{
|
||||
path: '/settings/media/cache',
|
||||
component: () => import('@/views/CachePage.vue'),
|
||||
meta: { auth: 'verified', title: 'Settings' },
|
||||
component: gated('@/views/CachePage.vue', () => import('@/views/CachePage.vue')),
|
||||
meta: { auth: 'verified', title: 'Settings', access: { key: 'settings.view' } },
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import type { CanOptions } from '@/lib/permissions'
|
||||
import { customAccess } from '@/test/access'
|
||||
|
||||
function withGrants(spec: string | null, id = 7) {
|
||||
const session = useSessionStore()
|
||||
session.session =
|
||||
spec === null ? null : { id, verified: true, displayName: '', access: customAccess(spec) }
|
||||
return usePermissionsStore()
|
||||
}
|
||||
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
describe('can(key, options)', () => {
|
||||
it.each<[string | null, string, CanOptions, boolean]>([
|
||||
['content.update=all', 'content.update', {}, true],
|
||||
['content.update=own', 'content.update', {}, true],
|
||||
['content.update=own', 'content.update', { scope: 'all' }, false],
|
||||
['content.update=own', 'content.update', { ownerId: 7 }, true],
|
||||
['content.update=own', 'content.update', { ownerId: 8 }, false],
|
||||
['content.update=own', 'content.update', { ownerId: 0 }, false],
|
||||
['content.update=all', 'content.update', { ownerId: 8 }, true],
|
||||
['content.update=none', 'content.update', {}, false],
|
||||
['', 'content.update', {}, false],
|
||||
[
|
||||
'content.update=own, content.update:events=all',
|
||||
'content.update',
|
||||
{ typeKey: 'events', scope: 'all' },
|
||||
true,
|
||||
],
|
||||
[
|
||||
'content.update=all, content.update:events=none',
|
||||
'content.update',
|
||||
{ typeKey: 'events' },
|
||||
false,
|
||||
],
|
||||
[
|
||||
'content.update=all, content.update:events=none',
|
||||
'content.update',
|
||||
{ typeKey: 'venues' },
|
||||
true,
|
||||
],
|
||||
['content.read=all', 'content.read', { typeKey: 'article' }, true],
|
||||
['settings.view=all', 'content.explode', {}, false],
|
||||
[null, 'media.view', {}, false],
|
||||
])('grants %s, key %s, options %j gives %s', (grants, key, options, expected) => {
|
||||
expect(withGrants(grants).can(key, options)).toBe(expected)
|
||||
})
|
||||
|
||||
it('treats an owner id as foreign when the session has no id', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
displayName: '',
|
||||
access: customAccess('content.update=own'),
|
||||
}
|
||||
|
||||
expect(usePermissionsStore().can('content.update', { ownerId: 7 })).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('scopeOf', () => {
|
||||
it('exposes none, own or all, with a typed row replacing the wildcard', () => {
|
||||
const permissions = withGrants('content.read=all, content.read:events=own')
|
||||
|
||||
expect(permissions.scopeOf('content.read')).toBe('all')
|
||||
expect(permissions.scopeOf('content.read', { typeKey: 'events' })).toBe('own')
|
||||
expect(permissions.scopeOf('schema.view')).toBe('none')
|
||||
})
|
||||
|
||||
it('is none without a session', () => {
|
||||
expect(withGrants(null).scopeOf('content.read')).toBe('none')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session shape', () => {
|
||||
it('has no isOwner or canManageContent getter and no string role', () => {
|
||||
const session = useSessionStore() as unknown as Record<string, unknown>
|
||||
|
||||
expect('isOwner' in session).toBe(false)
|
||||
expect('canManageContent' in session).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { ownerAccess } from '@/test/access'
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
@@ -9,7 +10,7 @@ beforeEach(() => {
|
||||
describe('session store clear action', () => {
|
||||
it('resets the session to null and the status to ready regardless of the previous state', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true }
|
||||
session.session = { verified: true, access: ownerAccess() }
|
||||
session.status = 'checking'
|
||||
|
||||
session.clear()
|
||||
@@ -22,7 +23,12 @@ describe('session store clear action', () => {
|
||||
describe('session store setDisplayName action', () => {
|
||||
it('replaces the session display name with the given value', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, username: 'marta', displayName: '' }
|
||||
session.session = {
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
displayName: '',
|
||||
}
|
||||
|
||||
session.setDisplayName('Marta Alves')
|
||||
|
||||
@@ -33,6 +39,7 @@ describe('session store setDisplayName action', () => {
|
||||
const session = useSessionStore()
|
||||
session.session = {
|
||||
verified: true,
|
||||
access: ownerAccess(),
|
||||
username: 'marta',
|
||||
displayName: 'Marta Alves',
|
||||
}
|
||||
|
||||
@@ -1,36 +0,0 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
|
||||
function sessionWith(role?: 'owner' | 'admin' | 'member') {
|
||||
const store = useSessionStore()
|
||||
store.session = {
|
||||
verified: true,
|
||||
displayName: '',
|
||||
...(role ? { role: role as never } : {}),
|
||||
}
|
||||
return store
|
||||
}
|
||||
|
||||
describe('session role getters', () => {
|
||||
beforeEach(() => setActivePinia(createPinia()))
|
||||
|
||||
it.each([
|
||||
['owner', true, true],
|
||||
['admin', false, true],
|
||||
['member', false, false],
|
||||
[undefined, false, false],
|
||||
] as const)('%s: isOwner %s, canManageContent %s', (role, owner, manage) => {
|
||||
const store = sessionWith(role)
|
||||
|
||||
expect(store.isOwner).toBe(owner)
|
||||
expect(store.canManageContent).toBe(manage)
|
||||
})
|
||||
|
||||
it('is false without a session', () => {
|
||||
const store = useSessionStore()
|
||||
|
||||
expect(store.isOwner).toBe(false)
|
||||
expect(store.canManageContent).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
startable,
|
||||
upToDate,
|
||||
} from '@/views/__tests__/updateTestKit'
|
||||
import { accessFor } from '@/test/access'
|
||||
|
||||
vi.mock('@/composables/useApi', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/composables/useApi')>()
|
||||
@@ -27,7 +28,7 @@ let requestMock: ReturnType<typeof vi.fn>
|
||||
function setup(role: 'owner' | 'admin', handlers: Record<string, () => Promise<unknown>>) {
|
||||
setActivePinia(createPinia())
|
||||
const session = useSessionStore()
|
||||
session.session = { verified: true, displayName: '', role }
|
||||
session.session = { verified: true, displayName: '', access: accessFor(role) }
|
||||
session.status = 'ready'
|
||||
requestMock = vi.fn((path: string) => handlers[path]!())
|
||||
vi.mocked(useApi).mockReturnValue({ getMe: vi.fn(), request: requestMock })
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import {
|
||||
allows,
|
||||
effectiveScope,
|
||||
type CanOptions,
|
||||
type RoleRef,
|
||||
type Scope,
|
||||
type ScopeOptions,
|
||||
} from '@/lib/permissions'
|
||||
|
||||
export const usePermissionsStore = defineStore('permissions', () => {
|
||||
const sessionStore = useSessionStore()
|
||||
|
||||
const grants = computed(() => sessionStore.session?.access?.permissions ?? [])
|
||||
const role = computed<RoleRef | undefined>(() => sessionStore.session?.access?.role)
|
||||
const roleName = computed(() => role.value?.name ?? '')
|
||||
|
||||
function can(key: string, options: CanOptions = {}): boolean {
|
||||
if (!sessionStore.session) {
|
||||
return false
|
||||
}
|
||||
return allows(grants.value, sessionStore.session.id, key, options)
|
||||
}
|
||||
|
||||
function scopeOf(key: string, options: ScopeOptions = {}): Scope {
|
||||
if (!sessionStore.session) {
|
||||
return 'none'
|
||||
}
|
||||
return effectiveScope(grants.value, key, options)
|
||||
}
|
||||
|
||||
return { grants, role, roleName, can, scopeOf }
|
||||
})
|
||||
@@ -1,6 +1,10 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { ApiError, useApi, type MeAuthor, type UserRole } from '@/composables/useApi'
|
||||
import { ref } from 'vue'
|
||||
import { ApiError, useApi, type MeAuthor } from '@/composables/useApi'
|
||||
import type { Access } from '@/lib/permissions'
|
||||
|
||||
const REFUSAL_REFRESH_GAP_MS = 5000
|
||||
const STALE_AFTER_MS = 30000
|
||||
|
||||
export type SessionStatus = 'idle' | 'checking' | 'ready' | 'error'
|
||||
|
||||
@@ -10,7 +14,7 @@ export interface Session {
|
||||
username?: string
|
||||
email?: string
|
||||
displayName: string
|
||||
role?: UserRole
|
||||
access?: Access
|
||||
author?: MeAuthor
|
||||
}
|
||||
|
||||
@@ -19,10 +23,8 @@ export const useSessionStore = defineStore('session', () => {
|
||||
const session = ref<Session | null>(null)
|
||||
let pending: Promise<void> | null = null
|
||||
|
||||
const isOwner = computed(() => session.value?.role === 'owner')
|
||||
const canManageContent = computed(
|
||||
() => session.value?.role === 'owner' || session.value?.role === 'admin',
|
||||
)
|
||||
let lastAnswerAt: number | null = null
|
||||
let lastAttemptAt: number | null = null
|
||||
|
||||
function check() {
|
||||
if (pending) {
|
||||
@@ -36,6 +38,7 @@ export const useSessionStore = defineStore('session', () => {
|
||||
.then((result) => {
|
||||
session.value = result
|
||||
status.value = 'ready'
|
||||
lastAnswerAt = Date.now()
|
||||
})
|
||||
.catch((error) => {
|
||||
if (error instanceof ApiError && error.status === 401) {
|
||||
@@ -59,13 +62,31 @@ export const useSessionStore = defineStore('session', () => {
|
||||
|
||||
async function refresh() {
|
||||
const { getMe } = useApi()
|
||||
lastAttemptAt = Date.now()
|
||||
try {
|
||||
session.value = await getMe()
|
||||
lastAnswerAt = Date.now()
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshAfterRefusal() {
|
||||
if (lastAttemptAt !== null && Date.now() - lastAttemptAt < REFUSAL_REFRESH_GAP_MS) {
|
||||
return
|
||||
}
|
||||
await refresh()
|
||||
}
|
||||
|
||||
async function refreshIfStale() {
|
||||
if (!session.value || lastAnswerAt === null) {
|
||||
return
|
||||
}
|
||||
if (Date.now() - lastAnswerAt > STALE_AFTER_MS) {
|
||||
await refresh()
|
||||
}
|
||||
}
|
||||
|
||||
function setAuthor(author: MeAuthor | undefined) {
|
||||
if (!session.value) {
|
||||
return
|
||||
@@ -87,13 +108,21 @@ export const useSessionStore = defineStore('session', () => {
|
||||
return {
|
||||
status,
|
||||
session,
|
||||
isOwner,
|
||||
canManageContent,
|
||||
check,
|
||||
retry,
|
||||
refresh,
|
||||
refreshAfterRefusal,
|
||||
refreshIfStale,
|
||||
clear,
|
||||
setAuthor,
|
||||
setDisplayName,
|
||||
}
|
||||
})
|
||||
|
||||
export function refreshSessionWhenVisible() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') {
|
||||
void useSessionStore().refreshIfStale()
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
readRestartMemory,
|
||||
writeRestartMemory,
|
||||
} from '@/lib/restartMemory'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
export type UpdateLoadState = 'idle' | 'loading' | 'ready' | 'error'
|
||||
export type UpdateCheckPhase = 'idle' | 'checking' | 'failed' | 'limited'
|
||||
@@ -58,7 +58,7 @@ function writeDismissedVersion(version: string) {
|
||||
}
|
||||
|
||||
export const useUpdateStore = defineStore('update', () => {
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const status = ref<UpdateStatus | null>(null)
|
||||
const loadState = ref<UpdateLoadState>('idle')
|
||||
const phase = ref<UpdateCheckPhase>('idle')
|
||||
@@ -67,20 +67,20 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
const dismissedVersion = ref<string | null>(readDismissedVersion())
|
||||
let pendingLoad: Promise<void> | null = null
|
||||
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const canApply = computed(() => permissions.can('system.apply_updates'))
|
||||
const available = computed(() => status.value?.available === true)
|
||||
const showCue = computed(() => isOwner.value && available.value)
|
||||
const showCue = computed(() => canApply.value && available.value)
|
||||
const restarting = computed(() => status.value?.state === 'restarting')
|
||||
const bannerVisible = computed(
|
||||
() => showCue.value && !restarting.value && status.value?.latest !== dismissedVersion.value,
|
||||
)
|
||||
const jobRunning = computed(
|
||||
() =>
|
||||
isOwner.value &&
|
||||
canApply.value &&
|
||||
status.value?.can_apply === true &&
|
||||
(status.value.state === 'downloading' || status.value.state === 'verifying'),
|
||||
)
|
||||
const readyToInstall = computed(() => isOwner.value && status.value?.state === 'ready')
|
||||
const readyToInstall = computed(() => canApply.value && status.value?.state === 'ready')
|
||||
const restartRun = ref<RestartRun | null>(null)
|
||||
const restartSlow = ref(false)
|
||||
const restartNow = ref(Date.now())
|
||||
@@ -221,7 +221,7 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
}
|
||||
|
||||
function resumeRestart() {
|
||||
if (!isOwner.value || restartRun.value || status.value?.state !== 'restarting') {
|
||||
if (!canApply.value || restartRun.value || status.value?.state !== 'restarting') {
|
||||
return
|
||||
}
|
||||
const remembered = readRestartMemory()
|
||||
@@ -313,7 +313,7 @@ export const useUpdateStore = defineStore('update', () => {
|
||||
phase,
|
||||
applyPhase,
|
||||
retryAfter,
|
||||
isOwner,
|
||||
canApply,
|
||||
available,
|
||||
showCue,
|
||||
bannerVisible,
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import type { Access, Grant, RoleRef, Scope } from '../lib/permissions'
|
||||
|
||||
export const CATALOGUE_KEYS = [
|
||||
'content.read',
|
||||
'content.create',
|
||||
'content.update',
|
||||
'content.delete',
|
||||
'content.publish',
|
||||
'schema.view',
|
||||
'schema.edit',
|
||||
'schema.delete',
|
||||
'media.view',
|
||||
'media.upload',
|
||||
'media.delete',
|
||||
'authors.view',
|
||||
'authors.create',
|
||||
'authors.edit',
|
||||
'authors.delete',
|
||||
'activity.view',
|
||||
'activity.export',
|
||||
'users.view',
|
||||
'users.invite',
|
||||
'users.change_role',
|
||||
'users.remove',
|
||||
'settings.view',
|
||||
'settings.edit_general',
|
||||
'settings.manage_sites',
|
||||
'settings.delete_site',
|
||||
'apikeys.view',
|
||||
'apikeys.create',
|
||||
'apikeys.revoke',
|
||||
'roles.view',
|
||||
'roles.manage',
|
||||
'roles.edit_owner_only',
|
||||
'system.apply_updates',
|
||||
'system.edit_retention',
|
||||
'system.transfer_ownership',
|
||||
] as const
|
||||
|
||||
export const OWNER_ONLY_KEYS = [
|
||||
'settings.delete_site',
|
||||
'roles.edit_owner_only',
|
||||
'system.apply_updates',
|
||||
'system.edit_retention',
|
||||
'system.transfer_ownership',
|
||||
] as const
|
||||
|
||||
export const OWNER_ROLE: RoleRef = { id: 1, name: 'Owner', built_in: true, locked: true }
|
||||
export const ADMIN_ROLE: RoleRef = { id: 2, name: 'Admin', built_in: true, locked: false }
|
||||
export const WRITER_ROLE: RoleRef = { id: 3, name: 'Writer', built_in: true, locked: false }
|
||||
|
||||
const WRITER_GRANTS: Grant[] = [
|
||||
{ key: 'content.read', scope: 'all' },
|
||||
{ key: 'content.create', scope: 'all' },
|
||||
{ key: 'content.update', scope: 'all' },
|
||||
{ key: 'schema.view', scope: 'all' },
|
||||
{ key: 'media.view', scope: 'all' },
|
||||
{ key: 'media.upload', scope: 'all' },
|
||||
{ key: 'authors.view', scope: 'all' },
|
||||
{ key: 'authors.edit', scope: 'own' },
|
||||
{ key: 'activity.view', scope: 'own' },
|
||||
]
|
||||
|
||||
function everyKey(skip: readonly string[] = []): Grant[] {
|
||||
return CATALOGUE_KEYS.filter((key) => !skip.includes(key)).map((key) => ({
|
||||
key,
|
||||
scope: 'all' as Scope,
|
||||
}))
|
||||
}
|
||||
|
||||
export function grantsFrom(spec: string): Grant[] {
|
||||
return spec
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.filter((part) => part !== '')
|
||||
.map((part) => {
|
||||
const [key, scope = 'all'] = part.split('=')
|
||||
return { key: key!.trim(), scope: scope.trim() as Scope }
|
||||
})
|
||||
}
|
||||
|
||||
export function ownerAccess(): Access {
|
||||
return { role: { ...OWNER_ROLE }, permissions: everyKey() }
|
||||
}
|
||||
|
||||
export function adminAccess(): Access {
|
||||
return { role: { ...ADMIN_ROLE }, permissions: everyKey(OWNER_ONLY_KEYS) }
|
||||
}
|
||||
|
||||
export function writerAccess(): Access {
|
||||
return { role: { ...WRITER_ROLE }, permissions: WRITER_GRANTS.map((grant) => ({ ...grant })) }
|
||||
}
|
||||
|
||||
export function noAccess(name = 'Guest'): Access {
|
||||
return { role: { id: 9, name, built_in: false, locked: false }, permissions: [] }
|
||||
}
|
||||
|
||||
export function customAccess(grants: string | Grant[], name = 'Contributor', id = 7): Access {
|
||||
return {
|
||||
role: { id, name, built_in: false, locked: false },
|
||||
permissions: typeof grants === 'string' ? grantsFrom(grants) : grants,
|
||||
}
|
||||
}
|
||||
|
||||
function withoutKeys(access: Access, drop: (key: string) => boolean): Access {
|
||||
return {
|
||||
role: { ...access.role, locked: false, name: 'Custom' },
|
||||
permissions: access.permissions.filter((grant) => !drop(grant.key)),
|
||||
}
|
||||
}
|
||||
|
||||
export function accessFor(role?: string): Access | undefined {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return ownerAccess()
|
||||
case 'admin':
|
||||
return adminAccess()
|
||||
case 'viewer':
|
||||
return customAccess('content.read, settings.view', 'Viewer')
|
||||
case 'keyholder':
|
||||
return customAccess('content.read, apikeys.view=own, apikeys.create', 'Key holder')
|
||||
case 'noschema':
|
||||
return withoutKeys(ownerAccess(), (key) => key.startsWith('schema.'))
|
||||
case 'member':
|
||||
case 'editor':
|
||||
case 'verified':
|
||||
return writerAccess()
|
||||
case 'user':
|
||||
return noAccess()
|
||||
default:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function meBody(role?: string, extra: Record<string, unknown> = {}) {
|
||||
const access = accessFor(role)
|
||||
return { ...(access ? { access } : {}), ...extra }
|
||||
}
|
||||
|
||||
export function roleRefFor(role?: string | null): RoleRef | null {
|
||||
switch (role) {
|
||||
case 'owner':
|
||||
return { ...OWNER_ROLE }
|
||||
case 'admin':
|
||||
return { ...ADMIN_ROLE }
|
||||
case 'member':
|
||||
case 'editor':
|
||||
return { ...WRITER_ROLE }
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,7 @@ const {
|
||||
people,
|
||||
chips,
|
||||
isFullView,
|
||||
canExport,
|
||||
isEmpty,
|
||||
exportDisabled,
|
||||
showListSkeleton,
|
||||
@@ -141,7 +142,7 @@ function goHome() {
|
||||
<p class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ActivityExportMenu
|
||||
v-if="isFullView && !forbidden"
|
||||
v-if="canExport && !forbidden"
|
||||
:total="total"
|
||||
:is-desktop="isDesktop"
|
||||
:disabled="exportDisabled || exportFlow.currentBusy.value"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useAp
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { displayPrefix } from '@/lib/apiKeys'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
@@ -27,6 +28,7 @@ type LoadState = 'loading' | 'ready' | 'error'
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const { listApiKeys, revokeApiKey } = useApiKeys()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
@@ -37,7 +39,7 @@ const revealed = ref<{ name: string; key: string } | null>(null)
|
||||
const revokeDialogOpen = ref(false)
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const seesEveryKey = computed(() => permissions.scopeOf('apikeys.view') === 'all')
|
||||
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
|
||||
const showList = computed(
|
||||
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
|
||||
@@ -47,7 +49,7 @@ const anyDialogOpen = computed(
|
||||
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
|
||||
)
|
||||
const description = computed(() =>
|
||||
isOwner.value
|
||||
seesEveryKey.value
|
||||
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
|
||||
: t('Keys act as you, with your role. You only see your own keys.'),
|
||||
)
|
||||
@@ -329,14 +331,14 @@ onMounted(loadKeys)
|
||||
v-if="isDesktop"
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="isOwner"
|
||||
:show-created-by="seesEveryKey"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
<ApiKeyCardList
|
||||
v-else
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="isOwner"
|
||||
:show-created-by="seesEveryKey"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -14,12 +14,22 @@ import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import { useArticleSites } from '@/composables/useArticleSites'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { effectiveSort } from '@/lib/articleListQuery'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
|
||||
const router = useRouter()
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const writerLine = computed(() =>
|
||||
access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
)
|
||||
|
||||
const list = useArticleList({
|
||||
sitesSettled: () => siteList.sitesSettled.value,
|
||||
@@ -132,11 +142,10 @@ onMounted(() => {
|
||||
>
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t("Everything you've written, online and in draft.") }}
|
||||
</p>
|
||||
<p class="text-muted">{{ writerLine }}</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
@@ -214,6 +223,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
||||
@click="openNewArticleModal"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
||||
@@ -44,6 +45,7 @@ type PanelTarget =
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toasts = useDirectoryToasts()
|
||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||
@@ -65,7 +67,9 @@ const linkTarget = ref<Author | null>(null)
|
||||
const deleteOptions = ref<ReassignOption[]>([])
|
||||
const destination = ref<string | number | undefined>(undefined)
|
||||
|
||||
const canManage = computed(() => session.canManageContent)
|
||||
const canManage = computed(() => permissions.scopeOf('authors.edit') === 'all')
|
||||
const canAdd = computed(() => permissions.can('authors.create'))
|
||||
const canDelete = computed(() => permissions.can('authors.delete'))
|
||||
const firstRun = computed(
|
||||
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
|
||||
)
|
||||
@@ -86,7 +90,7 @@ const overlayOpen = computed(
|
||||
const showPinnedButton = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
canManage.value &&
|
||||
canAdd.value &&
|
||||
!overlayOpen.value &&
|
||||
loadState.value === 'ready' &&
|
||||
!firstRun.value,
|
||||
@@ -411,7 +415,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
|
||||
onSelect: (trigger) => openEdit(author, trigger),
|
||||
},
|
||||
]
|
||||
if (!author.is_linked) {
|
||||
if (canManage.value && !author.is_linked) {
|
||||
items.push({
|
||||
key: 'link',
|
||||
label: t('Link user'),
|
||||
@@ -419,6 +423,9 @@ function menuItems(author: Author): DirectoryMenuItem[] {
|
||||
onSelect: (trigger) => openLink(author, trigger),
|
||||
})
|
||||
}
|
||||
if (!canDelete.value) {
|
||||
return items
|
||||
}
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: t('Delete…'),
|
||||
@@ -443,7 +450,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
|
||||
</h1>
|
||||
<p class="text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
|
||||
<DirectoryAddButton v-if="isDesktop && canAdd" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</DirectoryAddButton>
|
||||
</div>
|
||||
@@ -486,7 +493,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
|
||||
description: 'max-w-85 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template v-if="canManage" #actions>
|
||||
<template v-if="canAdd" #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</UButton>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { formatNumber, t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -13,7 +12,8 @@ import CacheSkeleton from '@/components/variants/CacheSkeleton.vue'
|
||||
import CacheStatTile from '@/components/variants/CacheStatTile.vue'
|
||||
import CacheTopImagesCard from '@/components/variants/CacheTopImagesCard.vue'
|
||||
import TypeToConfirmDialog from '@/components/variants/TypeToConfirmDialog.vue'
|
||||
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
|
||||
import type {
|
||||
CacheImageStat,
|
||||
@@ -25,6 +25,7 @@ import { cacheSize } from '@/lib/variants/cacheFormat'
|
||||
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useVariantCacheStore } from '@/stores/variantCache'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 40rem)'
|
||||
@@ -32,8 +33,8 @@ const SKELETON_DELAY_MS = 150
|
||||
const NOT_FOUND = 404
|
||||
const CONFIRM_WORD = 'purge'
|
||||
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const store = useVariantCacheStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
@@ -47,9 +48,8 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requested = false
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const canManage = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const canManage = computed(() => permissions.can('settings.view'))
|
||||
const canEdit = computed(() => permissions.can('settings.edit_general'))
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
|
||||
)
|
||||
@@ -57,10 +57,15 @@ const stats = computed(() => store.stats)
|
||||
const ready = computed(() => store.loadState === 'ready' && stats.value !== null)
|
||||
const dimmed = computed(() => store.isPurging)
|
||||
const purgeUnusedDisabled = computed(
|
||||
() => !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
|
||||
() =>
|
||||
!canEdit.value || !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
|
||||
)
|
||||
const purgeAllDisabled = computed(
|
||||
() => !ready.value || store.isPurging || (stats.value?.generated_files ?? 0) === 0,
|
||||
() =>
|
||||
!canEdit.value ||
|
||||
!ready.value ||
|
||||
store.isPurging ||
|
||||
(stats.value?.generated_files ?? 0) === 0,
|
||||
)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -295,7 +300,7 @@ function onPurgeImage(item: CacheImageStat) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
@@ -339,6 +344,14 @@ function onPurgeImage(item: CacheImageStat) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="!canEdit"
|
||||
:title="t('You can read these settings but not change them')"
|
||||
:description="
|
||||
t('Ask the owner to change them, or ask for a role that can edit settings.')
|
||||
"
|
||||
/>
|
||||
|
||||
<VariantsTabs active="cache" />
|
||||
|
||||
<CacheSkeleton
|
||||
|
||||
@@ -7,16 +7,18 @@ import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useContentListPage } from '@/composables/useContentListPage'
|
||||
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const props = defineProps<{ typeKey: string }>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const detail = useContentTypeDetail(
|
||||
() => props.typeKey,
|
||||
() => session.status === 'ready',
|
||||
)
|
||||
const page = useContentListPage(props.typeKey, detail, () => session.canManageContent)
|
||||
const page = useContentListPage(props.typeKey, detail, () => permissions.can('schema.edit'))
|
||||
|
||||
const notFound = computed(() => page.view.value === 'notFound')
|
||||
const centered = computed(() => page.centered.value)
|
||||
|
||||
@@ -24,11 +24,13 @@ import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||
import { useEditorImages } from '@/composables/useEditorImages'
|
||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { toBylines } from '@/lib/articleMeta'
|
||||
@@ -232,7 +234,15 @@ const overflowItems = computed(() => [
|
||||
],
|
||||
])
|
||||
|
||||
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
|
||||
const contentAccess = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const articleAccess = computed(() => contentAccess.rowAccess(article.value?.created_by))
|
||||
const canPublish = computed(() => contentAccess.canPublish(article.value?.created_by))
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
loadState.value === 'loaded' &&
|
||||
articleAccess.value.canEdit,
|
||||
)
|
||||
|
||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||
|
||||
@@ -426,6 +436,9 @@ useUnsavedChangesGuard({
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
:can-delete="articleAccess.canDelete"
|
||||
:delete-reason="articleAccess.deleteReason"
|
||||
@back="goBack"
|
||||
@retry="retrySave"
|
||||
@save="performSave"
|
||||
@@ -526,6 +539,7 @@ useUnsavedChangesGuard({
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
@details="detailsOpen = true"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
|
||||
@@ -11,6 +11,7 @@ import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
||||
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
import { accountName } from '@/lib/accountName'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
@@ -24,6 +25,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const update = useUpdateStore()
|
||||
const { listArticles } = useArticles()
|
||||
|
||||
@@ -153,9 +155,9 @@ function dismissBanner() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => update.isOwner,
|
||||
(isOwner) => {
|
||||
if (isOwner) {
|
||||
() => update.canApply,
|
||||
(canApply) => {
|
||||
if (canApply) {
|
||||
update.load()
|
||||
}
|
||||
},
|
||||
@@ -337,7 +339,7 @@ onMounted(loadArticles)
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<RecentActivityCard />
|
||||
<RecentActivityCard v-if="permissions.scopeOf('activity.view') !== 'none'" />
|
||||
|
||||
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
|
||||
<div
|
||||
|
||||
@@ -50,7 +50,7 @@ import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const MEDIA_PATH = '/media'
|
||||
@@ -80,7 +80,7 @@ const OFFLINE_COPY =
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -233,14 +233,13 @@ const filterLabel = computed(
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const canPurge = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const canUpload = computed(() => permissions.can('media.upload'))
|
||||
const canDeleteMedia = computed(() => permissions.scopeOf('media.delete') === 'all')
|
||||
const showPurge = computed(
|
||||
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
() => canDeleteMedia.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadBlocked = computed(() => !canUpload.value || offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
|
||||
)
|
||||
@@ -258,6 +257,7 @@ const hasSelection = computed(() => selection.count.value > 0)
|
||||
const showFab = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
canUpload.value &&
|
||||
!dialogOpen.value &&
|
||||
!hasSelection.value &&
|
||||
!dragging.value &&
|
||||
@@ -922,7 +922,7 @@ async function mergeAction() {
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
v-if="isDesktop && canUpload"
|
||||
:icon="uploadIcon"
|
||||
:disabled="uploadBlocked"
|
||||
class="h-8 px-3"
|
||||
@@ -946,6 +946,7 @@ async function mergeAction() {
|
||||
/>
|
||||
<MediaEmptyState
|
||||
variant="first-run"
|
||||
:read-only="!canUpload"
|
||||
:offline="offline"
|
||||
:limited="upload.limited.value"
|
||||
@action="openChooser"
|
||||
@@ -1060,6 +1061,7 @@ async function mergeAction() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<MediaSelectAllRow
|
||||
v-if="canDeleteMedia"
|
||||
:state="selection.allState.value"
|
||||
:count="media.items.value.length"
|
||||
@toggle="toggleAllSelected"
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const { sessionReady, forbidden } = useSchemaGate('custom')
|
||||
|
||||
const mobileTitle = computed(() => t('New content type'))
|
||||
@@ -20,10 +18,6 @@ const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
{ label: t('New content type') },
|
||||
])
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +39,7 @@ function goHome() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
<SchemaBuilderPage v-else-if="sessionReady" />
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -2,27 +2,26 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaFieldTable from '@/components/SchemaFieldTable.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useEntryCount } from '@/composables/useEntryCount'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { resolveSchemaTypeView } from '@/lib/schemaTypeView'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
|
||||
const route = useRoute()
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
|
||||
const {
|
||||
@@ -89,10 +88,6 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +114,7 @@ function goHome() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="view === 'forbidden'" @back="goHome" />
|
||||
<NoAccessState v-if="view === 'forbidden'" />
|
||||
|
||||
<template v-else-if="sessionReady">
|
||||
<SchemaTable v-if="view === 'skeleton'" :custom="[]" :builtin="[]" loading />
|
||||
|
||||
@@ -3,12 +3,13 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaEmpty from '@/components/SchemaEmpty.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
|
||||
@@ -17,6 +18,7 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const NEW_TYPE_PATH = '/schema/new'
|
||||
|
||||
const router = useRouter()
|
||||
const permissions = usePermissionsStore()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
const {
|
||||
@@ -40,18 +42,16 @@ const customRows = computed(() => customItems.value.map(customRow))
|
||||
const builtinRows = computed(() => builtinItems.value.map(builtinRow))
|
||||
const summary = computed(() => schemaSummary(customItems.value.length, builtinItems.value.length))
|
||||
|
||||
const canEdit = computed(() => permissions.can('schema.edit'))
|
||||
|
||||
function createType() {
|
||||
router.push(NEW_TYPE_PATH)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :full-bleed="forbidden" :mobile-title="t('Schema')">
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
@@ -64,6 +64,7 @@ function goHome() {
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="canEdit"
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5 max-md:h-11"
|
||||
:disabled="loading"
|
||||
@@ -95,7 +96,7 @@ function goHome() {
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !forbidden && populated && !empty"
|
||||
v-if="!isDesktop && !forbidden && canEdit && populated && !empty"
|
||||
pinned
|
||||
@click="createType"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '@/stores/update'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
@@ -50,6 +51,7 @@ const { getSettings } = useSettings()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const update = useUpdateStore()
|
||||
const retry = useRetryAfter()
|
||||
|
||||
@@ -57,7 +59,8 @@ const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const settings = ref<WorkspaceSettings | null>(null)
|
||||
const version = computed(() => settings.value?.version ?? '')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const canApply = computed(() => update.canApply)
|
||||
const canManageSites = computed(() => permissions.can('settings.manage_sites'))
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -115,7 +118,7 @@ const updatesStatus = computed(() => {
|
||||
if (update.status) {
|
||||
return update.status
|
||||
}
|
||||
return update.loadState === 'error' && update.isOwner ? neverCheckedStatus() : null
|
||||
return update.loadState === 'error' && update.canApply ? neverCheckedStatus() : null
|
||||
})
|
||||
|
||||
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
||||
@@ -324,7 +327,7 @@ watch(
|
||||
}
|
||||
autoCheckHandled = true
|
||||
const jobActive = update.jobRunning || update.readyToInstall
|
||||
if (update.isOwner && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
if (update.canApply && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
onCheck()
|
||||
}
|
||||
},
|
||||
@@ -454,7 +457,7 @@ onBeforeUnmount(() => {
|
||||
v-if="updatesStatus"
|
||||
ref="updatesCard"
|
||||
:status="updatesStatus"
|
||||
:role="session.session?.role"
|
||||
:can-apply="canApply"
|
||||
:phase="updatesPhase"
|
||||
:apply-phase="update.applyPhase"
|
||||
:retry-label="retry.formatted.value"
|
||||
@@ -466,12 +469,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<MediaLinksCard />
|
||||
|
||||
<PublishingLinksCard v-if="isOwner" />
|
||||
<PublishingLinksCard v-if="canManageSites" />
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
v-if="isOwner"
|
||||
:settings="settings"
|
||||
:load-state="loadState"
|
||||
:show-skeleton="showSkeleton"
|
||||
|
||||
+21
-15
@@ -2,7 +2,7 @@
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
@@ -16,7 +16,7 @@ import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
||||
import DirectoryPagination from '@/components/DirectoryPagination.vue'
|
||||
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
||||
import SiteFormPanel from '@/components/SiteFormPanel.vue'
|
||||
import SitesForbidden from '@/components/SitesForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||
import { useDirectoryListState } from '@/composables/useDirectoryListState'
|
||||
import { useDirectoryPages } from '@/composables/useDirectoryPages'
|
||||
@@ -28,6 +28,7 @@ import { COPY, copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
||||
@@ -36,8 +37,8 @@ const INITIAL_SKELETON_ROWS = 3
|
||||
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toasts = useDirectoryToasts()
|
||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||
@@ -46,10 +47,11 @@ const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites()
|
||||
const removal = useDirectoryDelete<Site>()
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const canManageSites = computed(() => permissions.can('settings.manage_sites'))
|
||||
const canDeleteSite = computed(() => permissions.can('settings.delete_site'))
|
||||
const list = useDirectoryPages<Site>({
|
||||
loadPage: listSitePage,
|
||||
ready: () => sessionReady.value && isOwner.value,
|
||||
ready: () => sessionReady.value && canManageSites.value,
|
||||
onLoaded: () => followLink(),
|
||||
})
|
||||
const { items: sites, total, shown, notice, searchInput, pageCount } = list
|
||||
@@ -60,7 +62,7 @@ const panel = ref<PanelTarget | null>(null)
|
||||
const destinationId = ref<number | undefined>(undefined)
|
||||
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
|
||||
() => (sessionReady.value && !canManageSites.value) || loadState.value === 'forbidden',
|
||||
)
|
||||
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
|
||||
{
|
||||
@@ -173,14 +175,14 @@ async function loadAllSites() {
|
||||
watch(
|
||||
() => route.query.edit,
|
||||
() => {
|
||||
if (loadState.value === 'ready' && isOwner.value) {
|
||||
if (loadState.value === 'ready' && canManageSites.value) {
|
||||
followLink()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function followLink() {
|
||||
if (!isOwner.value) {
|
||||
if (!canManageSites.value) {
|
||||
return
|
||||
}
|
||||
return followEditLink(
|
||||
@@ -363,6 +365,13 @@ function confirmDelete() {
|
||||
return removal.confirm(runDelete, handleDeleteFailure)
|
||||
}
|
||||
|
||||
function deleteHint(site: Site) {
|
||||
if (!canDeleteSite.value) {
|
||||
return t('Only the owner can delete a site.')
|
||||
}
|
||||
return site.is_default ? copy('defaultSiteBlocked') : undefined
|
||||
}
|
||||
|
||||
function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
const items: DirectoryMenuItem[] = [
|
||||
{
|
||||
@@ -386,8 +395,9 @@ function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: !site.is_default,
|
||||
unavailable: site.is_default,
|
||||
hint: site.is_default ? copy('defaultSiteBlocked') : undefined,
|
||||
unavailable: site.is_default || !canDeleteSite.value,
|
||||
ownerOnly: !canDeleteSite.value,
|
||||
hint: deleteHint(site),
|
||||
onSelect: (trigger) => openDelete(site, trigger),
|
||||
})
|
||||
return items
|
||||
@@ -395,10 +405,6 @@ function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
|
||||
const noResultsTitle = computed(() => t('No sites match "{0}"', [shown.value.q]))
|
||||
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
|
||||
|
||||
function backToSettings() {
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -424,7 +430,7 @@ function backToSettings() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SitesForbidden v-if="forbidden" @back="backToSettings" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
|
||||
Loaded 100 of 180 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user