feat: roles SPA foundations

This commit is contained in:
Florian Sylvain committed 2026-10-27 22:21:12 +01:00
1 parent 80f7037d3a
commit 907949aa12
180 files changed
+4308 -835

No files matched your search

+2 -1
View File
@@ -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"
+6 -4
View File
@@ -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>
+13 -3
View File
@@ -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"
+12 -5
View File
@@ -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>
+22 -9
View File
@@ -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
+3 -1
View File
@@ -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)
+15 -1
View File
@@ -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"
+6 -4
View File
@@ -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
+10 -8
View File
@@ -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"
+3 -9
View File
@@ -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>
+4 -9
View File
@@ -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 />
+8 -7
View File
@@ -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"
>
+8 -6
View File
@@ -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
View File
@@ -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">
+4 -1
View File
@@ -12,6 +12,7 @@ import UsersTable from '@/components/UsersTable.vue'
import { ApiError } from '@/composables/useApi'
import { useUsers, userRowKey, type CreatedInvite, type UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const SKELETON_DELAY_MS = 150
const DESKTOP_QUERY = '(min-width: 48rem)'
@@ -24,6 +25,7 @@ const CANCEL_FAILED_COPY = "Couldn't cancel the invite. Try again in a moment."
const toast = useLocalizedToast()
const overlay = useLocalizedOverlay()
const session = useSessionStore()
const permissions = usePermissionsStore()
const { listUsers, cancelInvite, renewInvite } = useUsers()
const isDesktop = useMediaQuery(DESKTOP_QUERY)
@@ -37,7 +39,7 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
let opener: HTMLElement | null = null
const sessionUserId = computed(() => session.session?.id)
const canInvite = computed(() => session.session?.role === 'owner')
const canInvite = computed(() => permissions.can('users.invite'))
const showList = computed(
() => loadState.value === 'ready' || (loadState.value === 'loading' && showSkeleton.value),
)
@@ -97,6 +99,7 @@ function onInvited(invite: CreatedInvite) {
id: invite.id,
email: invite.email,
display_name: '',
role: invite.role ?? null,
status: 'pending',
created_at: invite.created_at,
}
+18 -7
View File
@@ -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 } from '@/i18n/presentation'
import ConfirmDialog from '@/components/ConfirmDialog.vue'
import DashboardShell from '@/components/DashboardShell.vue'
@@ -11,7 +10,8 @@ import RequestReferenceCard from '@/components/variants/RequestReferenceCard.vue
import VariantCardList from '@/components/variants/VariantCardList.vue'
import VariantFormSlideover from '@/components/variants/VariantFormSlideover.vue'
import VariantsEmpty from '@/components/variants/VariantsEmpty.vue'
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
import NoAccessState from '@/components/NoAccessState.vue'
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
import VariantsSkeleton from '@/components/variants/VariantsSkeleton.vue'
import VariantsTable from '@/components/variants/VariantsTable.vue'
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
@@ -29,6 +29,7 @@ import {
import { formatVariantSize } from '@/lib/variants/variantFormat'
import { useImageVariantsStore } from '@/stores/imageVariants'
import { useSessionStore } from '@/stores/session'
import { usePermissionsStore } from '@/stores/permissions'
const DESKTOP_QUERY = '(min-width: 40rem)'
const SKELETON_DELAY_MS = 150
@@ -40,8 +41,8 @@ interface FormTarget {
variant?: ImageVariant
}
const router = useRouter()
const session = useSessionStore()
const permissions = usePermissionsStore()
const store = useImageVariantsStore()
const overlay = useLocalizedOverlay()
const { announceFailure, announceSuccess } = useVariantToasts()
@@ -54,7 +55,8 @@ let formOpener: HTMLElement | null = null
let requested = false
const sessionReady = computed(() => session.status === 'ready')
const canManage = computed(() => session.canManageContent)
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',
)
@@ -322,7 +324,7 @@ async function onDelete(name: string) {
/>
</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 +341,7 @@ async function onDelete(name: string) {
</p>
</div>
<UButton
v-if="canEdit"
data-testid="add-variant"
icon="i-lucide-plus"
class="h-11 shrink-0 justify-center px-3 text-sm sm:h-8"
@@ -348,6 +351,14 @@ async function onDelete(name: string) {
</UButton>
</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 />
<VariantsSkeleton
@@ -388,7 +399,7 @@ async function onDelete(name: string) {
v-if="isDesktop"
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-locked="store.isBusy || !canEdit"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@@ -399,7 +410,7 @@ async function onDelete(name: string) {
v-else
:items="store.items"
:is-enabled="store.enabledOf"
:is-locked="store.isBusy"
:is-locked="store.isBusy || !canEdit"
:is-purging="store.isPurging"
@toggle="onToggle"
@edit="openEdit"
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import AcceptInviteView from '../AcceptInviteView.vue'
import { ownerAccess } from '@/test/access'
type Handler = () => Response | Promise<Response>
@@ -346,7 +347,8 @@ describe('Success', () => {
it('shows the panel and toast, then navigates to /home after 800 ms', async () => {
const { wrapper } = await mountAccept('/invite/accept?c=abc123', {
...withAccept(() => mockResponse(200, { message: 'ok' })),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, access: ownerAccess() }),
})
vi.useFakeTimers()
@@ -30,6 +30,7 @@ import {
unmountActivity,
} from './activityHarness'
import { activityItems, makeItem, pageOf, summaryDefault } from './activityFixtures'
import { roleRefFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -360,14 +361,14 @@ describe('The Person menu', () => {
id: 3,
username: 'marta',
display_name: 'Marta Alves',
role: 'owner',
role: roleRefFor('owner'),
},
{
kind: 'user',
id: 4,
username: 'jonas',
display_name: '',
role: 'admin',
role: roleRefFor('admin'),
},
{
kind: 'invite',
@@ -93,6 +93,7 @@ describe('Navigation', () => {
expect(router.currentRoute.value.meta).toEqual({
auth: 'verified',
title: 'Activity',
access: { key: 'activity.view' },
})
})
})
+15 -6
View File
@@ -11,6 +11,9 @@ import type { ApiKey, CreatedApiKey } from '@/composables/useApiKeys'
import { useSessionStore } from '@/stores/session'
import { initializeLocale, setLocale } from '@/i18n'
import ApiKeysView from '../ApiKeysView.vue'
import type { Access } from '@/lib/permissions'
import type { Access } from '@/lib/permissions'
import { accessFor, customAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -166,6 +169,7 @@ interface Handlers {
interface MountOptions {
desktop?: boolean
role?: string | null
access?: Access
username?: string
displayName?: string
}
@@ -174,6 +178,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
const {
desktop = true,
role = 'owner',
access,
username = 'marta',
displayName = 'Marta Alves',
} = options
@@ -186,7 +191,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
username,
id: 1,
displayName,
role: sessionRole as 'owner' | 'admin' | undefined,
access: access ?? accessFor(sessionRole),
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
@@ -209,7 +214,7 @@ async function mountPage(handlers: Handlers = {}, options: MountOptions = {}) {
verified: true,
username,
id: 1,
role: sessionRole,
access: accessFor(sessionRole),
}),
request: requestMock,
})
@@ -490,9 +495,13 @@ describe('Owner: list', () => {
})
})
describe('Admin: own keys only', () => {
it('shows two rows with no Created by column and the admin description', async () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { role: 'admin' })
function ownAccess() {
return customAccess('apikeys.view=own, apikeys.create, apikeys.revoke=own', 'Key holder')
}
describe('Own keys only', () => {
it('shows two rows with no Created by column and the own-keys description', async () => {
await mountPage({ list: () => Promise.resolve(adminFixture()) }, { access: ownAccess() })
expect(textOf(document.body.querySelector('thead tr')!)).toBe(
'Name Key Status Created Last used Expires',
@@ -510,7 +519,7 @@ describe('Admin: own keys only', () => {
it('reads the cards without " by {name}" below md', async () => {
await mountPage(
{ list: () => Promise.resolve(adminFixture()) },
{ role: 'admin', desktop: false, username: 'jonas' },
{ access: ownAccess(), desktop: false, username: 'jonas' },
)
expect(rowTexts()[0]).toBe(
@@ -0,0 +1,150 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import { customAccess, writerAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
import {
articleOf,
createMetaBackend,
mountMeta,
textOf,
unmountMeta,
type MetaBackend,
} from './metaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
void router
beforeEach(() => {
localStorage.clear()
})
afterEach(unmountMeta)
const SESSION_USER = 1
function grants(publish: string, remove: string, update: string): Access {
return customAccess(
`content.read:article=all, content.create:article=all, content.publish:article=${publish}, content.delete:article=${remove}, content.update:article=${update}`,
'Contributor',
)
}
async function mountArticles(access: Access, articles = [articleOf(1, 'Article 1')]) {
const backend: MetaBackend = createMetaBackend()
backend.articles = articles
backend.me.access = access
await mountMeta('articles', '/article', { backend })
return backend
}
async function openMenu(title: string) {
document.body.querySelector<HTMLElement>(`button[aria-label="Actions for ${title}"]`)!.click()
await flushPromises()
}
function menuItem(label: string) {
return Array.from(document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')).find(
(item) => textOf(item).startsWith(label),
)
}
function isDisabled(item: HTMLElement | undefined) {
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
}
describe('Article controls under all and none', () => {
it('offers every control to a role that holds all of them', async () => {
await mountArticles(grants('all', 'all', 'all'))
await openMenu('Article 1')
expect(menuItem('Publish')).toBeDefined()
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(textOf(document.body)).not.toContain('Ask an editor to publish.')
})
it('hides Publish, keeps the Draft status with the editor hint and disables Delete', async () => {
await mountArticles(grants('none', 'none', 'all'))
expect(document.body.querySelector('[data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
await openMenu('Article 1')
expect(menuItem('Publish')).toBeUndefined()
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(isDisabled(menuItem('Edit'))).toBe(false)
})
it('disables Edit as well when the role cannot update', async () => {
await mountArticles(grants('none', 'none', 'none'))
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't edit articles.")
})
})
describe('The Writer built-in Articles list', () => {
it('reads the Writer header, enables Edit and disables Delete with its reason', async () => {
await mountArticles(writerAccess())
expect(textOf(document.body)).toContain(
'You can write and edit all articles. Publishing is done by an editor.',
)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
expect(menuItem('Publish')).toBeUndefined()
})
})
describe('Article controls under own scope', () => {
const OWN = grants('own', 'own', 'own')
const articles = [
articleOf(1, 'Article 1', { created_by: SESSION_USER }),
articleOf(2, 'Article 2', { created_by: 9 }),
]
it('makes Edit, Delete and Publish available on the article the person created', async () => {
await mountArticles(OWN, articles)
await openMenu('Article 1')
expect(isDisabled(menuItem('Edit'))).toBe(false)
expect(isDisabled(menuItem('Delete'))).toBe(false)
expect(menuItem('Publish')).toBeDefined()
})
it('disables Edit and Delete and hides Publish on an article by someone else', async () => {
await mountArticles(OWN, articles)
await openMenu('Article 2')
expect(isDisabled(menuItem('Edit'))).toBe(true)
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain('You can only edit content you created.')
expect(menuItem('Publish')).toBeUndefined()
expect(document.body.querySelector('tr [data-testid="publish-hint"]')?.textContent).toBe(
'Ask an editor to publish.',
)
})
})
describe('A stale menu', () => {
it('disables Delete with its reason once the session loses the permission', async () => {
await mountArticles(grants('all', 'all', 'all'))
const session = useSessionStore()
session.session = { ...session.session!, access: grants('all', 'none', 'all') }
await flushPromises()
await openMenu('Article 1')
expect(isDisabled(menuItem('Delete'))).toBe(true)
expect(textOf(document.body)).toContain("Your role can't delete articles.")
})
})
+2 -1
View File
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const NOW = new Date('2026-08-26T10:00:00Z')
const HOUR_MS = 60 * 60 * 1000
@@ -137,7 +138,7 @@ async function mountArticles(handlers: Record<string, () => Response | Promise<R
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
...handlers,
@@ -9,6 +9,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const KEY = 'renewcms-page-size-articles'
const TOTAL = 312
@@ -62,7 +63,7 @@ function installFetch() {
async function mountAt(path: string) {
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)
wrapper = mount(ArticleView, {
@@ -10,6 +10,7 @@ import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__te
import ArticleView from '../ArticleView.vue'
import { oneSite } from './editorMetaKit'
import { articleEnvelope, parseListUrl, type EnvelopeParams } from './articleEnvelope'
import { ownerAccess } from '@/test/access'
const TOTAL = 312
const PLASTER_EVERY = 20
@@ -102,7 +103,7 @@ async function mountAt(path: string) {
setActivePinia(createPinia())
overlayMock = createOverlayMock()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
await router.push(path)
wrapper = mount(ArticleView, {
@@ -1,3 +1,4 @@
import { accessFor } from '@/test/access'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
@@ -156,7 +157,7 @@ describe('Articles: the Site column and the Site filter', () => {
withAuthors(1, ['Inès Duarte'], { site_id: 2 }),
withAuthors(2, ['Inès Duarte'], { site_id: 1 }),
])
backend.me.role = role === 'verified' ? undefined : role
backend.me.access = accessFor(role)
await mountMeta('articles', '/article', { backend })
expect(headers()).toEqual([
+26 -5
View File
@@ -10,6 +10,8 @@ import type { CacheStats } from '@/composables/useVariantCache'
import { initializeLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import CachePage from '../CachePage.vue'
import { accessFor, customAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -111,6 +113,7 @@ async function mountPage(
options: {
desktop?: boolean
role?: string | null
access?: Access
routes?: Record<string, Handler>
} = {},
) {
@@ -124,7 +127,7 @@ async function mountPage(
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole as 'owner' | 'admin' | undefined,
access: options.access ?? accessFor(sessionRole),
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
@@ -142,7 +145,7 @@ async function mountPage(
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
access: accessFor(sessionRole),
}),
request: requestMock,
})
@@ -263,11 +266,11 @@ describe('Rule: Cache screen, access and header', () => {
routes: role ? { [STATS]: () => Promise.reject(new ApiError(403)) } : {},
})
expect(document.body.textContent).toContain("You don't have access")
expect(document.body.textContent).toContain("You don't have access to this page")
expect(document.body.textContent).toContain(
'Only admins can manage image variants. Ask an admin if you need one changed.',
"doesn't include it. Ask an owner or admin if you need it.",
)
expect(findButton('Back to Home')).toBeDefined()
expect(findButton('Go to Home') ?? findButton('Go to Articles')).toBeDefined()
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
expect(document.body.querySelector('h1:not([data-slot="title"])')).toBeNull()
expect(calls(STATS)).toHaveLength(role ? 1 : 0)
@@ -821,3 +824,21 @@ describe('Rule: Accessibility and vocabulary', () => {
}
})
})
describe('Rule: Cache screen, settings.edit_general', () => {
it('lets a session with settings.edit_general purge and shows no read-only banner', async () => {
await mountPage({ access: customAccess('settings.view, settings.edit_general') })
expect(document.body.textContent).not.toContain(
'You can read these settings but not change them',
)
expect(findButton('Purge all variants')?.disabled).toBe(false)
})
it('locks the purge controls behind the banner with settings.view alone', async () => {
await mountPage({ access: customAccess('settings.view') })
expect(document.body.textContent).toContain('You can read these settings but not change them')
expect(findButton('Purge all variants')?.disabled).toBe(true)
})
})
+2 -2
View File
@@ -42,7 +42,7 @@ function main() {
}
function headers() {
return [...main().querySelectorAll('thead th')].map(text)
return [...main().querySelectorAll('thead th')].map(text).filter((label) => label !== '')
}
function rows() {
@@ -50,7 +50,7 @@ function rows() {
}
function cells(row: Element) {
return [...row.querySelectorAll('td')].map(text)
return [...row.querySelectorAll('td')].slice(0, -1).map(text)
}
function skeletonRows() {
@@ -38,7 +38,7 @@ function seed(value: string, key = KEY) {
}
function headers() {
return [...document.body.querySelectorAll('thead th')].map(text)
return [...document.body.querySelectorAll('thead th')].map(text).filter((label) => label !== '')
}
function trigger() {
@@ -92,7 +92,7 @@ function mountWithThumbnail(
})
}
const headers = () => [...document.body.querySelectorAll('thead th')]
const headers = () => [...document.body.querySelectorAll('thead th')].slice(0, -1)
const rowsOf = () => [...document.body.querySelectorAll('tbody tr')]
const thumbCell = (row: number) => rowsOf()[row]!.querySelectorAll('td')[2]!
@@ -96,7 +96,7 @@ describe('Navigation actions', () => {
},
)
it.each(['user', 'editor', 'unknown'])(
it.each(['member', 'editor'])(
'does not render Edit content type for the role %s',
async (role) => {
await mountList('/content/events', { role })
@@ -292,7 +292,7 @@ describe('French', () => {
expect(text(main().querySelector('[data-testid="entry-toolbar"]'))).toBe(
'Trier par Dernière mise à jour Colonnes Modifier le type de contenu',
)
expect([...main().querySelectorAll('thead th')].map(text).slice(-3)).toEqual([
expect([...main().querySelectorAll('thead th')].map(text).slice(-4, -1)).toEqual([
'Créé',
'Modifié',
'Modifié par',
@@ -0,0 +1,190 @@
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { setLocale } from '@/i18n'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { customAccess } from '@/test/access'
import {
eventEntries,
installSelectPolyfills,
listPage,
lookupNames,
mountList,
text,
unmountAll,
} from './contentTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeAll(installSelectPolyfills)
afterEach(async () => {
setLocale('en')
await unmountAll()
})
void router
const SESSION_USER = 1
function entryBy(id: number, createdBy: number) {
return { ...eventEntries[0]!, id, created_by: createdBy, updated_by: createdBy }
}
const entries = [entryBy(1, SESSION_USER), entryBy(2, 9), entryBy(3, 0)]
function access(update: string, remove: string, create = 'content.create:events=all') {
return customAccess(
`content.read:events=all, ${create}, content.update:events=${update}, content.delete:events=${remove}`,
'Contributor',
)
}
function main() {
return document.body.querySelector('#dashboard-content')!
}
function rowOf(id: number) {
return [...main().querySelectorAll('tbody tr')].find(
(row) => text(row.querySelector('td'))?.trim() === String(id),
)!
}
async function mountWith(update: string, remove: string, create?: string) {
await mountList('/content/events', {
access: access(update, remove, create),
list: (_key, query) => listPage(entries, 3, query),
lookup: lookupNames,
})
await vi.waitFor(() => expect(main().querySelectorAll('tbody tr')).toHaveLength(3))
}
async function openMenu(id: number) {
rowOf(id).querySelector<HTMLElement>(`button[aria-label="Actions for #${id}"]`)!.click()
await flushPromises()
}
function menuItem(label: string) {
return [...document.body.querySelectorAll<HTMLElement>('[role="menuitem"]')].find((item) =>
text(item).startsWith(label),
)
}
function disabled(item: HTMLElement | undefined) {
return item?.getAttribute('aria-disabled') === 'true' || item?.hasAttribute('data-disabled')
}
function viewOnly(id: number) {
return rowOf(id).querySelector('[data-testid="view-only-mark"]')
}
describe('Entry list rows under each scope', () => {
it.each([
{ update: 'own', remove: 'own', row: 1, mark: false, edit: true, del: true, reason: '' },
{
update: 'own',
remove: 'own',
row: 2,
mark: true,
edit: false,
del: false,
reason: 'You can only edit content you created.',
},
{
update: 'own',
remove: 'own',
row: 3,
mark: true,
edit: false,
del: false,
reason: 'You can only edit content you created.',
},
{ update: 'all', remove: 'all', row: 2, mark: false, edit: true, del: true, reason: '' },
{
update: 'all',
remove: 'none',
row: 1,
mark: false,
edit: true,
del: false,
reason: "Your role can't delete entries.",
},
{
update: 'none',
remove: 'none',
row: 1,
mark: true,
edit: false,
del: false,
reason: "Your role can't edit entries.",
},
])(
'update $update, delete $remove, row $row: mark $mark, edit $edit, delete $del',
async ({ update, remove, row, mark, edit, del, reason }) => {
await mountWith(update, remove)
expect(viewOnly(row) !== null).toBe(mark)
await openMenu(row)
expect(disabled(menuItem('Edit'))).toBe(!edit)
expect(disabled(menuItem('Delete'))).toBe(!del)
if (reason) {
expect(document.body.textContent).toContain(reason)
}
if (update === 'none') {
expect(document.body.textContent).toContain("Your role can't delete entries.")
}
},
)
it('shows the View only mark with a lock icon and an accessible name', async () => {
await mountWith('own', 'own')
const mark = viewOnly(2)!
expect(text(mark)).toBe('View only')
expect(mark.getAttribute('aria-label')).toBe('View only')
expect(mark.getAttribute('tabindex')).toBe('0')
})
})
describe('The entry list explains the own scope', () => {
it('reads the own-scope line, the note and keeps New entry for a role that can create', async () => {
await mountWith('own', 'own')
expect(text(main())).toContain('You can create entries and edit your own.')
expect(text(main())).toContain('Entries by others are view only')
expect(text(main())).toContain('Your role lets you edit entries you created.')
expect([...main().querySelectorAll('button')].some((b) => text(b) === 'New entry')).toBe(
true,
)
})
it('hides New entry when the role cannot create', async () => {
await mountWith('own', 'own', 'content.create:events=none')
expect([...main().querySelectorAll('button')].some((b) => text(b) === 'New entry')).toBe(
false,
)
expect(text(main())).not.toContain('You can create entries and edit your own.')
})
it('shows no note for a role that edits everything', async () => {
await mountWith('all', 'all')
expect(text(main())).not.toContain('Entries by others are view only')
})
it('marks entries by others on the mobile cards too', async () => {
await mountList('/content/events', {
width: MOBILE_WIDTH,
access: access('own', 'own'),
list: (_key, query) => listPage(entries, 3, query),
lookup: lookupNames,
})
await flushPromises()
expect(main().querySelectorAll('[data-testid="view-only-mark"]')).toHaveLength(2)
})
})
@@ -63,10 +63,10 @@ describe('Language: every string of the sites screen has a French entry', () =>
})
it('translates the 403 page', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
await mountDirectory('sites', '/settings/sites', { role: 'member' })
expect(document.body.textContent).toContain('Vous n’avez pas accès aux sites')
expect(document.body.textContent).toContain('Retour aux paramètres')
expect(document.body.textContent).toContain('Vous n’avez pas accès à cette page')
expect(document.body.textContent).toContain('Aller aux articles')
expect(leftovers()).toEqual([])
})
})
@@ -9,6 +9,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
import { accessFor } from '@/test/access'
let wrapper: VueWrapper | null = null
@@ -32,7 +33,7 @@ async function mountWith(
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
access: accessFor('owner'),
}
session.status = 'ready'
await router.push(path)
+11 -2
View File
@@ -8,6 +8,7 @@ import { createAppRouter } from '@/router'
import { setLocale } from '@/i18n'
import { useSessionStore } from '@/stores/session'
import { oneSite } from './editorMetaKit'
import { ownerAccess } from '@/test/access'
type Handler = () => Response | Promise<Response>
@@ -54,7 +55,9 @@ function installFetch(handlers: Record<string, Handler> = {}) {
async function boot(path: string, signedIn: boolean, handlers: Record<string, Handler> = {}) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = signedIn ? { verified: true, username: 'marta', displayName: '' } : null
session.session = signedIn
? { verified: true, access: ownerAccess(), username: 'marta', displayName: '' }
: null
session.status = 'ready'
installFetch(handlers)
const router = createAppRouter(createMemoryHistory())
@@ -167,7 +170,12 @@ describe('Title follows navigation', () => {
const { router, session } = await boot('/login', false)
expect(document.title).toBe('Log in · RenewCMS')
session.session = { verified: true, username: 'marta', displayName: '' }
session.session = {
verified: true,
access: ownerAccess(),
username: 'marta',
displayName: '',
}
await router.push('/home')
await flushPromises()
@@ -296,6 +304,7 @@ describe('Not-found layout', () => {
'GET /v1/auth/me': () =>
mockResponse(200, {
is_verified: true,
access: ownerAccess(),
username: 'marta',
display_name: 'Marta',
}),
@@ -23,6 +23,7 @@ import {
type MetaRole,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
import { customAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -178,6 +179,20 @@ describe('Add author: "Create author…" only for roles with author rights', ()
})
})
describe('Add author: the row follows authors.create', () => {
it.each([
{ grants: 'content.read, content.update, authors.view, authors.create', shown: true },
{ grants: 'content.read, content.update, authors.view', shown: false },
])('grants $grants: row shown $shown', async ({ grants, shown }) => {
const backend = backendWith('owner', 'Inès Duarte')
backend.me.access = customAccess(grants)
await mountMeta('editor', EDITOR, { backend })
await openCombobox()
expect(createRow() !== null).toBe(shown)
})
})
describe('Add author: no match', () => {
it.each([
{ role: 'owner', create: true },
@@ -8,6 +8,7 @@ import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { ownerAccess } from '@/test/access'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -158,7 +159,7 @@ async function mountEditor(
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
@@ -9,6 +9,7 @@ import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { fixture } from './mediaTestKit'
import { ownerAccess } from '@/test/access'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -62,7 +63,7 @@ async function mountEditor(body = BODY_WITH_TWO_IMAGES) {
setActivePinia(createPinia())
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = vi.fn(async (url: string) => {
if (url === '/v1/article/12?format=html') {
@@ -1,3 +1,4 @@
import { accessFor } from '@/test/access'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import router from '@/router'
@@ -278,7 +279,7 @@ describe('Editor section: the Site select', () => {
'is above the authors with the canonical host as helper text for a %s',
async (role) => {
const backend = backendWith('several', ['Inès Duarte'], 2)
backend.me.role = role === 'verified' ? undefined : role
backend.me.access = accessFor(role)
await mountMeta('editor', EDITOR, { backend })
const body = textOf(document.body.querySelector('[data-testid="meta-body"]'))
@@ -9,6 +9,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
import { typeInto } from './directoryTestKit'
import { oneSite } from './editorMetaKit'
import { fieldByLabel, openPanel, panelRoot, saveChangesButton } from './seoTestKit'
import { ownerAccess } from '@/test/access'
function reply(status: number, body: unknown = null, text = '') {
return {
@@ -70,7 +71,7 @@ async function mountEditor() {
setViewportWidth(DESKTOP_WIDTH)
Element.prototype.scrollIntoView = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
await router.push('/article/12/edit')
wrapper = mount(RootComponent, {
@@ -10,6 +10,7 @@ import RootComponent from '@/App.vue'
import { messages } from '@/i18n/messages'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { fixture, textOf } from './mediaTestKit'
import { accessFor } from '@/test/access'
const FIXED_NOW = '2026-09-26T11:02:00Z'
const HERO_BODY =
@@ -116,7 +117,7 @@ async function mountEditor(options: EditorOptions = {}) {
session.session = {
verified: true,
username: 'marta',
role: (options.role ?? 'owner') as never,
access: accessFor(options.role ?? 'owner'),
}
session.status = 'ready'
const variants = options.variants ?? (() => ({ status: 200, body: { items: CATALOG } }))
@@ -385,7 +386,7 @@ describe('The Variant select', () => {
})
it('sends no variants request for a session that is not owner or admin', async () => {
const wrapper = await mountEditor({ role: 'user' })
const wrapper = await mountEditor({ role: 'member' })
await openPicker(wrapper)
await selectTile('north-wall.jpg')
+3 -2
View File
@@ -9,6 +9,7 @@ import type { Article } from '@/composables/useArticles'
import ArticleEditor from '@/components/ArticleEditor.vue'
import RootComponent from '@/App.vue'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { ownerAccess } from '@/test/access'
const FIXED_NOW = '2026-09-26T11:02:00Z'
@@ -97,7 +98,7 @@ async function mountEditor(
overlayMock = createOverlayMock()
toastAdd = vi.fn()
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter(handlers) as unknown as typeof fetch
await router.push(path)
@@ -632,7 +633,7 @@ describe('Session expired mid-save keeps the draft and restores it after logging
expect(router.currentRoute.value.query.reason).toBe('expired')
const session = useSessionStore()
session.session = { verified: true, username: 'marta' }
session.session = { verified: true, access: ownerAccess(), username: 'marta' }
session.status = 'ready'
global.fetch = fetchRouter({
'GET /v1/article/12?format=html': () =>
@@ -0,0 +1,49 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { customAccess } from '@/test/access'
import { button, entry42, main, mountEditor, text, unmountAll } from './entryEditorKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
void router
afterEach(unmountAll)
const OWN_UPDATE = customAccess(
'content.read:events=all, content.update:events=own, content.delete:events=own',
'Contributor',
)
describe('Direct link to an entry the role can only view', () => {
it('shows the entry with the View only mark and no Save or Delete action', async () => {
await mountEditor('/content/events/42', { access: OWN_UPDATE })
expect(text(main())).toContain('Lime plaster workshop')
expect(main().querySelector('[data-testid="view-only-mark"]')).not.toBeNull()
expect(button('Save')).toBeUndefined()
expect(button('Delete')).toBeUndefined()
expect(main().querySelector('button[type="submit"]')).toBeNull()
})
it('keeps the form editable for an entry the role created', async () => {
await mountEditor('/content/events/42', {
access: OWN_UPDATE,
entry: () => ({ ...entry42, created_by: 1 }),
})
expect(main().querySelector('[data-testid="view-only-mark"]')).toBeNull()
expect(button('Save')).toBeDefined()
})
it('shows no View only mark for a role that updates every entry', async () => {
await mountEditor('/content/events/42', {
access: customAccess('content.read:events=all, content.update:events=all'),
})
expect(main().querySelector('[data-testid="view-only-mark"]')).toBeNull()
expect(button('Save')).toBeDefined()
})
})
@@ -9,6 +9,7 @@ import type { ActivityItem } from '@/lib/activity'
import HomeView from '../HomeView.vue'
import { anonymous, makeItem, target } from './activityFixtures'
import { articleEnvelope, articleListRoute } from './articleEnvelope'
import { accessFor } from '@/test/access'
const ACTIVITY_ROUTE = 'GET /v1/activity?size=5'
const ARTICLES_ROUTE = articleListRoute({ size: 5, sort: 'updated_at:desc' })
@@ -53,7 +54,7 @@ let calls: string[] = []
async function mountHome(role: 'owner' | 'admin' | undefined, handlers: Record<string, Handler>) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.session = { verified: true, username: 'marta', access: accessFor(role) }
session.status = 'ready'
calls = []
global.fetch = vi.fn((url: string, init?: RequestInit) => {
@@ -123,7 +124,7 @@ describe('Home, owner or admin, the last five events with the actor name', () =>
})
describe('Home, regular user, own events only and no actor name', () => {
it.each([['editor' as const], [undefined]])('shows only the time for %s', async (role) => {
it.each([['editor' as const]])('shows only the time for %s', async (role) => {
await mountHome(role as undefined, {
[ACTIVITY_ROUTE]: () => page(recentItems(3)),
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
@@ -294,7 +295,7 @@ describe('Home card, error shows a retryable alert that leaves the page alone',
describe('Home card, empty when there are no events yet', () => {
it.each([
['owner' as const, 'Actions on your site will appear here.'],
[undefined, 'Your actions will appear here.'],
['editor', 'Your actions will appear here.'],
])('says so for %s', async (role, text) => {
await mountHome(role as 'owner', {
[ACTIVITY_ROUTE]: () => page([], 0),
@@ -387,3 +388,13 @@ describe('Home card, the French interface', () => {
expect(card().text()).toContain('Aucune activité pour le moment')
})
})
describe('Home card, not shown without activity.view', () => {
it('renders no recent activity card and makes no activity request', async () => {
await mountHome(undefined, {
[ARTICLES_ROUTE]: () => response(200, articleEnvelope(articles, { size: 5 })),
})
expect(card().exists()).toBe(false)
})
})
@@ -11,6 +11,7 @@ import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__te
import HomeView from '../HomeView.vue'
import { NEW_TAG, newerAvailable, upToDate } from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
import { accessFor } from '@/test/access'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -33,7 +34,7 @@ async function mountHome({ role, status, width = DESKTOP_WIDTH }: MountOptions)
setViewportWidth(width)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.session = { verified: true, username: 'marta', access: accessFor(role) }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
@@ -18,6 +18,7 @@ import {
verifying,
} from './updateTestKit'
import { articleEnvelope, articleListUrl } from './articleEnvelope'
import { accessFor } from '@/test/access'
function mockResponse(status: number, body: unknown = null) {
return {
@@ -33,7 +34,7 @@ let wrapper: VueWrapper | null = null
async function mountHome(role: UserRole, status: UpdateStatus) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', role }
session.session = { verified: true, username: 'marta', access: accessFor(role) }
session.status = 'ready'
global.fetch = vi.fn((url: string) => {
if (url === articleListUrl({ size: 5, sort: 'updated_at:desc' })) {
+8 -1
View File
@@ -371,17 +371,20 @@ describe('The greeting is identical across every load state at a fixed hour', ()
})
await vi.advanceTimersByTimeAsync(150)
expect(loadingWrapper.text()).toContain('Good morning, marta')
loadingWrapper.unmount()
const defaultWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () =>
mockResponse(200, articleEnvelope(sixArticles, { size: 5 })),
})
expect(defaultWrapper.text()).toContain('Good morning, marta')
defaultWrapper.unmount()
const emptyWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(200, articleEnvelope([], { size: 5 })),
})
expect(emptyWrapper.text()).toContain('Good morning, marta')
emptyWrapper.unmount()
const errorWrapper = await mountHome({
[HOME_ARTICLES_ROUTE]: () => mockResponse(500),
@@ -423,7 +426,11 @@ describe('The greeting names the person by display name, else username', () => {
vi.spyOn(Date.prototype, 'getHours').mockReturnValue(9)
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', displayName: '' }
session.session = {
verified: true,
username: 'marta',
displayName: '',
}
session.status = 'ready'
global.fetch = fetchRouter({
[HOME_ARTICLES_ROUTE]: () =>
@@ -10,6 +10,7 @@ import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import { deferred, setViewport, stubBrowser, variant } from './variantsTestKit'
import IntegrationView from '../IntegrationView.vue'
import { accessFor, ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -110,7 +111,7 @@ async function mountPage(options: MountOptions = {}) {
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole,
access: accessFor(sessionRole),
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
@@ -122,6 +123,7 @@ async function mountPage(options: MountOptions = {}) {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
access: ownerAccess(),
username: 'marta',
id: 1,
role,
@@ -7,6 +7,7 @@ import { useSessionStore } from '@/stores/session'
import EndpointCard from '@/components/EndpointCard.vue'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import IntegrationView from '../IntegrationView.vue'
import { ownerAccess } from '@/test/access'
const SEARCH_LABEL = 'Search the API reference'
const FLASH_MS = 1500
@@ -35,7 +36,7 @@ async function mountIntegration(width = DESKTOP_WIDTH, hash = '') {
})
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(`/integration${hash}`)
const wrapper = mount(IntegrationView, {
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia } from 'pinia'
import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import LoginView from '../LoginView.vue'
import { ownerAccess } from '@/test/access'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
@@ -318,6 +319,7 @@ describe('A successful log in navigates to the right destination', () => {
{
path: '/login?redirect=/article/12/edit',
verified: true,
access: ownerAccess(),
destination: '/article/12/edit',
},
])(
@@ -0,0 +1,56 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { customAccess } from '@/test/access'
import { findButton, mountPage, settle, textOf, unmountPage } from './mediaTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
void router
afterEach(unmountPage)
function checkboxes() {
return document.body.querySelectorAll('[role="checkbox"]')
}
describe('Media controls by grants', () => {
it('is read-only with media.view only: no upload, no selection', async () => {
await mountPage({}, '/media', undefined, { access: customAccess('media.view') })
await settle()
expect(findButton('Upload')).toBeUndefined()
expect(checkboxes()).toHaveLength(0)
})
it('offers upload with media.upload and still no selection', async () => {
await mountPage({}, '/media', undefined, {
access: customAccess('media.view, media.upload'),
})
await settle()
expect(findButton('Upload')).toBeDefined()
expect(checkboxes()).toHaveLength(0)
})
it('offers selection with media.delete at all', async () => {
await mountPage({}, '/media', undefined, {
access: customAccess('media.view, media.delete=all'),
})
await settle()
expect(checkboxes().length).toBeGreaterThan(0)
})
it('treats media.delete at own like none', async () => {
await mountPage({}, '/media', undefined, {
access: customAccess('media.view, media.delete=own'),
})
await settle()
expect(checkboxes()).toHaveLength(0)
expect(textOf(document.body)).not.toContain('Purge duplicates')
})
})
@@ -21,6 +21,7 @@ import {
unmountPage,
type Handlers,
} from './mediaTestKit'
import { accessFor } from '@/test/access'
const toasts = vi.hoisted(() => ({ add: vi.fn(), remove: vi.fn() }))
@@ -46,7 +47,7 @@ async function mountUnverified(handlers: Handlers, url = '/media') {
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
access: accessFor('owner'),
}
}
+2 -1
View File
@@ -35,6 +35,7 @@ import {
xhrFor,
type Handlers,
} from './mediaTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -846,7 +847,7 @@ describe('Session errors on the upload calls are global', () => {
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
access: accessFor('owner'),
}
await chooseFiles([photo('a.jpg')])
+2 -1
View File
@@ -10,6 +10,7 @@ import type { MediaItem, MediaUsageItem } from '@/composables/useMedia'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import MediaView from '../MediaView.vue'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -311,7 +312,7 @@ async function mountPage(handlers: Handlers = {}, url = '/media', width = DESKTO
username: 'marta',
id: 1,
displayName: '',
role: 'owner',
access: accessFor('owner'),
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
+4 -1
View File
@@ -5,6 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import PendingView from '../PendingView.vue'
import { ownerAccess } from '@/test/access'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
@@ -243,7 +244,8 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
it('awaits session.retry, navigates to /home and shows the info toast', async () => {
const { wrapper } = await mountPending({
'DELETE /v1/auth/register': () => mockResponse(403),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, access: ownerAccess() }),
})
const session = useSessionStore()
session.session = { verified: false }
@@ -259,6 +261,7 @@ describe('Already verified — 403 shows the verified panel and toast, then navi
expect(session.session).toMatchObject({
verified: true,
access: ownerAccess(),
displayName: '',
})
expect(pushSpy).not.toHaveBeenCalledWith('/register/pending')
@@ -7,6 +7,7 @@ import App from '@/App.vue'
import { useSessionStore, type Session } from '@/stores/session'
import { ApiError, NetworkError, authRedirectPending, useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -47,6 +48,7 @@ function createOverlayMock() {
const MARTA: Session = {
id: 1,
verified: true,
access: accessFor('noschema'),
username: 'marta',
email: 'marta@renew.example',
displayName: '',
@@ -112,7 +112,7 @@ describe('The overflow menu', () => {
await mountAt('/schema/recipes', { role: 'user' })
expect(more()).toBeNull()
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
})
it('is enabled on a saved type and disabled while the PUT is in flight', async () => {
@@ -447,7 +447,7 @@ describe('Other answers of DELETE', () => {
await click(dialogButton(DELETE))
await vi.waitFor(() => expect(dialog()).toBeNull())
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
})
it.each([
@@ -172,7 +172,7 @@ describe('The count cannot be loaded', () => {
it('shows the access page when the count answers 403', async () => {
await mountAt('/schema/events', { entries: failWith(403) })
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
expect(nameInput()).toBeNull()
})
@@ -201,7 +201,7 @@ describe('Roles and sessions in edit mode', () => {
it('tells a verified user who is neither to go home, leaving the URL', async () => {
await mountAt('/schema/events', { role: 'user' })
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
expect(button('Go to Home')).toBeInstanceOf(HTMLButtonElement)
expect(router.currentRoute.value.path).toBe('/schema/events')
expect(calls.filter((url) => url.includes('/entry'))).toHaveLength(0)
@@ -719,7 +719,7 @@ describe('Other answers of the PUT', () => {
it('shows the access page for 403', async () => {
await answered(failWithBody(403, 'Only owners and admins'))
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
expect(nameInput()).toBeNull()
})
@@ -531,7 +531,7 @@ describe('The builder is for owners and admins', () => {
it('keeps the forbidden state and renders no relation control', async () => {
await mountAt('/schema/events', { role: 'user', content: types })
expect(text(el('#dashboard-content'))).toContain("You don't have access to Schema")
expect(text(el('#dashboard-content'))).toContain("You don't have access to this page")
expect(el('[data-testid="relation-picker"]')).toBeNull()
})
})
@@ -351,7 +351,7 @@ describe('The server refuses a key or a field', () => {
await click(saveButton())
expect(text(main())).toContain("You don't have access to Schema")
expect(text(main())).toContain("You don't have access to this page")
expect(nameInput()).toBeNull()
})
@@ -32,7 +32,7 @@ addCollection(lucide)
afterEach(unmountAll)
const NOT_FOUND_TITLE = 'Content type not found'
const ACCESS_TITLE = "You don't have access to Schema"
const ACCESS_TITLE = "You don't have access to this page"
function newTypeButtons() {
return all('button').filter((candidate) => text(candidate) === 'New content type')
@@ -22,7 +22,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
})
const SKELETON_DELAY_MS = 150
const ACCESS_TITLE = "You don't have access to Schema"
const ACCESS_TITLE = "You don't have access to this page"
const NOT_FOUND_TITLE = 'Content type not found'
const NOT_FOUND_TEXT = 'It may have been deleted, or the address may be mistyped.'
const NOTE =
+2 -2
View File
@@ -25,7 +25,7 @@ vi.mock('@/composables/useApi', async (importOriginal) => {
})
const SKELETON_DELAY_MS = 150
const ACCESS_TITLE = "You don't have access to Schema"
const ACCESS_TITLE = "You don't have access to this page"
addCollection(lucide)
@@ -280,7 +280,7 @@ describe('Schema list: forbidden', () => {
expect(router.currentRoute.value.path).toBe('/schema')
expect(text(main())).toContain(ACCESS_TITLE)
expect(text(main())).toContain(
'Only owners and admins can change content types. You can still add and edit entries from the Content menu.',
"Your role, , doesn't include it. Ask an owner or admin if you need it.",
)
expect(calls).not.toContain('/v1/builtin-type')
expect(document.body.querySelector('a[href="/schema"]')).toBeNull()
+2 -1
View File
@@ -1,3 +1,4 @@
import { accessFor } from '@/test/access'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { MOBILE_WIDTH } from '@/components/__tests__/viewport'
@@ -243,7 +244,7 @@ describe('Dismiss x: behaviour', () => {
it.each(['owner', 'admin', 'verified'] as const)('is available to the %s', async (role) => {
const backend = backendWithDismissals()
backend.me.role = role === 'verified' ? undefined : role
backend.me.access = accessFor(role)
await mountSeo(backend)
const panel = await openPanel()
+6 -7
View File
@@ -321,21 +321,20 @@ describe.each([
})
describe('Sites SEO defaults: sessions', () => {
it('shows the owner-only toast when the PATCH answers 403', async () => {
it('shows no owner-only toast of its own when the PATCH answers 403 missing_permission', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/1'] = failWith(403, 'owner_only')
backend.responders['PATCH /v1/site/1'] = failWith(403, 'missing_permission')
await openEdit('Renew', backend)
await typeInto(templateField(), '%title% – %site%')
await submit()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
})
it('shows the 403 page to an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
it('shows the 403 page to a writer', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'member' })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(document.body.textContent).toContain("You don't have access to this page")
expect(document.body.textContent).not.toContain('Title template')
expect(vi.mocked(useApi)).toHaveBeenCalled()
})
@@ -18,6 +18,7 @@ import {
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
import { accessFor, ownerAccess } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -34,7 +35,7 @@ let wrapper: VueWrapper | null = null
async function mountSettings(role: UserRole, status: UpdateStatus) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role }
session.session = { verified: true, username: 'marta', id: 1, access: accessFor(role) }
session.status = 'ready'
const request = vi.fn((path: string) => {
if (path === '/v1/settings/') {
@@ -51,6 +52,7 @@ async function mountSettings(role: UserRole, status: UpdateStatus) {
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
access: ownerAccess(),
username: 'marta',
id: 1,
displayName: '',
@@ -135,10 +137,10 @@ describe('Settings, admin, container', () => {
it('keeps the admin view with no command block', async () => {
await mountSettings('admin', newerAvailable({ container: true }))
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardText()).not.toContain('Docker')
expect(updatesCard().querySelector('code')).toBeNull()
expect(visibleButtonLabels(updatesCard())).toEqual([])
expect(visibleButtonLabels(updatesCard())).toEqual(['Apply update'])
})
})
@@ -346,7 +346,10 @@ describe('Media cleanup card: save failures', () => {
expect(toasts.add).not.toHaveBeenCalled()
expect(router.currentRoute.value.path).toBe('/settings')
expect(switchError()).toBeNull()
expect(textOf(cardEl())).toContain('Only owners and admins can change this.')
expect(textOf(cardEl())).toContain('You can read these settings but not change them')
expect(textOf(cardEl())).toContain(
'Ask the owner to change them, or ask for a role that can edit settings.',
)
expect(findButton('Purge duplicates', cardEl())).toBeUndefined()
expect(switchEl().disabled).toBe(true)
})
@@ -365,7 +368,7 @@ describe('Media cleanup card: read-only', () => {
expect(lastLine()).toBe('Never run')
expect(nextLine()).toBeNull()
expect(textOf(card.querySelector('[role="status"]'))).toBe(
'Only owners and admins can change this.',
'You can read these settings but not change themAsk the owner to change them, or ask for a role that can edit settings.',
)
expect(card.querySelector('[role="separator"]')).toBeNull()
expect(textOf(card)).not.toContain('Purge now')
@@ -35,6 +35,7 @@ import {
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -129,7 +130,7 @@ async function mountSettings(options: MountOptions) {
verified: true,
username: 'marta',
id: 1,
role: options.role,
access: accessFor(options.role),
}
session.status = 'ready'
const statusHandler = asHandler(options.status)
@@ -167,7 +168,7 @@ async function mountSettings(options: MountOptions) {
username: 'marta',
id: 1,
displayName: '',
role: options.meAfterRefresh ?? options.role,
access: accessFor(options.meAfterRefresh ?? options.role),
})
vi.mocked(useApi).mockReturnValue({ getMe, request: requestMock })
await router.push('/settings')
@@ -935,7 +936,7 @@ describe("Settings, another owner's tab never takes the toast", () => {
await mountSettings({ role: 'admin', status: restarting() })
expect(overlayElement()).toBeNull()
expect(cardButtons()).toEqual([])
expect(cardButtons()).toEqual(['Apply update'])
const before = callsTo(STATUS_PATH).length
await tick(3000)
expect(callsTo(STATUS_PATH)).toHaveLength(before)
@@ -1090,8 +1091,8 @@ describe('Settings, 403 and 401 on Restart', () => {
description: 'Ask the owner of this site to update RenewCMS.',
}),
)
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardButtons()).toEqual(['Apply update'])
expect(sessionStorage.length).toBe(0)
})
@@ -10,6 +10,7 @@ import type { WorkspaceSettings } from '@/composables/useSettings'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -60,7 +61,7 @@ async function mountPage(
) {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = { verified: true, username: 'marta', id: 1, role }
session.session = { verified: true, username: 'marta', id: 1, access: accessFor(role) }
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
if (path === SETTINGS_PATH && init?.method === 'PATCH') {
@@ -74,7 +75,9 @@ async function mountPage(
}
throw new Error(`Unexpected request: ${path}`)
})
getMeMock = vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1, role })
getMeMock = vi
.fn()
.mockResolvedValue({ verified: true, username: 'marta', id: 1, access: accessFor(role) })
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
request: requestMock,
@@ -151,18 +154,28 @@ afterEach(async () => {
document.body.innerHTML = ''
})
describe('Settings retention is hidden for everyone but the owner', () => {
it.each([['admin' as const], ['editor' as const], [undefined]])(
'renders nothing for %s',
async (role) => {
await mountPage(role, () => Promise.resolve(settings()))
describe('Settings retention is read-only without system.edit_retention', () => {
it('shows the card locked with the Owner only tag and the admin helper for an admin', async () => {
await mountPage('admin', () => Promise.resolve(settings()))
expect(retention()).toBeNull()
expect(document.body.textContent).not.toContain('Keep activity for')
expect(document.body.textContent).toContain('Media cleanup')
expect(settingsGets()).toHaveLength(1)
},
)
expect(retention()).not.toBeNull()
expect(text()).toContain('Owner only')
expect(text()).toContain(
'Admins can read the log. Only the owner can change how long it is kept.',
)
expect(text()).not.toContain('Only owners can change it.')
const select = document.body.querySelector<HTMLButtonElement>(
'[data-testid="retention-select-days"]',
)
expect(select?.disabled).toBe(true)
expect(settingsGets()).toHaveLength(1)
})
it('shows no Owner only tag to the owner', async () => {
await mountPage('owner', () => Promise.resolve(settings()))
expect(text()).not.toContain('Owner only')
})
})
describe('Settings retention, Default with 90 days', () => {
@@ -440,7 +453,7 @@ describe('Settings retention, save error reverts the control and Try again retri
expect(text()).not.toContain('Events older than')
})
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
it('refreshes the session on a 403 and locks the card for a demoted owner', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings()),
@@ -450,13 +463,13 @@ describe('Settings retention, save error reverts the control and Try again retri
verified: true,
username: 'marta',
id: 1,
role: 'admin',
access: accessFor('admin'),
})
await choose(365)
expect(getMeMock).toHaveBeenCalled()
expect(retention()).toBeNull()
expect(text()).toContain('Owner only')
})
it('shows no card alert on a 401', async () => {
@@ -790,7 +803,7 @@ describe('Settings retention size limit, save errors revert only the size select
expect(text()).not.toContain('Events over')
})
it('refreshes the session on a 403 and removes the card for a demoted owner', async () => {
it('refreshes the session on a 403 and locks the card for a demoted owner', async () => {
await mountPage(
'owner',
() => Promise.resolve(settings({ activity_retention_max_gb: 5 })),
@@ -800,12 +813,12 @@ describe('Settings retention size limit, save errors revert only the size select
verified: true,
username: 'marta',
id: 1,
role: 'admin',
access: accessFor('admin'),
})
await chooseSize(10)
expect(retention()).toBeNull()
expect(text()).toContain('Owner only')
})
it('shows no card alert on a 401', async () => {
@@ -24,6 +24,7 @@ import {
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -81,7 +82,7 @@ async function mountSettings(options: MountOptions) {
verified: true,
username: 'marta',
id: 1,
role: options.role,
access: accessFor(options.role),
}
session.status = 'ready'
const statusHandler = asHandler(options.status)
@@ -106,7 +107,7 @@ async function mountSettings(options: MountOptions) {
username: 'marta',
id: 1,
displayName: '',
role: options.meAfterRefresh ?? options.role,
access: accessFor(options.meAfterRefresh ?? options.role),
})
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
@@ -530,8 +531,8 @@ describe('Settings, 403 role changed on Update', () => {
}),
)
expect(getMeMock).toHaveBeenCalled()
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardButtons()).toEqual(['Apply update'])
})
})
@@ -638,10 +639,8 @@ describe('Settings, admin view during a job', () => {
await tick(5000)
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
)
expect(cardButtons()).toEqual([])
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardButtons()).toEqual(['Apply update'])
expect(progressbar()).toBeNull()
expect(callsTo(STATUS_PATH)).toHaveLength(afterOpening)
})
@@ -22,6 +22,8 @@ import {
DEFAULT_PURGE_SETTINGS,
PURGE_SETTINGS_PATH,
} from './updateTestKit'
import { accessFor, customAccess } from '@/test/access'
import type { Access } from '@/lib/permissions'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -49,6 +51,7 @@ let wrapper: VueWrapper | null = null
interface MountOptions {
role: UserRole
access?: Access
status?: Handler | UpdateStatus
check?: Handler | UpdateStatus
meAfterRefresh?: UserRole
@@ -69,7 +72,7 @@ async function mountSettings(options: MountOptions) {
verified: true,
username: 'marta',
id: 1,
role: options.role,
access: options.access ?? accessFor(options.role),
}
session.status = 'ready'
const statusHandler = asHandler(options.status, () => Promise.resolve(upToDate()))
@@ -94,7 +97,7 @@ async function mountSettings(options: MountOptions) {
username: 'marta',
id: 1,
displayName: '',
role: options.meAfterRefresh ?? options.role,
access: accessFor(options.meAfterRefresh ?? options.role),
})
vi.mocked(useApi).mockReturnValue({
getMe: getMeMock,
@@ -363,10 +366,13 @@ describe('Settings, admin view', () => {
await mountSettings({ role: 'admin', status: newerAvailable() })
expect(cardText()).toContain('Version 2.3.0-beta is available')
expect(cardText()).toContain(
'You have v2.2.0-beta. Only the owner can update RenewCMS. Ask the owner of this site.',
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardButtons()).toEqual(['Apply update'])
expect(cardText()).toContain('Owner only')
const apply = Array.from(document.body.querySelectorAll('button')).find(
(button) => button.textContent?.trim() === 'Apply update',
)
expect(cardButtons()).toEqual([])
expect(apply?.disabled).toBe(true)
expect(callsTo(CHECK_PATH)).toHaveLength(0)
})
@@ -383,6 +389,30 @@ describe('Settings, admin view', () => {
})
})
describe('Settings, Software card follows system.apply_updates', () => {
it('disables the Apply action with settings.view alone and shows the Owner only tag', async () => {
await mountSettings({
role: 'admin',
access: customAccess('settings.view'),
status: newerAvailable(),
})
expect(cardText()).toContain('Owner only')
expect(updatesCard().querySelector('button')?.disabled).toBe(true)
})
it('offers the check action with system.apply_updates and no Owner only tag', async () => {
await mountSettings({
role: 'admin',
access: customAccess('settings.view, system.apply_updates'),
status: upToDate(),
})
expect(cardText()).not.toContain('Owner only')
expect(cardText()).toContain('Check for updates')
})
})
describe('Settings, Role changed (403)', () => {
it('toasts, reloads the status and renders the admin view', async () => {
let reads = 0
@@ -408,8 +438,8 @@ describe('Settings, Role changed (403)', () => {
)
expect(getMeMock).toHaveBeenCalled()
expect(callsTo(STATUS_PATH)).toHaveLength(2)
expect(cardText()).toContain('Only the owner can update RenewCMS.')
expect(cardButtons()).toEqual([])
expect(cardText()).toContain('Only the owner can apply updates.')
expect(cardButtons()).toEqual(['Apply update'])
})
})
+26 -10
View File
@@ -7,6 +7,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import { useSessionStore } from '@/stores/session'
import SettingsView from '../SettingsView.vue'
import { DEFAULT_PURGE_SETTINGS, PURGE_SETTINGS_PATH } from './updateTestKit'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -30,14 +31,14 @@ function deferred<T>() {
return { promise, resolve }
}
async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'admin') {
async function mountPage(settings: () => Promise<unknown>, role: string | undefined = 'owner') {
setActivePinia(createPinia())
const session = useSessionStore()
session.session = {
verified: true,
access: accessFor(role),
username: 'marta',
id: 1,
...(role ? { role } : {}),
}
session.status = 'ready'
requestMock = vi.fn((path: string) => {
@@ -50,7 +51,12 @@ async function mountPage(settings: () => Promise<unknown>, role?: 'owner' | 'adm
throw new Error(`Unexpected request: ${path}`)
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified: true, username: 'marta', id: 1 }),
getMe: vi.fn().mockResolvedValue({
verified: true,
access: accessFor('owner'),
username: 'marta',
id: 1,
}),
request: requestMock,
})
await router.push('/settings')
@@ -192,8 +198,10 @@ describe('A 401 or an unverified 403 is handled globally, not by the page', () =
it.each([401, 403])('shows no error alert of its own on a %i', async (status) => {
await mountPage(() => Promise.reject(new ApiError(status)))
expect(document.body.querySelector('[role="alert"]')).toBeNull()
expect(tryAgainButton()).toBeUndefined()
const alerts = Array.from(document.body.querySelectorAll('[role="alert"]')).filter(
(alert) => !alert.closest('[data-testid="retention-card"]'),
)
expect(alerts).toEqual([])
})
})
@@ -218,13 +226,21 @@ describe('Publishing card: the owner manages sites from Settings', () => {
expect(nav.querySelector('a[href="/settings/sites"]')?.textContent).toContain('Sites')
})
it('hides the Publishing card, the Manage sites row and the Sites entry from an admin', async () => {
it('shows the Publishing card, the Manage sites row and the Sites entry to an admin', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'admin')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
expect(document.body.textContent).not.toContain('Manage sites')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).not.toBeNull()
expect(document.body.textContent).toContain('Manage sites')
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(nav.textContent).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
expect(nav.textContent).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
it('hides the Publishing card and the Sites entry from a role without settings.manage_sites', async () => {
await mountPage(() => Promise.resolve({ version: 'v2.2.0-beta' }), 'editor')
expect(document.body.querySelector('[data-testid="publishing-links-card"]')).toBeNull()
const nav = document.body.querySelector('nav[aria-label="Main"]')
expect(nav?.querySelector('a[href="/settings/sites"]') ?? null).toBeNull()
})
})
@@ -21,6 +21,7 @@ import {
type MetaBackend,
} from './metaTestKit'
import { useSessionStore } from '@/stores/session'
import { accessFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -104,7 +105,7 @@ describe('Sites and authors across Editor, Articles and Profile', () => {
it('a verified account creates its profile from the hint, saves, and the Authors column shows the avatar', async () => {
const backend = createMetaBackend({
sites: renewSites().slice(0, 1),
role: 'verified',
access: accessFor('verified'),
})
backend.article.authors = []
backend.articles = [backend.article]
@@ -8,6 +8,7 @@ import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport
import { META_SECTION_OPEN_KEY } from '@/lib/metaSectionMemory'
import { useSessionStore } from '@/stores/session'
import { articleMeta, oneSite, readyCheck } from './editorMetaKit'
import { ownerAccess } from '@/test/access'
type Handler = () => Response
@@ -54,6 +55,7 @@ async function mountAt(path: string) {
const session = useSessionStore()
session.session = {
verified: true,
access: ownerAccess(),
username: 'marta',
id: 1,
displayName: 'Marta Alves',
+45 -18
View File
@@ -3,6 +3,7 @@ import { flushPromises } from '@vue/test-utils'
import router from '@/router'
import { useApi } from '@/composables/useApi'
import { DESKTOP_WIDTH, MOBILE_WIDTH } from '@/components/__tests__/viewport'
import { customAccess } from '@/test/access'
import {
callsTo,
chooseMenuItem,
@@ -239,33 +240,28 @@ describe('Sites: set as default', () => {
)
})
it('closes and shows the owner-only toast on a 403', async () => {
it('closes and leaves the generic missing-permission toast to the API client on a 403', async () => {
const backend = createBackend()
backend.responders['PATCH /v1/site/2/default'] = failWith(
403,
'Only the owner can do this.',
)
backend.responders['PATCH /v1/site/2/default'] = failWith(403, 'missing_permission')
await openDialog(backend)
await click('Make default', dialog()!)
expect(dialog()).toBeNull()
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
})
})
describe.each([
['admin', '/settings/sites'],
['member', '/settings/sites'],
['admin', '/settings/sites?edit=journal'],
['member', '/settings/sites?edit=journal'],
])('Sites: forbidden (403 page) for %s on %s', (role, path) => {
it('explains instead of redirecting, opens no panel and Back to Settings goes to /settings', async () => {
it('explains instead of redirecting, opens no panel and offers Go to Articles', async () => {
const backend = await mountDirectory('sites', path, { role })
const text = document.body.textContent ?? ''
expect(text).toContain("You don't have access to Sites")
expect(text).toContain("You don't have access to this page")
expect(text).toContain(
'Only the owner can manage sites. Ask the owner of this site if you need a change.',
"Your role, Writer, doesn't include it. Ask an owner or admin if you need it.",
)
expect(router.currentRoute.value.fullPath).toBe(path)
expect(dialog()).toBeNull()
@@ -275,9 +271,9 @@ describe.each([
expect(callsTo(backend, 'GET', '/v1/site/')).toHaveLength(0)
const push = vi.spyOn(router, 'push').mockResolvedValue(undefined)
await click('Back to Settings')
await click('Go to Articles')
expect(push).toHaveBeenCalledWith('/settings')
expect(push).toHaveBeenCalledWith('/article')
push.mockRestore()
})
})
@@ -288,7 +284,7 @@ describe('Sites: the API answers 403 where the SPA expected the owner', () => {
backend.responders['GET /v1/site/'] = failWith(403, 'Only the owner can do this.')
await mountDirectory('sites', '/settings/sites', { backend })
expect(document.body.textContent).toContain("You don't have access to Sites")
expect(document.body.textContent).toContain("You don't have access to this page")
expect(rows()).toHaveLength(0)
})
@@ -300,8 +296,7 @@ describe('Sites: the API answers 403 where the SPA expected the owner', () => {
await chooseMenuItem('Delete…')
await click('Delete site', dialog()!)
expect(document.body.textContent).toContain('Only the owner can manage sites')
expect(document.body.textContent).toContain('Ask the owner of this site.')
expect(document.body.textContent).not.toContain('Only the owner can manage sites')
})
})
@@ -316,13 +311,45 @@ describe('Sites: the sidebar for the owner and for an admin', () => {
expect(link.getAttribute('aria-current')).toBe('page')
})
it('has no Publishing group and no Sites entry for an admin', async () => {
it('shows the Publishing group and the Sites entry for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).not.toBeNull()
})
it('has no Publishing group and no Sites entry for a writer', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'member' })
const nav = document.body.querySelector('nav[aria-label="Main"]')!
expect(textOf(nav)).not.toContain('Publishing')
expect(nav.querySelector('a[href="/settings/sites"]')).toBeNull()
})
it('disables Delete site with the Owner only tag for an admin', async () => {
await mountDirectory('sites', '/settings/sites', { role: 'admin' })
await openRowMenu('Archive')
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(candidate) => candidate.textContent?.includes('Delete…'),
)!
expect(item.getAttribute('aria-disabled')).toBe('true')
expect(item.textContent).toContain('Owner only')
})
it('enables Delete site with settings.delete_site', async () => {
await mountDirectory('sites', '/settings/sites', {
access: customAccess('settings.view, settings.manage_sites, settings.delete_site'),
})
await openRowMenu('Archive')
const item = Array.from(document.body.querySelectorAll('[role="menuitem"]')).find(
(candidate) => candidate.textContent?.includes('Delete…'),
)!
expect(item.getAttribute('aria-disabled')).not.toBe('true')
expect(item.textContent).not.toContain('Owner only')
})
})
void useApi
@@ -10,6 +10,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import UsersView from '../UsersView.vue'
import { accessFor, roleRefFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -58,7 +59,7 @@ function defaultFixture(): UserListItem[] {
id: 3,
username: 'ines',
email: 'ines.duarte@renew.example',
role: 'admin',
role: roleRefFor('admin'),
display_name: 'Inès Duarte',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
@@ -68,7 +69,7 @@ function defaultFixture(): UserListItem[] {
id: 2,
username: 'jonas',
email: 'jonas@renew.example',
role: 'admin',
role: roleRefFor('admin'),
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
@@ -78,7 +79,7 @@ function defaultFixture(): UserListItem[] {
id: 1,
username: 'marta',
email: 'marta@renew.example',
role: 'owner',
role: roleRefFor('owner'),
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
@@ -157,7 +158,7 @@ async function mountPage(
username: 'marta',
id: 1,
displayName: '',
role: sessionRole,
access: accessFor(sessionRole),
}
session.status = 'ready'
requestMock = vi.fn((path: string, options?: RequestInit) => {
@@ -180,7 +181,7 @@ async function mountPage(
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
access: accessFor(sessionRole),
}),
request: requestMock,
})
@@ -291,8 +292,9 @@ describe.each([
{ name: 'below md', desktop: false },
])('Non-owners see no row actions ($name)', ({ desktop }) => {
it.each([
{ role: 'admin', present: false },
{ role: 'member', present: false },
{ role: null, present: false },
{ role: 'admin', present: true },
{ role: 'owner', present: true },
])('role $role shows the actions: $present', async ({ role, present }) => {
await mountPage({}, desktop, role)
+17 -13
View File
@@ -7,6 +7,7 @@ import { ApiError, NetworkError, useApi } from '@/composables/useApi'
import type { CreatedInvite, UserListItem } from '@/composables/useUsers'
import { useSessionStore } from '@/stores/session'
import UsersView from '../UsersView.vue'
import { accessFor, roleRefFor } from '@/test/access'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
@@ -38,6 +39,7 @@ function defaultFixture(): UserListItem[] {
kind: 'invite',
id: 2,
email: 'sam.oliveira@renew.example',
role: roleRefFor('admin'),
status: 'pending',
created_at: '2026-09-27T09:00:00Z',
},
@@ -46,6 +48,7 @@ function defaultFixture(): UserListItem[] {
id: 1,
email: 'p.huber@gmail.com',
display_name: 'Paul Huber',
role: roleRefFor('admin'),
status: 'expired',
created_at: '2026-08-03T09:00:00Z',
},
@@ -54,7 +57,7 @@ function defaultFixture(): UserListItem[] {
id: 3,
username: 'ines',
email: 'ines.duarte@renew.example',
role: 'admin',
role: roleRefFor('admin'),
display_name: 'Inès Duarte',
status: 'accepted',
created_at: '2026-06-18T09:00:00Z',
@@ -64,7 +67,7 @@ function defaultFixture(): UserListItem[] {
id: 2,
username: 'jonas',
email: 'jonas@renew.example',
role: 'admin',
role: roleRefFor('admin'),
display_name: '',
status: 'accepted',
created_at: '2026-04-02T09:00:00Z',
@@ -74,7 +77,7 @@ function defaultFixture(): UserListItem[] {
id: 1,
username: 'marta',
email: 'marta@renew.example',
role: 'owner',
role: roleRefFor('owner'),
display_name: '',
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
@@ -91,6 +94,7 @@ function inviteFixture(email = VALID_EMAIL): CreatedInvite {
id: 3,
kind: 'invite',
email,
role: roleRefFor('admin'),
status: 'pending',
created_at: new Date().toISOString(),
expires_at: new Date(Date.now() + 3 * 24 * 60 * 60 * 1000).toISOString(),
@@ -139,7 +143,7 @@ async function mountPage(
username: 'marta',
id: 1,
displayName: '',
role: role ?? undefined,
access: accessFor(role ?? undefined),
}
session.status = 'ready'
requestMock = vi.fn((path: string) => {
@@ -156,7 +160,7 @@ async function mountPage(
verified: true,
username: 'marta',
id: 1,
role: role ?? undefined,
access: accessFor(role ?? undefined),
}),
request: requestMock,
})
@@ -368,7 +372,7 @@ describe('Display names in the Users list', () => {
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: 'owner',
access: accessFor('owner'),
}
await flushPromises()
@@ -436,10 +440,10 @@ describe('Only you — nobody has been invited yet', () => {
})
})
describe('Non-owners cannot invite', () => {
describe('Roles without users.invite cannot invite', () => {
it.each([
{ name: 'admin on desktop', role: 'admin', desktop: true },
{ name: 'admin below md', role: 'admin', desktop: false },
{ name: 'a writer on desktop', role: 'member', desktop: true },
{ name: 'a writer below md', role: 'member', desktop: false },
{ name: 'a missing role on desktop', role: null, desktop: true },
{ name: 'an unknown role below md', role: 'guest', desktop: false },
])('renders the list but no Invite user for $name', async ({ role, desktop }) => {
@@ -449,8 +453,8 @@ describe('Non-owners cannot invite', () => {
expect(rows()).toHaveLength(5)
})
it('hides the Only-you hint for an admin', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'admin')
it('hides the Only-you hint for a writer', async () => {
await mountPage({ list: () => Promise.resolve(onlyYouFixture()) }, true, 'member')
expect(rows()).toHaveLength(1)
expect(document.body.textContent).not.toContain('Invite someone so they can sign in')
@@ -458,12 +462,12 @@ describe('Non-owners cannot invite', () => {
})
it.each([true, false])(
'shows no hint for an admin on marta and jonas alone (desktop %s)',
'shows no hint for a writer on marta and jonas alone (desktop %s)',
async (desktop) => {
await mountPage(
{ list: () => Promise.resolve(defaultFixture().slice(3)) },
desktop,
'admin',
'member',
)
expect(rows()).toHaveLength(2)
+5 -8
View File
@@ -44,8 +44,7 @@ vi.mock('@nuxt/ui/composables', async (importOriginal) => {
return { ...actual, useToast: () => toasts }
})
const FORBIDDEN_TEXT =
'Only admins can manage image variants. Ask an admin if you need one changed.'
const FORBIDDEN_TEXT = "doesn't include it. Ask an owner or admin if you need it."
const FOOTER = '6 variants · 1,284 generated files · 212 MB'
const SERVER_TEXT =
"The server couldn't finish. Try again. If it keeps happening, check the server log."
@@ -84,9 +83,9 @@ function statusOf(name: string) {
}
function expectForbidden() {
expect(document.body.textContent).toContain("You don't have access")
expect(document.body.textContent).toContain("You don't have access to this page")
expect(document.body.textContent).toContain(FORBIDDEN_TEXT)
expect(findButton('Back to Home')!.disabled).toBe(false)
expect((findButton('Go to Home') ?? findButton('Go to Articles'))!.disabled).toBe(false)
expect(pageTitle()).toBeNull()
expect(document.body.querySelector('[role="tablist"]')).toBeNull()
expect(variantsTable()).toBeUndefined()
@@ -118,8 +117,6 @@ describe('Rule: Only admins and owners see the page', () => {
expectForbidden()
expect(calls(LIST)).toHaveLength(0)
expect(iconNames(document.body)).toContain('i-lucide-lock')
const back = findButton('Back to Home')!
expect(iconOf(back)).toBe('i-lucide-arrow-left')
})
it('A session whose role is not owner or admin gets the Forbidden state: owner answered 403', async () => {
@@ -131,10 +128,10 @@ describe('Rule: Only admins and owners see the page', () => {
expect(calls(LIST)).toHaveLength(1)
})
it('Back to Home leaves the page', async () => {
it('Go to Home leaves the page', async () => {
await mountPage({ role: null })
await click(findButton('Back to Home'))
await click(findButton('Go to Home'))
await vi.waitFor(() => expect(router.currentRoute.value.path).toBe('/home'))
})
@@ -0,0 +1,42 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import router from '@/router'
import { initializeLocale } from '@/i18n'
import { customAccess } from '@/test/access'
import { findButton, mountPage, stubBrowser, unmountPage } from './variantsTestKit'
vi.mock('@/composables/useApi', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/composables/useApi')>()
return { ...actual, useApi: vi.fn() }
})
beforeEach(async () => {
stubBrowser()
localStorage.clear()
initializeLocale()
await router.push('/login')
})
afterEach(async () => {
await unmountPage()
localStorage.clear()
initializeLocale()
})
describe('Variants, settings.edit_general', () => {
it('shows Add variant and no read-only banner with settings.edit_general', async () => {
await mountPage({ access: customAccess('settings.view, settings.edit_general') })
expect(document.body.querySelector('[data-testid="add-variant"]')).not.toBeNull()
expect(document.body.textContent).not.toContain(
'You can read these settings but not change them',
)
expect(findButton('Add variant')).toBeDefined()
})
it('hides Add variant and shows the read-only banner with settings.view alone', async () => {
await mountPage({ access: customAccess('settings.view') })
expect(document.body.querySelector('[data-testid="add-variant"]')).toBeNull()
expect(document.body.textContent).toContain('You can read these settings but not change them')
})
})
+4 -1
View File
@@ -5,6 +5,7 @@ import uiPlugin from '@nuxt/ui/vue-plugin'
import router from '@/router'
import { useSessionStore } from '@/stores/session'
import VerifyView from '../VerifyView.vue'
import { ownerAccess } from '@/test/access'
function mockResponse(status: number, body: unknown = null, headers: Record<string, string> = {}) {
return {
@@ -95,7 +96,8 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
it('shows the success panel, then navigates after 800ms once session.retry resolves', async () => {
const { wrapper } = await mountVerify('/register/validate?c=abc123', {
'POST /v1/auth/verify': () => mockResponse(200),
'GET /v1/auth/me': () => mockResponse(200, { is_verified: true }),
'GET /v1/auth/me': () =>
mockResponse(200, { is_verified: true, access: ownerAccess() }),
})
expect(wrapper.text()).toContain('Your account is verified')
@@ -116,6 +118,7 @@ describe('Success — 200 refreshes the session and navigates to /home with a to
const session = useSessionStore()
expect(session.session).toMatchObject({
verified: true,
access: ownerAccess(),
displayName: '',
})
})
+3 -2
View File
@@ -11,6 +11,7 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, MOBILE_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import ActivityView from '../ActivityView.vue'
import { activityItems, pageOf, summaryDefault } from './activityFixtures'
import { accessFor } from '@/test/access'
addCollection(lucide)
@@ -91,7 +92,7 @@ export async function mountActivity(options: MountOptions = {}) {
verified,
username: 'ines',
displayName: 'Inès Duarte',
role: role as 'owner' | undefined,
access: accessFor(role),
}
session.status = 'ready'
@@ -122,7 +123,7 @@ export async function mountActivity(options: MountOptions = {}) {
return respond(api.exportFile, url.searchParams, url.pathname) as Blob
})
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({ verified, role }),
getMe: vi.fn().mockResolvedValue({ verified, access: accessFor(role) }),
request,
requestBlob,
} as unknown as ReturnType<typeof useApi>)
+8 -3
View File
@@ -12,6 +12,8 @@ import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import AuthorsView from '../AuthorsView.vue'
import SitesView from '../SitesView.vue'
import { accessFor, ownerAccess, roleRefFor } from '@/test/access'
import type { Access } from '@/lib/permissions'
export interface Call {
method: string
@@ -155,7 +157,7 @@ export function usersFixture() {
username,
display_name: display,
email,
role,
role: roleRefFor(role),
status: 'accepted',
created_at: '2026-03-12T09:00:00Z',
})
@@ -329,6 +331,7 @@ export function pending<T>() {
export interface MountOptions {
role?: string
access?: Access
width?: number
backend?: Backend
author?: MeAuthor
@@ -357,7 +360,7 @@ export async function mountDirectory(
id: 1,
email: options.email ?? 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
access: options.access ?? accessFor(options.role ?? 'owner'),
...(options.author ? { author: options.author } : {}),
}
session.status = 'ready'
@@ -387,7 +390,9 @@ export async function mountDirectory(
return { body: result, total: (result as { total: number }).total }
})
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,
requestPaged,
} as unknown as ReturnType<typeof useApi>)
+5 -2
View File
@@ -10,6 +10,8 @@ import type { MediaItem } from '@/composables/useMedia'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import MediaView from '../MediaView.vue'
import type { Access } from '@/lib/permissions'
import { accessFor, ownerAccess } from '@/test/access'
type Row = [
number,
@@ -392,7 +394,7 @@ export async function mountPage(
handlers: Handlers = {},
url = '/media',
width = DESKTOP_WIDTH,
options: { realApi?: boolean; role?: string; app?: boolean } = {},
options: { realApi?: boolean; role?: string; access?: Access; app?: boolean } = {},
) {
setViewportWidth(width)
setActivePinia(createPinia())
@@ -402,7 +404,7 @@ export async function mountPage(
username: 'marta',
id: 1,
displayName: '',
role: (options.role ?? 'owner') as never,
access: options.access ?? accessFor(options.role ?? 'owner'),
}
session.status = 'ready'
const dispatch = dispatcher(handlers)
@@ -414,6 +416,7 @@ export async function mountPage(
vi.mocked(useApi).mockReturnValue({
getMe: vi.fn().mockResolvedValue({
verified: true,
access: ownerAccess(),
username: 'marta',
id: 1,
}),
+3 -2
View File
@@ -31,6 +31,7 @@ export {
toasts,
pending,
} from './directoryTestKit'
import { accessFor } from '@/test/access'
export { failWith, networkFailure } from './directoryTestKit'
export type MetaRole = 'owner' | 'admin' | 'verified'
@@ -124,7 +125,7 @@ export function createMetaBackend(
username: 'marta',
email: MARTA_EMAIL,
displayName: 'Marta Alves',
role: options.role === 'verified' ? undefined : (options.role ?? 'owner'),
access: accessFor(options.role ?? 'owner'),
...(options.author ? { author: options.author } : {}),
},
}
@@ -289,7 +290,7 @@ export async function mountMeta(
id: backend.me.id,
email: backend.me.email,
displayName: backend.me.displayName,
role: backend.me.role,
access: backend.me.access,
...(backend.me.author ? { author: backend.me.author } : {}),
}
session.status = 'ready'
+4 -5
View File
@@ -8,6 +8,7 @@ import type { PurgePreview, PurgeResult, PurgeSettings } from '@/composables/use
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import SettingsView from '../SettingsView.vue'
import { accessFor } from '@/test/access'
export const SETTINGS_PATH = '/v1/media/purge-settings'
export const PREVIEW_PATH = '/v1/media/purge-preview'
@@ -196,9 +197,7 @@ function fetchFrom(request: (path: string, init?: RequestInit) => Promise<unknow
export async function mountSettings(
mocks: PurgeMocks = {},
options: { role?: string | undefined; realApi?: boolean } = {
role: 'owner',
},
options: { role?: string | undefined; realApi?: boolean } = { role: 'owner' },
) {
setViewportWidth(DESKTOP_WIDTH)
setActivePinia(createPinia())
@@ -209,7 +208,7 @@ export async function mountSettings(
username: 'marta',
id: 1,
displayName: '',
role: options.role as never,
access: accessFor(options.role),
}
session.status = 'ready'
const dispatch = installPurgeApi(mocks)
@@ -227,7 +226,7 @@ export async function mountSettings(
username: 'marta',
id: 1,
displayName: '',
role: purgeKit.meRole,
access: accessFor(purgeKit.meRole),
}))
if (options.realApi) {
const actual =
+9 -2
View File
@@ -12,6 +12,8 @@ import { useContentTypesStore } from '@/stores/contentTypes'
import { useSessionStore } from '@/stores/session'
import { DESKTOP_WIDTH, setViewportWidth } from '@/components/__tests__/viewport'
import { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
import type { Access } from '@/lib/permissions'
import { accessFor, ownerAccess } from '@/test/access'
export { apiField, eventsFields, recipesFields } from '@/lib/__tests__/schemaFixtures'
@@ -167,6 +169,7 @@ export interface SchemaMount {
entry?: (key: string, id: number) => unknown
writeEntry?: (write: EntryWrite) => unknown
role?: string
access?: Access
create?: (body: PostedRequest['body']) => unknown
update?: (body: PostedRequest['body'], key: string) => unknown
entries?: (key: string) => unknown
@@ -221,7 +224,7 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
id: 1,
email: 'marta@renew.example',
displayName: 'Marta Alves',
role: (options.role ?? 'owner') as never,
access: options.access ?? accessFor(options.role ?? 'owner'),
}
session.status = options.status ?? 'ready'
const request = vi.fn(async (url: string, init?: RequestInit) => {
@@ -289,7 +292,11 @@ export async function mountAt(path: string, options: SchemaMount = {}, shellOnly
getMe:
options.status === 'checking'
? vi.fn(() => new Promise(() => undefined))
: vi.fn().mockResolvedValue({ verified: true, username: 'marta' }),
: vi.fn().mockResolvedValue({
verified: true,
access: ownerAccess(),
username: 'marta',
}),
request,
} as unknown as ReturnType<typeof useApi>)
await router.push(path)
+5 -2
View File
@@ -9,6 +9,8 @@ import { useApi } from '@/composables/useApi'
import type { ImageVariant } from '@/composables/useImageVariants'
import { useSessionStore } from '@/stores/session'
import VariantsPage from '../VariantsPage.vue'
import { accessFor } from '@/test/access'
import type { Access } from '@/lib/permissions'
export const MB = 1048576
export const LIST = 'GET /v1/image-variants'
@@ -19,6 +21,7 @@ export type Handler = (body?: Record<string, unknown>) => Promise<unknown>
export interface MountOptions {
desktop?: boolean
role?: string | null
access?: Access
routes?: Record<string, Handler>
}
@@ -127,7 +130,7 @@ export async function mountPage(options: MountOptions = {}) {
username: 'marta',
id: 1,
displayName: 'Marta Alves',
role: sessionRole as 'owner' | 'admin' | undefined,
access: options.access ?? accessFor(sessionRole),
}
session.status = 'ready'
requestMock = vi.fn((path: string, init?: RequestInit) => {
@@ -147,7 +150,7 @@ export async function mountPage(options: MountOptions = {}) {
verified: true,
username: 'marta',
id: 1,
role: sessionRole,
access: options.access ?? accessFor(sessionRole),
}),
request: requestMock,
})