feat: roles SPA foundations
This commit is contained in:
1 parent
80f7037d3a
commit
907949aa12
180 files changed
+4308
-835
No files matched your search
@@ -40,6 +40,7 @@ const {
|
||||
people,
|
||||
chips,
|
||||
isFullView,
|
||||
canExport,
|
||||
isEmpty,
|
||||
exportDisabled,
|
||||
showListSkeleton,
|
||||
@@ -141,7 +142,7 @@ function goHome() {
|
||||
<p class="text-muted">{{ subtitle }}</p>
|
||||
</div>
|
||||
<ActivityExportMenu
|
||||
v-if="isFullView && !forbidden"
|
||||
v-if="canExport && !forbidden"
|
||||
:total="total"
|
||||
:is-desktop="isDesktop"
|
||||
:disabled="exportDisabled || exportFlow.currentBusy.value"
|
||||
|
||||
@@ -15,6 +15,7 @@ import { useApiKeys, type ApiKey, type CreatedApiKey } from '@/composables/useAp
|
||||
import { toastsAboveBulkBar } from '@/composables/useToastOffset'
|
||||
import { displayPrefix } from '@/lib/apiKeys'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const AUTH_HANDLED_STATUSES = [401, 403]
|
||||
@@ -27,6 +28,7 @@ type LoadState = 'loading' | 'ready' | 'error'
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const { listApiKeys, revokeApiKey } = useApiKeys()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
@@ -37,7 +39,7 @@ const revealed = ref<{ name: string; key: string } | null>(null)
|
||||
const revokeDialogOpen = ref(false)
|
||||
let opener: HTMLElement | null = null
|
||||
|
||||
const isOwner = computed(() => session.session?.role === 'owner')
|
||||
const seesEveryKey = computed(() => permissions.scopeOf('apikeys.view') === 'all')
|
||||
const isEmpty = computed(() => loadState.value === 'ready' && keys.value.length === 0)
|
||||
const showList = computed(
|
||||
() => loadState.value === 'loading' || (loadState.value === 'ready' && keys.value.length > 0),
|
||||
@@ -47,7 +49,7 @@ const anyDialogOpen = computed(
|
||||
() => createOpen.value || revealed.value !== null || revokeDialogOpen.value,
|
||||
)
|
||||
const description = computed(() =>
|
||||
isOwner.value
|
||||
seesEveryKey.value
|
||||
? t("Keys act as the user who created them. As an owner you see everyone's keys.")
|
||||
: t('Keys act as you, with your role. You only see your own keys.'),
|
||||
)
|
||||
@@ -329,14 +331,14 @@ onMounted(loadKeys)
|
||||
v-if="isDesktop"
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="isOwner"
|
||||
:show-created-by="seesEveryKey"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
<ApiKeyCardList
|
||||
v-else
|
||||
:keys="keys"
|
||||
:loading="loadState === 'loading'"
|
||||
:show-created-by="isOwner"
|
||||
:show-created-by="seesEveryKey"
|
||||
@revoke="confirmRevoke"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -14,12 +14,22 @@ import AppPaginator from '@/components/AppPaginator.vue'
|
||||
import { useArticleList } from '@/composables/useArticleList'
|
||||
import { useArticleRowActions } from '@/composables/useArticleRowActions'
|
||||
import { useArticleSites } from '@/composables/useArticleSites'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { effectiveSort } from '@/lib/articleListQuery'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
|
||||
const DEFAULT_SKELETON_ROWS = 6
|
||||
|
||||
const router = useRouter()
|
||||
const access = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const writerLine = computed(() =>
|
||||
access.canCreate.value &&
|
||||
access.updateScope.value === 'all' &&
|
||||
access.publishScope.value === 'none'
|
||||
? t('You can write and edit all articles. Publishing is done by an editor.')
|
||||
: t("Everything you've written, online and in draft."),
|
||||
)
|
||||
|
||||
const list = useArticleList({
|
||||
sitesSettled: () => siteList.sitesSettled.value,
|
||||
@@ -132,11 +142,10 @@ onMounted(() => {
|
||||
>
|
||||
{{ t('Articles') }}
|
||||
</h1>
|
||||
<p class="text-muted">
|
||||
{{ t("Everything you've written, online and in draft.") }}
|
||||
</p>
|
||||
<p class="text-muted">{{ writerLine }}</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="hidden h-8 px-3 sm:flex"
|
||||
@click="openNewArticleModal"
|
||||
@@ -214,6 +223,7 @@ onMounted(() => {
|
||||
</div>
|
||||
|
||||
<UButton
|
||||
v-if="access.canCreate.value"
|
||||
icon="i-lucide-plus"
|
||||
class="fixed inset-x-4 bottom-4 h-12 justify-center text-[0.9375rem] shadow-(--shadow-popover) sm:hidden"
|
||||
@click="openNewArticleModal"
|
||||
|
||||
@@ -30,6 +30,7 @@ import { copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const LIST_COLUMNS = 'minmax(0,1.4fr) minmax(0,1fr) 110px 100px 44px'
|
||||
@@ -44,6 +45,7 @@ type PanelTarget =
|
||||
|
||||
const route = useRoute()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toasts = useDirectoryToasts()
|
||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||
@@ -65,7 +67,9 @@ const linkTarget = ref<Author | null>(null)
|
||||
const deleteOptions = ref<ReassignOption[]>([])
|
||||
const destination = ref<string | number | undefined>(undefined)
|
||||
|
||||
const canManage = computed(() => session.canManageContent)
|
||||
const canManage = computed(() => permissions.scopeOf('authors.edit') === 'all')
|
||||
const canAdd = computed(() => permissions.can('authors.create'))
|
||||
const canDelete = computed(() => permissions.can('authors.delete'))
|
||||
const firstRun = computed(
|
||||
() => loadState.value === 'ready' && total.value === 0 && shown.value.q === '',
|
||||
)
|
||||
@@ -86,7 +90,7 @@ const overlayOpen = computed(
|
||||
const showPinnedButton = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
canManage.value &&
|
||||
canAdd.value &&
|
||||
!overlayOpen.value &&
|
||||
loadState.value === 'ready' &&
|
||||
!firstRun.value,
|
||||
@@ -411,7 +415,7 @@ function menuItems(author: Author): DirectoryMenuItem[] {
|
||||
onSelect: (trigger) => openEdit(author, trigger),
|
||||
},
|
||||
]
|
||||
if (!author.is_linked) {
|
||||
if (canManage.value && !author.is_linked) {
|
||||
items.push({
|
||||
key: 'link',
|
||||
label: t('Link user'),
|
||||
@@ -419,6 +423,9 @@ function menuItems(author: Author): DirectoryMenuItem[] {
|
||||
onSelect: (trigger) => openLink(author, trigger),
|
||||
})
|
||||
}
|
||||
if (!canDelete.value) {
|
||||
return items
|
||||
}
|
||||
items.push({
|
||||
key: 'delete',
|
||||
label: t('Delete…'),
|
||||
@@ -443,7 +450,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
|
||||
</h1>
|
||||
<p class="text-muted">{{ description }}</p>
|
||||
</div>
|
||||
<DirectoryAddButton v-if="isDesktop && canManage" @click="openAdd">
|
||||
<DirectoryAddButton v-if="isDesktop && canAdd" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</DirectoryAddButton>
|
||||
</div>
|
||||
@@ -486,7 +493,7 @@ const noResultsTitle = computed(() => t('No authors match "{0}"', [shown.value.q
|
||||
description: 'max-w-85 text-pretty text-muted',
|
||||
}"
|
||||
>
|
||||
<template v-if="canManage" #actions>
|
||||
<template v-if="canAdd" #actions>
|
||||
<UButton icon="i-lucide-plus" class="h-11 px-4 md:h-8" @click="openAdd">
|
||||
{{ t('Add author') }}
|
||||
</UButton>
|
||||
|
||||
@@ -3,7 +3,6 @@ import { formatNumber, t } from '@/i18n'
|
||||
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useLocalizedOverlay, useLocalizedToast } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
@@ -13,7 +12,8 @@ import CacheSkeleton from '@/components/variants/CacheSkeleton.vue'
|
||||
import CacheStatTile from '@/components/variants/CacheStatTile.vue'
|
||||
import CacheTopImagesCard from '@/components/variants/CacheTopImagesCard.vue'
|
||||
import TypeToConfirmDialog from '@/components/variants/TypeToConfirmDialog.vue'
|
||||
import VariantsForbidden from '@/components/variants/VariantsForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import ReadOnlyBanner from '@/components/ReadOnlyBanner.vue'
|
||||
import VariantsTabs from '@/components/variants/VariantsTabs.vue'
|
||||
import type {
|
||||
CacheImageStat,
|
||||
@@ -25,6 +25,7 @@ import { cacheSize } from '@/lib/variants/cacheFormat'
|
||||
import { isHandledByApiClient, isStatus } from '@/lib/variants/variantErrors'
|
||||
import { formatVariantSize } from '@/lib/variants/variantFormat'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useVariantCacheStore } from '@/stores/variantCache'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 40rem)'
|
||||
@@ -32,8 +33,8 @@ const SKELETON_DELAY_MS = 150
|
||||
const NOT_FOUND = 404
|
||||
const CONFIRM_WORD = 'purge'
|
||||
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const store = useVariantCacheStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toast = useLocalizedToast()
|
||||
@@ -47,9 +48,8 @@ let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let requested = false
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const canManage = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const canManage = computed(() => permissions.can('settings.view'))
|
||||
const canEdit = computed(() => permissions.can('settings.edit_general'))
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !canManage.value) || store.loadState === 'forbidden',
|
||||
)
|
||||
@@ -57,10 +57,15 @@ const stats = computed(() => store.stats)
|
||||
const ready = computed(() => store.loadState === 'ready' && stats.value !== null)
|
||||
const dimmed = computed(() => store.isPurging)
|
||||
const purgeUnusedDisabled = computed(
|
||||
() => !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
|
||||
() =>
|
||||
!canEdit.value || !ready.value || store.isPurging || (stats.value?.unused.files ?? 0) === 0,
|
||||
)
|
||||
const purgeAllDisabled = computed(
|
||||
() => !ready.value || store.isPurging || (stats.value?.generated_files ?? 0) === 0,
|
||||
() =>
|
||||
!canEdit.value ||
|
||||
!ready.value ||
|
||||
store.isPurging ||
|
||||
(stats.value?.generated_files ?? 0) === 0,
|
||||
)
|
||||
|
||||
function clearSkeletonTimer() {
|
||||
@@ -295,7 +300,7 @@ function onPurgeImage(item: CacheImageStat) {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<VariantsForbidden v-if="forbidden" @home="router.push('/home')" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-3 sm:flex-row sm:items-start sm:gap-4">
|
||||
@@ -339,6 +344,14 @@ function onPurgeImage(item: CacheImageStat) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ReadOnlyBanner
|
||||
v-if="!canEdit"
|
||||
:title="t('You can read these settings but not change them')"
|
||||
:description="
|
||||
t('Ask the owner to change them, or ask for a role that can edit settings.')
|
||||
"
|
||||
/>
|
||||
|
||||
<VariantsTabs active="cache" />
|
||||
|
||||
<CacheSkeleton
|
||||
|
||||
@@ -7,16 +7,18 @@ import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import { useContentListPage } from '@/composables/useContentListPage'
|
||||
import { useContentTypeDetail } from '@/composables/useContentTypeDetail'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const props = defineProps<{ typeKey: string }>()
|
||||
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
|
||||
const detail = useContentTypeDetail(
|
||||
() => props.typeKey,
|
||||
() => session.status === 'ready',
|
||||
)
|
||||
const page = useContentListPage(props.typeKey, detail, () => session.canManageContent)
|
||||
const page = useContentListPage(props.typeKey, detail, () => permissions.can('schema.edit'))
|
||||
|
||||
const notFound = computed(() => page.view.value === 'notFound')
|
||||
const centered = computed(() => page.centered.value)
|
||||
|
||||
@@ -24,11 +24,13 @@ import { ApiError, useApi } from '@/composables/useApi'
|
||||
import type { Article } from '@/composables/useArticles'
|
||||
import { useArticleDraft, type SaveOutcome } from '@/composables/useArticleDraft'
|
||||
import { useArticleLifecycle } from '@/composables/useArticleLifecycle'
|
||||
import { useContentAccess } from '@/composables/useContentAccess'
|
||||
import { useEditorFocus } from '@/composables/useEditorFocus'
|
||||
import { useEditorImages } from '@/composables/useEditorImages'
|
||||
import { useEditorSeoPanel } from '@/composables/useEditorSeoPanel'
|
||||
import { useSeoDismissals } from '@/composables/useSeoDismissals'
|
||||
import { useUnsavedChangesGuard } from '@/composables/useUnsavedChangesGuard'
|
||||
import { ARTICLE_TYPE_KEY } from '@/lib/routeAccess'
|
||||
import { useDirectoryToasts } from '@/composables/useDirectoryToasts'
|
||||
import { useSites, type Site } from '@/composables/useSites'
|
||||
import { toBylines } from '@/lib/articleMeta'
|
||||
@@ -232,7 +234,15 @@ const overflowItems = computed(() => [
|
||||
],
|
||||
])
|
||||
|
||||
const saveEnabled = computed(() => draft.state.value === 'dirty' && loadState.value === 'loaded')
|
||||
const contentAccess = useContentAccess(ARTICLE_TYPE_KEY, 'articles')
|
||||
const articleAccess = computed(() => contentAccess.rowAccess(article.value?.created_by))
|
||||
const canPublish = computed(() => contentAccess.canPublish(article.value?.created_by))
|
||||
const saveEnabled = computed(
|
||||
() =>
|
||||
draft.state.value === 'dirty' &&
|
||||
loadState.value === 'loaded' &&
|
||||
articleAccess.value.canEdit,
|
||||
)
|
||||
|
||||
const publishDisabled = computed(() => draft.state.value === 'offline')
|
||||
|
||||
@@ -426,6 +436,9 @@ useUnsavedChangesGuard({
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
:can-delete="articleAccess.canDelete"
|
||||
:delete-reason="articleAccess.deleteReason"
|
||||
@back="goBack"
|
||||
@retry="retrySave"
|
||||
@save="performSave"
|
||||
@@ -526,6 +539,7 @@ useUnsavedChangesGuard({
|
||||
:save-enabled="saveEnabled"
|
||||
:publish-pending="publishPending"
|
||||
:publish-disabled="publishDisabled"
|
||||
:can-publish="canPublish"
|
||||
@details="detailsOpen = true"
|
||||
@save="performSave"
|
||||
@publish="handlePublish"
|
||||
|
||||
@@ -11,6 +11,7 @@ import RecentActivityCard from '@/components/RecentActivityCard.vue'
|
||||
import UpdateBanner from '@/components/UpdateBanner.vue'
|
||||
import { useArticles, type Article, type ArticleCounts } from '@/composables/useArticles'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUpdateStore } from '@/stores/update'
|
||||
import { accountName } from '@/lib/accountName'
|
||||
import { formatRelativeDate } from '@/lib/relativeDate'
|
||||
@@ -24,6 +25,7 @@ type LoadState = 'loading' | 'default' | 'empty' | 'error'
|
||||
const router = useRouter()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const update = useUpdateStore()
|
||||
const { listArticles } = useArticles()
|
||||
|
||||
@@ -153,9 +155,9 @@ function dismissBanner() {
|
||||
}
|
||||
|
||||
watch(
|
||||
() => update.isOwner,
|
||||
(isOwner) => {
|
||||
if (isOwner) {
|
||||
() => update.canApply,
|
||||
(canApply) => {
|
||||
if (canApply) {
|
||||
update.load()
|
||||
}
|
||||
},
|
||||
@@ -337,7 +339,7 @@ onMounted(loadArticles)
|
||||
</template>
|
||||
</UAlert>
|
||||
|
||||
<RecentActivityCard />
|
||||
<RecentActivityCard v-if="permissions.scopeOf('activity.view') !== 'none'" />
|
||||
|
||||
<div class="flex items-start gap-3.5 border border-default bg-default p-4">
|
||||
<div
|
||||
|
||||
@@ -50,7 +50,7 @@ import { MEDIA_PAGE_SIZE_KEY, storePageSize } from '@/lib/pageSizeMemory'
|
||||
import { pageCountFor } from '@/lib/pagination'
|
||||
import { purgedToast } from '@/lib/mediaPurge'
|
||||
import { RATE_LIMITED_TEXT, RATE_LIMITED_TITLE, UPLOAD_ACCEPT } from '@/lib/mediaUploadRows'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const MEDIA_PATH = '/media'
|
||||
@@ -80,7 +80,7 @@ const OFFLINE_COPY =
|
||||
|
||||
const route = useRoute()
|
||||
const toast = useLocalizedToast()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const media = useMedia()
|
||||
const selection = useMediaSelection(media.items)
|
||||
const upload = useMediaUpload({ onAdded: refreshInBackground })
|
||||
@@ -233,14 +233,13 @@ const filterLabel = computed(
|
||||
],
|
||||
)
|
||||
const showList = computed(() => ready.value && !nothingToShow.value && !emptyLibrary.value)
|
||||
const canPurge = computed(
|
||||
() => session.session?.role === 'owner' || session.session?.role === 'admin',
|
||||
)
|
||||
const canUpload = computed(() => permissions.can('media.upload'))
|
||||
const canDeleteMedia = computed(() => permissions.scopeOf('media.delete') === 'all')
|
||||
const showPurge = computed(
|
||||
() => canPurge.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
() => canDeleteMedia.value && isDuplicates.value && showList.value && groups.value.length > 0,
|
||||
)
|
||||
const dragging = computed(() => dragDepth.value > 0)
|
||||
const uploadBlocked = computed(() => offline.value || upload.limited.value)
|
||||
const uploadBlocked = computed(() => !canUpload.value || offline.value || upload.limited.value)
|
||||
const uploadLabel = computed(() =>
|
||||
upload.limited.value ? t('Try again in {0}', [upload.countdown.value]) : t('Upload'),
|
||||
)
|
||||
@@ -258,6 +257,7 @@ const hasSelection = computed(() => selection.count.value > 0)
|
||||
const showFab = computed(
|
||||
() =>
|
||||
!isDesktop.value &&
|
||||
canUpload.value &&
|
||||
!dialogOpen.value &&
|
||||
!hasSelection.value &&
|
||||
!dragging.value &&
|
||||
@@ -922,7 +922,7 @@ async function mergeAction() {
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="isDesktop"
|
||||
v-if="isDesktop && canUpload"
|
||||
:icon="uploadIcon"
|
||||
:disabled="uploadBlocked"
|
||||
class="h-8 px-3"
|
||||
@@ -946,6 +946,7 @@ async function mergeAction() {
|
||||
/>
|
||||
<MediaEmptyState
|
||||
variant="first-run"
|
||||
:read-only="!canUpload"
|
||||
:offline="offline"
|
||||
:limited="upload.limited.value"
|
||||
@action="openChooser"
|
||||
@@ -1060,6 +1061,7 @@ async function mergeAction() {
|
||||
</template>
|
||||
<template v-else>
|
||||
<MediaSelectAllRow
|
||||
v-if="canDeleteMedia"
|
||||
:state="selection.allState.value"
|
||||
:count="media.items.value.length"
|
||||
@toggle="toggleAllSelected"
|
||||
|
||||
@@ -2,17 +2,15 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const { sessionReady, forbidden } = useSchemaGate('custom')
|
||||
|
||||
const mobileTitle = computed(() => t('New content type'))
|
||||
@@ -20,10 +18,6 @@ const breadcrumb = computed(() => [
|
||||
{ label: t('Schema'), to: SCHEMA_PATH },
|
||||
{ label: t('New content type') },
|
||||
])
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -45,7 +39,7 @@ function goHome() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
<SchemaBuilderPage v-else-if="sessionReady" />
|
||||
</DashboardShell>
|
||||
</template>
|
||||
@@ -2,27 +2,26 @@
|
||||
import { t } from '@/i18n'
|
||||
|
||||
import { computed, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryBadge from '@/components/DirectoryBadge.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaBuilderPage from '@/components/SchemaBuilderPage.vue'
|
||||
import SchemaFieldTable from '@/components/SchemaFieldTable.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import SchemaTypeNotFound from '@/components/SchemaTypeNotFound.vue'
|
||||
import { useDelayedSkeleton } from '@/composables/useDelayedSkeleton'
|
||||
import { useEntryCount } from '@/composables/useEntryCount'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { resolveSchemaTypeView } from '@/lib/schemaTypeView'
|
||||
import { HOME_PATH, SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { SCHEMA_PATH } from '@/lib/routePaths'
|
||||
import { TOPBAR_ACTION_ID } from '@/lib/uiSlots'
|
||||
|
||||
const route = useRoute()
|
||||
const offlineMessage = computed(() =>
|
||||
t("You're offline. Changes will be saved when you reconnect."),
|
||||
)
|
||||
const router = useRouter()
|
||||
const skeleton = useDelayedSkeleton()
|
||||
|
||||
const {
|
||||
@@ -89,10 +88,6 @@ watch(
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
function goHome() {
|
||||
router.push(HOME_PATH)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -119,7 +114,7 @@ function goHome() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SchemaForbidden v-if="view === 'forbidden'" @back="goHome" />
|
||||
<NoAccessState v-if="view === 'forbidden'" />
|
||||
|
||||
<template v-else-if="sessionReady">
|
||||
<SchemaTable v-if="view === 'skeleton'" :custom="[]" :builtin="[]" loading />
|
||||
|
||||
@@ -3,12 +3,13 @@ import { t } from '@/i18n'
|
||||
|
||||
import { computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import DashboardShell from '@/components/DashboardShell.vue'
|
||||
import DirectoryAddButton from '@/components/DirectoryAddButton.vue'
|
||||
import DirectoryLoadError from '@/components/DirectoryLoadError.vue'
|
||||
import SchemaEmpty from '@/components/SchemaEmpty.vue'
|
||||
import SchemaForbidden from '@/components/SchemaForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import SchemaTable from '@/components/SchemaTable.vue'
|
||||
import { useSchemaGate } from '@/composables/useSchemaGate'
|
||||
import { builtinRow, customRow, schemaSummary } from '@/lib/schemaRows'
|
||||
@@ -17,6 +18,7 @@ const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const NEW_TYPE_PATH = '/schema/new'
|
||||
|
||||
const router = useRouter()
|
||||
const permissions = usePermissionsStore()
|
||||
const isDesktop = useMediaQuery(DESKTOP_QUERY)
|
||||
|
||||
const {
|
||||
@@ -40,18 +42,16 @@ const customRows = computed(() => customItems.value.map(customRow))
|
||||
const builtinRows = computed(() => builtinItems.value.map(builtinRow))
|
||||
const summary = computed(() => schemaSummary(customItems.value.length, builtinItems.value.length))
|
||||
|
||||
const canEdit = computed(() => permissions.can('schema.edit'))
|
||||
|
||||
function createType() {
|
||||
router.push(NEW_TYPE_PATH)
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
router.push('/home')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardShell compact-padding :full-bleed="forbidden" :mobile-title="t('Schema')">
|
||||
<SchemaForbidden v-if="forbidden" @back="goHome" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
@@ -64,6 +64,7 @@ function goHome() {
|
||||
</p>
|
||||
</div>
|
||||
<UButton
|
||||
v-if="canEdit"
|
||||
icon="i-lucide-plus"
|
||||
class="h-8 px-3.5 max-md:h-11"
|
||||
:disabled="loading"
|
||||
@@ -95,7 +96,7 @@ function goHome() {
|
||||
</div>
|
||||
|
||||
<DirectoryAddButton
|
||||
v-if="!isDesktop && !forbidden && populated && !empty"
|
||||
v-if="!isDesktop && !forbidden && canEdit && populated && !empty"
|
||||
pinned
|
||||
@click="createType"
|
||||
>
|
||||
|
||||
@@ -18,6 +18,7 @@ import type { RestartKind, UpdateStatus } from '@/composables/useUpdate'
|
||||
import { reloadPage } from '@/lib/reloadPage'
|
||||
import { displayVersion, isCheckDue } from '@/lib/updateFormat'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
import { useUpdateStore, type RestartOutcome, type RestartRequestResult } from '@/stores/update'
|
||||
|
||||
const SKELETON_DELAY_MS = 150
|
||||
@@ -50,6 +51,7 @@ const { getSettings } = useSettings()
|
||||
const toast = useLocalizedToast()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const update = useUpdateStore()
|
||||
const retry = useRetryAfter()
|
||||
|
||||
@@ -57,7 +59,8 @@ const updatesCard = ref<InstanceType<typeof UpdatesCard> | null>(null)
|
||||
const loadState = ref<LoadState>('loading')
|
||||
const settings = ref<WorkspaceSettings | null>(null)
|
||||
const version = computed(() => settings.value?.version ?? '')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const canApply = computed(() => update.canApply)
|
||||
const canManageSites = computed(() => permissions.can('settings.manage_sites'))
|
||||
const showSkeleton = ref(false)
|
||||
let skeletonTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
@@ -115,7 +118,7 @@ const updatesStatus = computed(() => {
|
||||
if (update.status) {
|
||||
return update.status
|
||||
}
|
||||
return update.loadState === 'error' && update.isOwner ? neverCheckedStatus() : null
|
||||
return update.loadState === 'error' && update.canApply ? neverCheckedStatus() : null
|
||||
})
|
||||
|
||||
const updatesPhase = computed(() => (update.status ? update.phase : 'failed'))
|
||||
@@ -324,7 +327,7 @@ watch(
|
||||
}
|
||||
autoCheckHandled = true
|
||||
const jobActive = update.jobRunning || update.readyToInstall
|
||||
if (update.isOwner && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
if (update.canApply && !jobActive && isCheckDue(update.status?.checked_at ?? null)) {
|
||||
onCheck()
|
||||
}
|
||||
},
|
||||
@@ -454,7 +457,7 @@ onBeforeUnmount(() => {
|
||||
v-if="updatesStatus"
|
||||
ref="updatesCard"
|
||||
:status="updatesStatus"
|
||||
:role="session.session?.role"
|
||||
:can-apply="canApply"
|
||||
:phase="updatesPhase"
|
||||
:apply-phase="update.applyPhase"
|
||||
:retry-label="retry.formatted.value"
|
||||
@@ -466,12 +469,11 @@ onBeforeUnmount(() => {
|
||||
|
||||
<MediaLinksCard />
|
||||
|
||||
<PublishingLinksCard v-if="isOwner" />
|
||||
<PublishingLinksCard v-if="canManageSites" />
|
||||
|
||||
<MediaCleanupCard />
|
||||
|
||||
<RetentionCard
|
||||
v-if="isOwner"
|
||||
:settings="settings"
|
||||
:load-state="loadState"
|
||||
:show-skeleton="showSkeleton"
|
||||
|
||||
+21
-15
@@ -2,7 +2,7 @@
|
||||
import { t, formatNumber } from '@/i18n'
|
||||
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useMediaQuery } from '@vueuse/core'
|
||||
import { useLocalizedOverlay } from '@/i18n/presentation'
|
||||
import ConfirmDialog from '@/components/ConfirmDialog.vue'
|
||||
@@ -16,7 +16,7 @@ import DirectoryPageNotice from '@/components/DirectoryPageNotice.vue'
|
||||
import DirectoryPagination from '@/components/DirectoryPagination.vue'
|
||||
import ReassignDialog, { type ReassignOption } from '@/components/ReassignDialog.vue'
|
||||
import SiteFormPanel from '@/components/SiteFormPanel.vue'
|
||||
import SitesForbidden from '@/components/SitesForbidden.vue'
|
||||
import NoAccessState from '@/components/NoAccessState.vue'
|
||||
import { useDirectoryDelete } from '@/composables/useDirectoryDelete'
|
||||
import { useDirectoryListState } from '@/composables/useDirectoryListState'
|
||||
import { useDirectoryPages } from '@/composables/useDirectoryPages'
|
||||
@@ -28,6 +28,7 @@ import { COPY, copy } from '@/lib/directoryCopy'
|
||||
import { classifyFailure } from '@/lib/directoryErrors'
|
||||
import type { DirectoryMenuItem } from '@/lib/directoryMenu'
|
||||
import { useSessionStore } from '@/stores/session'
|
||||
import { usePermissionsStore } from '@/stores/permissions'
|
||||
|
||||
const DESKTOP_QUERY = '(min-width: 48rem)'
|
||||
const LIST_COLUMNS = 'minmax(0,1.1fr) minmax(0,1.5fr) 110px 44px'
|
||||
@@ -36,8 +37,8 @@ const INITIAL_SKELETON_ROWS = 3
|
||||
type PanelTarget = { mode: 'add' } | { mode: 'edit'; site: Site }
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const session = useSessionStore()
|
||||
const permissions = usePermissionsStore()
|
||||
const overlay = useLocalizedOverlay()
|
||||
const toasts = useDirectoryToasts()
|
||||
const { rememberOpener, restoreFocus, leavePanel, followEditLink } = useDirectoryView()
|
||||
@@ -46,10 +47,11 @@ const { listAllSites, listSitePage, setDefaultSite, deleteSite } = useSites()
|
||||
const removal = useDirectoryDelete<Site>()
|
||||
|
||||
const sessionReady = computed(() => session.status === 'ready')
|
||||
const isOwner = computed(() => session.isOwner)
|
||||
const canManageSites = computed(() => permissions.can('settings.manage_sites'))
|
||||
const canDeleteSite = computed(() => permissions.can('settings.delete_site'))
|
||||
const list = useDirectoryPages<Site>({
|
||||
loadPage: listSitePage,
|
||||
ready: () => sessionReady.value && isOwner.value,
|
||||
ready: () => sessionReady.value && canManageSites.value,
|
||||
onLoaded: () => followLink(),
|
||||
})
|
||||
const { items: sites, total, shown, notice, searchInput, pageCount } = list
|
||||
@@ -60,7 +62,7 @@ const panel = ref<PanelTarget | null>(null)
|
||||
const destinationId = ref<number | undefined>(undefined)
|
||||
|
||||
const forbidden = computed(
|
||||
() => (sessionReady.value && !isOwner.value) || loadState.value === 'forbidden',
|
||||
() => (sessionReady.value && !canManageSites.value) || loadState.value === 'forbidden',
|
||||
)
|
||||
const { noResults, showList, skeletonRows, showPaginator, singlePageLabel } = useDirectoryListState(
|
||||
{
|
||||
@@ -173,14 +175,14 @@ async function loadAllSites() {
|
||||
watch(
|
||||
() => route.query.edit,
|
||||
() => {
|
||||
if (loadState.value === 'ready' && isOwner.value) {
|
||||
if (loadState.value === 'ready' && canManageSites.value) {
|
||||
followLink()
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
function followLink() {
|
||||
if (!isOwner.value) {
|
||||
if (!canManageSites.value) {
|
||||
return
|
||||
}
|
||||
return followEditLink(
|
||||
@@ -363,6 +365,13 @@ function confirmDelete() {
|
||||
return removal.confirm(runDelete, handleDeleteFailure)
|
||||
}
|
||||
|
||||
function deleteHint(site: Site) {
|
||||
if (!canDeleteSite.value) {
|
||||
return t('Only the owner can delete a site.')
|
||||
}
|
||||
return site.is_default ? copy('defaultSiteBlocked') : undefined
|
||||
}
|
||||
|
||||
function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
const items: DirectoryMenuItem[] = [
|
||||
{
|
||||
@@ -386,8 +395,9 @@ function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
icon: 'i-lucide-trash-2',
|
||||
destructive: true,
|
||||
separated: !site.is_default,
|
||||
unavailable: site.is_default,
|
||||
hint: site.is_default ? copy('defaultSiteBlocked') : undefined,
|
||||
unavailable: site.is_default || !canDeleteSite.value,
|
||||
ownerOnly: !canDeleteSite.value,
|
||||
hint: deleteHint(site),
|
||||
onSelect: (trigger) => openDelete(site, trigger),
|
||||
})
|
||||
return items
|
||||
@@ -395,10 +405,6 @@ function menuItems(site: Site): DirectoryMenuItem[] {
|
||||
|
||||
const noResultsTitle = computed(() => t('No sites match "{0}"', [shown.value.q]))
|
||||
const headings = computed(() => [t('Site'), t('Base URL'), t('Articles'), ''])
|
||||
|
||||
function backToSettings() {
|
||||
router.push('/settings')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -424,7 +430,7 @@ function backToSettings() {
|
||||
/>
|
||||
</template>
|
||||
|
||||
<SitesForbidden v-if="forbidden" @back="backToSettings" />
|
||||
<NoAccessState v-if="forbidden" />
|
||||
|
||||
<div v-else-if="sessionReady" class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-end gap-4">
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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.")
|
||||
})
|
||||
})
|
||||
@@ -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([
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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' })) {
|
||||
|
||||
@@ -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'),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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')])
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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'])
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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: '',
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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>)
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user