fix(auth): enforce library roles for metadata editing

Library access roles had no effect on metadata editing: only the global Edit metadata permission
was checked, so a Viewer could edit every library they could see and an Editor could not be
limited to one library. Require Editor or Owner on the book's library for every metadata write,
including bulk edits, locks, covers, file renames, entity manager changes and author edits, and
hide the matching actions in the web client.

Keep existing access levels when an admin saves a user's library list. The old replace reset every
row to Viewer, which would now silently revoke editing on each user save.

Existing grants are Viewer, so after upgrading, admins must set Editor on each library where a
non-superuser should keep editing metadata. The released iOS app already gates editing on Editor.

Closes #1778
This commit is contained in:
neon
2026-10-14 03:57:51 +00:00
parent 602a03e6c5
commit 59e3f36f5e
75 changed files with 1407 additions and 158 deletions
+16 -11
View File
@@ -64,14 +64,18 @@ const BTN_TEXT_DESTRUCTIVE =
'h-8 px-3 rounded-full text-sm font-medium text-destructive hover:bg-destructive hover:text-destructive-foreground transition-colors'
const DIVIDER = 'w-px h-5 bg-border mx-1 shrink-0'
const props = defineProps<{
count: number
visible: boolean
inCollection?: boolean
inFlight?: InFlightOp | null
// Selection is query-scoped (all matching across pages), so individual ids aren't enumerable.
queryScoped?: boolean
}>()
const props = withDefaults(
defineProps<{
count: number
visible: boolean
inCollection?: boolean
inFlight?: InFlightOp | null
// Selection is query-scoped (all matching across pages), so individual ids aren't enumerable.
queryScoped?: boolean
metadataEditable?: boolean
}>(),
{ metadataEditable: true },
)
const emit = defineEmits<{
'add-to-collection': []
@@ -111,8 +115,9 @@ const slots = useSlots()
const hasCustomContent = computed(() => Boolean(slots.content))
const canBulkActions = computed(() => !isDemoRestrictedAccount.value)
const canDownload = computed(() => hasPermission('library_download') && canBulkActions.value)
const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
const canMoveToLibrary = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
const canSetRating = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value)
const canEditMetadata = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value && props.metadataEditable !== false)
const canMoveToLibrary = computed(() => hasPermission('library_edit_metadata') && canBulkActions.value && props.metadataEditable !== false)
const canShowMoreMenu = computed(() => canDownload.value || canEditMetadata.value || canMoveToLibrary.value)
const canShare = computed(() => hasPermission('email_send') || canDownload.value)
const numericFieldSelected = computed(() => bulkField.value === 'publishedYear')
@@ -310,7 +315,7 @@ watch(
<TooltipContent side="top">{{ t('components.selectionActionBar.setReadingStatus') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="canEditMetadata">
<Tooltip v-if="canSetRating">
<TooltipTrigger as-child>
<button
data-testid="action-bulk-set-rating"
@@ -55,6 +55,14 @@ function mountBar() {
}
describe('SelectionActionBar demo restriction', () => {
it('hides metadata actions when the selection includes a read-only library', async () => {
const wrapper = mountBar()
await wrapper.setProps({ metadataEditable: false })
expect(wrapper.find('[data-testid="action-edit-individually"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="action-bulk-set-field"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="action-bulk-set-rating"]').exists()).toBe(true)
})
beforeEach(() => {
permissionState.allowed = new Set(['library_edit_metadata', 'library_download', 'email_send', 'library_delete_books'])
permissionState.demoRestricted = false
@@ -73,6 +73,10 @@ vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([]), fetchLibraries: mocks.fetchLibraries }),
}))
vi.mock('@/features/library/composables/useLibraryPermissions', () => ({
useLibraryPermissions: () => ({ hasLibraryPermission: () => true, hasPermissionInAnyLibrary: () => true }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true, isSuperuser: ref(false) }),
}))
@@ -15,6 +15,7 @@ import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrol
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { usePageTitle } from '@/composables/usePageTitle'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
@@ -43,11 +44,12 @@ const route = useRoute()
const router = useRouter()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { hasPermission, isSuperuser } = usePermissions()
const { isSuperuser } = usePermissions()
const { width: windowWidth } = useWindowSize()
const { portraitCoverSize, gridGap } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { hasPermissionInAnyLibrary } = useLibraryPermissions()
const authorBooksViewMode = ref<'grid' | 'list'>('grid')
@@ -95,7 +97,7 @@ const {
loadFresh: loadFreshMetadataPreview,
} = useAuthorMetadataPreview(authorName)
const canUpdate = computed(() => hasPermission('library_edit_metadata'))
const canUpdate = computed(() => hasPermissionInAnyLibrary('library_edit_metadata', 'editor'))
const canMerge = computed(() => isSuperuser.value)
const canDelete = computed(() => isSuperuser.value)
@@ -73,6 +73,10 @@ vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([]), fetchLibraries: mocks.fetchLibraries }),
}))
vi.mock('@/features/library/composables/useLibraryPermissions', () => ({
useLibraryPermissions: () => ({ hasLibraryPermission: () => true, hasPermissionInAnyLibrary: () => true }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true, isDemoRestrictedAccount: ref(false), isSuperuser: ref(false) }),
}))
@@ -14,6 +14,7 @@ import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import JumpRail from '@/features/book/components/JumpRail.vue'
import { bulkRefreshAuthorsMetadata, deleteAuthors, refreshAuthorMetadata } from '../api/author'
import AuthorConfirmDialog from '../components/AuthorConfirmDialog.vue'
@@ -39,7 +40,8 @@ const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { gridGap, viewMode, authorCoverSize, authorCoverShape, authorRowDensity, authorCoverFallback, showJumpRails } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries()
const { hasPermission, isDemoRestrictedAccount, isSuperuser } = usePermissions()
const { hasPermissionInAnyLibrary } = useLibraryPermissions()
const { isDemoRestrictedAccount, isSuperuser } = usePermissions()
const list = useAuthorsList()
const { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, hasSortName, addedWithinDays, minBookCount, load } = list
const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors()
@@ -66,7 +68,7 @@ const bulkRefreshConfirmButton = ref<HTMLButtonElement | null>(null)
const pendingDeleteIds = ref<number[]>([])
const deleteDialogOpen = ref(false)
const canRefreshMetadata = computed(() => hasPermission('library_edit_metadata') && !isDemoRestrictedAccount.value)
const canRefreshMetadata = computed(() => hasPermissionInAnyLibrary('library_edit_metadata', 'editor') && !isDemoRestrictedAccount.value)
const canDeleteAuthors = computed(() => isSuperuser.value)
/** The page offers exactly the two views the design has; `table` is not one of them. */
@@ -40,7 +40,10 @@ vi.mock('@/features/book/lib/book-card-mapper', () => ({
const mockHasPermission = vi.fn<() => boolean>().mockReturnValue(true)
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: mockHasPermission }),
usePermissions: () => ({ hasPermission: mockHasPermission, isSuperuser: ref(false) }),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 7, accessLevel: 'editor' }]) }),
}))
const mockFetchAuthors = vi.hoisted(() =>
@@ -139,6 +142,7 @@ function makeFile(overrides: Partial<{ id: number; format: string; role: string;
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
libraryId: 7,
status: 'present',
coverAspectRatio: '2/3',
title: 'Dune',
@@ -44,6 +44,7 @@ import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { useRefreshingBooks } from '../composables/useRefreshingBooks'
import { mergeBookCardWithDetail } from '../lib/book-card-mapper'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useDisplaySettings, type GridCardLabelField } from '@/composables/useDisplaySettings'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO, coverAspectRatioValue, fittedCoverFrameStyle } from '../lib/cover-aspect-ratio'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
@@ -124,6 +125,8 @@ async function handleRefreshMetadata() {
if (updated) emit('update:book', mergeBookCardWithDetail(props.book, updated))
}
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const { cardOverlays, bookCoverDisplayMode, gridCardPrimaryLabel, gridCardSecondaryLabel, cardInfoMode, thumbnailClickAction } = useDisplaySettings()
const injectedCoverAspectRatio = inject(COVER_ASPECT_RATIO_KEY, ref(DEFAULT_COVER_ASPECT_RATIO))
const effectiveCoverAspectRatio = computed<CoverAspectRatio>(
@@ -681,8 +684,8 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<BookText class="size-4 mr-2" />
{{ t('book.actions.bookDetails') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
<DropdownMenuSeparator v-if="canEditMetadata" />
<DropdownMenuSub v-if="canEditMetadata">
<DropdownMenuSubTrigger>
<Pencil class="size-4 mr-2" />
{{ t('book.actions.metadata') }}
@@ -708,7 +711,7 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<FolderPlus class="size-4 mr-2" />
{{ t('book.actions.addToCollection') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="allowMoveToLibrary && hasPermission('library_edit_metadata')" @click="emit('action', 'move-to-library')">
<DropdownMenuItem v-if="allowMoveToLibrary && canEditMetadata" @click="emit('action', 'move-to-library')">
<FolderInput class="size-4 mr-2" />
{{ t('book.move.action') }}
</DropdownMenuItem>
@@ -823,8 +826,8 @@ const secondaryLabelText = computed(() => resolveBookLabel(gridCardSecondaryLabe
<BookText class="size-4 mr-2" />
{{ t('book.actions.bookDetails') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuSub v-if="hasPermission('library_edit_metadata')">
<DropdownMenuSeparator v-if="canEditMetadata" />
<DropdownMenuSub v-if="canEditMetadata">
<DropdownMenuSubTrigger>
<Pencil class="size-4 mr-2" />
{{ t('book.actions.metadata') }}
@@ -33,6 +33,7 @@ import { useCoverVersions } from '../composables/useCoverVersions'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '../lib/cover-aspect-ratio'
import { useRefreshMetadata } from '../composables/useRefreshMetadata'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import SendBookDialog from '@/features/email/components/SendBookDialog.vue'
import { RATING_STARS, getRatingStarClass } from '@/features/book/lib/rating-stars'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
@@ -61,6 +62,8 @@ const emit = defineEmits<{
}>()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const { thumbnailClickAction } = useDisplaySettings()
const showSendDialog = ref(false)
@@ -421,19 +424,16 @@ function handleRowClick(event: MouseEvent) {
{{ t('book.actions.bookDetails') }}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
v-if="hasPermission('library_edit_metadata')"
@click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })"
>
<DropdownMenuItem v-if="canEditMetadata" @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
<Pencil class="size-4 mr-2" />
{{ t('book.actions.editMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<DropdownMenuItem v-if="canEditMetadata" :disabled="refreshing" @click="refreshWithFeedback(book.id)">
<Loader2 v-if="refreshing" class="size-4 mr-2 animate-spin" />
<RefreshCw v-else class="size-4 mr-2" />
{{ t('book.actions.refreshMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="allowMoveToLibrary && hasPermission('library_edit_metadata')" @click="emit('action', 'move-to-library')">
<DropdownMenuItem v-if="allowMoveToLibrary && canEditMetadata" @click="emit('action', 'move-to-library')">
<FolderInput class="size-4 mr-2" />
{{ t('book.move.action') }}
</DropdownMenuItem>
@@ -3,6 +3,7 @@ import { computed, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, Eye, Folder, FolderPlus, Headphones, Pencil, Star, Trash2, X } from '@lucide/vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { Sheet, SheetContent, SheetTitle, SheetDescription } from '@/components/ui/sheet'
import { Skeleton } from '@/components/ui/skeleton'
import { DialogRoot, DialogContent, DialogPortal, DialogOverlay, DialogClose, DialogTitle, DialogDescription } from 'reka-ui'
@@ -27,6 +28,8 @@ const { t } = useI18n()
const props = defineProps<{ bookId: number | null; open: boolean }>()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', detail.value?.libraryId, 'editor'))
const emit = defineEmits<{
'update:open': [value: boolean]
action: [type: 'add-to-collection' | 'delete']
@@ -412,7 +415,7 @@ function handleDelete() {
</TooltipTrigger>
<TooltipContent>{{ t('book.actions.peek') }}</TooltipContent>
</Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<Tooltip v-if="canEditMetadata">
<TooltipTrigger as-child>
<button
class="flex items-center justify-center gap-1.5 h-9 px-3 rounded-md border border-input bg-background text-sm hover:bg-muted transition-colors"
@@ -35,13 +35,14 @@ import BookTableContextMenu from './table/BookTableContextMenu.vue'
import BookTableHeader from './table/BookTableHeader.vue'
import BookTableHeaderContextMenu from './table/BookTableHeaderContextMenu.vue'
import type { BookCard, Rule, SortSpec, TableLayoutState, TableViewType } from '@bookorbit/types'
import { BOOK_METADATA_LOCK_FIELDS } from '@bookorbit/types'
import { BOOK_METADATA_LOCK_FIELDS, Permission } from '@bookorbit/types'
import { isBookPlaceholder, type BookSlot } from '@/features/book/composables/useBookWindow'
import { useNarratorSearch } from '@/features/book/composables/useNarratorSearch'
import { sortFieldLabel } from '@/features/book/lib/filter-labels'
import { DEFAULT_SORT } from '@/features/book/lib/sort-defaults'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { useActiveCustomFields } from '@/features/book/composables/useActiveCustomFields'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -144,16 +145,22 @@ const { getSortDir, handleColumnSort, removeSortField } = useTableSorting(
() => props.defaultSort ?? DEFAULT_SORT,
)
const { getQuickFilterOptions, buildQuickFilterRule } = useTableQuickFilters(props.viewType)
const { hasLibraryPermission } = useLibraryPermissions()
function canEditBookMetadata(book: BookCard): boolean {
return hasLibraryPermission(Permission.LibraryEditMetadata, book.libraryId, 'editor')
}
const { getCellValue, isCellLocked, isCellReadOnly, isMandatoryFieldEmpty, isBookFileMissing, getPinnedCellBackground } = useTableCellHelpers(
locks,
() => isReadOnly.value,
() => combinedColumnMap.value,
canEditBookMetadata,
)
const { coverDialogBook, handleCoverClick, handleCoverDialogUpdateBook } = useTableCoverDialog(
() => props.selectionMode ?? false,
(updated) => emit('update:book', updated),
() => loadedBooks.value,
)
const isCoverDialogReadOnly = computed(() => isReadOnly.value || (coverDialogBook.value !== null && !canEditBookMetadata(coverDialogBook.value)))
function getChipsSearchFn(colId: string): (q: string) => Promise<string[]> {
if (colId === 'authors') return searchAuthors
@@ -605,6 +612,7 @@ function emitLockStateUpdate(book: BookCard, nextFields: BookCard['lockedFields'
}
async function handleToggleCellLock(book: BookCard, colId: string) {
if (!canEditBookMetadata(book)) return
const lockField = COLUMN_DEF_MAP.get(colId)?.lockField
if (!lockField) return
const beforeFields = [...locks.getFields(book.id)]
@@ -615,6 +623,7 @@ async function handleToggleCellLock(book: BookCard, colId: string) {
}
async function handleLockAllRow(book: BookCard) {
if (!canEditBookMetadata(book)) return
const beforeFields = [...locks.getFields(book.id)]
await locks.lockAll(book.id)
const afterFields = [...locks.getFields(book.id)]
@@ -623,6 +632,7 @@ async function handleLockAllRow(book: BookCard) {
}
async function handleUnlockAllRow(book: BookCard) {
if (!canEditBookMetadata(book)) return
const beforeFields = [...locks.getFields(book.id)]
await locks.unlockAll(book.id)
const afterFields = [...locks.getFields(book.id)]
@@ -894,6 +904,7 @@ defineExpose({
:is-locked="isCellLocked(rowBook(vItem.index), col.id)"
:is-active="editor.isActive(rowBook(vItem.index).id, col.id)"
:is-read-only="isCellReadOnly(rowBook(vItem.index), col)"
:can-edit-metadata="canEditBookMetadata(rowBook(vItem.index))"
:is-fully-locked="isRowFullyLocked(rowBook(vItem.index))"
:locked-field-count="getRowLockedFieldCount(rowBook(vItem.index))"
:selection-mode="props.selectionMode ?? false"
@@ -1050,7 +1061,12 @@ defineExpose({
/>
</div>
<BookCoverDialog :book="coverDialogBook" :read-only="isReadOnly" @close="coverDialogBook = null" @update:book="handleCoverDialogUpdateBook" />
<BookCoverDialog
:book="coverDialogBook"
:read-only="isCoverDialogReadOnly"
@close="coverDialogBook = null"
@update:book="handleCoverDialogUpdateBook"
/>
<KeyboardShortcutOverlay v-model:open="shortcutOverlayOpen" />
</template>
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookCard } from '@bookorbit/types'
@@ -6,12 +7,17 @@ import BookListRow from '../BookListRow.vue'
const { hasPermissionMock } = vi.hoisted(() => ({ hasPermissionMock: vi.fn<(permission: string) => boolean>() }))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 1, accessLevel: 'editor' }]) }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: hasPermissionMock, isDemoRestrictedAccount: { value: false } }),
usePermissions: () => ({ hasPermission: hasPermissionMock, isDemoRestrictedAccount: { value: false }, isSuperuser: { value: false } }),
}))
const book: BookCard = {
id: 12,
libraryId: 1,
status: 'present',
coverAspectRatio: '2/3',
title: 'Dune',
@@ -29,8 +29,13 @@ vi.mock('vue-router', async (importOriginal) => {
}
})
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 1, accessLevel: 'editor' }]) }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
isSuperuser: { value: false },
hasPermission: (name: string) => {
if (name === 'library_delete_books') return permissionState.canDelete
if (name === 'library_edit_metadata') return permissionState.canEditMetadata
@@ -6,7 +6,7 @@ import { useI18n } from 'vue-i18n'
import BookDetailTabs from './BookDetailTabs.vue'
import { useBookNavigation } from '../../composables/useBookNavigation'
const props = defineProps<{ bookId: number }>()
const props = defineProps<{ bookId: number; canEditMetadata?: boolean }>()
const { t } = useI18n()
const route = useRoute()
@@ -36,7 +36,7 @@ async function navigateToBook(id: number) {
<div
class="flex items-stretch border-b shrink-0 h-10 md:h-9 px-3 pl-[max(0.75rem,env(safe-area-inset-left))] pr-[max(0.75rem,env(safe-area-inset-right))] md:px-3"
>
<BookDetailTabs :book-id="bookId" />
<BookDetailTabs :book-id="bookId" :can-edit-metadata="canEditMetadata" />
<div v-if="hasContext" class="hidden sm:flex items-center gap-1 ml-4 border-l pl-4">
<span v-if="index !== -1" class="text-xs text-muted-foreground mr-2 font-medium tabular-nums">
@@ -3,7 +3,7 @@ import BookDetailHeader from './BookDetailHeader.vue'
import { coverTintVars, type CoverTint } from '@/features/book/lib/cover-tint'
import { computed } from 'vue'
const props = withDefaults(defineProps<{ bookId: number; coverTint?: CoverTint | null }>(), { coverTint: null })
const props = withDefaults(defineProps<{ bookId: number; canEditMetadata?: boolean; coverTint?: CoverTint | null }>(), { coverTint: null })
const tintVars = computed(() => coverTintVars(props.coverTint))
</script>
@@ -16,7 +16,7 @@ const tintVars = computed(() => coverTintVars(props.coverTint))
]"
:style="tintVars"
>
<BookDetailHeader :book-id="bookId" />
<BookDetailHeader :book-id="bookId" :can-edit-metadata="canEditMetadata" />
<main class="flex-1 overflow-y-auto overflow-x-hidden p-4 sm:px-6 sm:py-4">
<slot />
</main>
@@ -3,19 +3,20 @@ import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { normalizeBookDetailTab, type BookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const props = defineProps<{ bookId: number }>()
const props = defineProps<{ bookId: number; canEditMetadata?: boolean }>()
const route = useRoute()
const router = useRouter()
const { t } = useI18n()
const { hasPermission } = usePermissions()
const activeTab = computed(() => normalizeBookDetailTab(route.query.tab))
const activeTab = computed(() => {
const requested = normalizeBookDetailTab(route.query.tab)
return requested === 'edit' && !props.canEditMetadata ? 'details' : requested
})
const tabs = computed<{ label: string; tab: BookDetailTab }[]>(() => {
const result: { label: string; tab: BookDetailTab }[] = [{ label: t('book.detail.tabs.details'), tab: 'details' }]
if (hasPermission('library_edit_metadata')) {
if (props.canEditMetadata) {
result.push({ label: t('book.detail.tabs.editMetadata'), tab: 'edit' })
}
result.push({ label: t('book.detail.tabs.files'), tab: 'files' })
@@ -2,14 +2,13 @@
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { CheckCircle2, FileUp, Loader2, Plus, Upload, X, XCircle, RotateCcw } from '@lucide/vue'
import { useI18n } from 'vue-i18n'
import { Permission } from '@bookorbit/types'
import { SUPPORTED_FORMATS_ACCEPT, useAddBookFile } from '@/features/book/composables/useAddBookFile'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useAppInfo } from '@/features/settings/composables/useAppInfo'
import { formatBytes } from '@/lib/formatting'
const props = defineProps<{
bookId: number
canRenameFiles: boolean
}>()
const emit = defineEmits<{
@@ -18,7 +17,6 @@ const emit = defineEmits<{
}>()
const { t } = useI18n()
const { hasPermission } = usePermissions()
const { maxUploadSizeMb } = useAppInfo()
const isDragging = ref(false)
@@ -91,7 +89,7 @@ function onFileInputChange(e: Event) {
async function handleUpload() {
if (pendingCount.value === 0 || isUploading.value) return
await startUpload(props.bookId, { renameAfter: renameAfter.value })
await startUpload(props.bookId, { renameAfter: props.canRenameFiles && renameAfter.value })
}
function fileRowClass(status: string): string {
@@ -288,7 +286,7 @@ function formatPillClass(filename: string): string {
<span v-else-if="isUploading" class="text-xs text-muted-foreground tabular-nums">{{
t('book.detail.addFile.uploadingProgress', { done: doneCount, total: files.length })
}}</span>
<label v-else-if="hasPermission(Permission.LibraryEditMetadata)" class="flex items-center gap-2 cursor-pointer select-none">
<label v-else-if="canRenameFiles" class="flex items-center gap-2 cursor-pointer select-none">
<input
v-model="renameAfter"
type="checkbox"
@@ -45,6 +45,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { api } from '@/lib/api'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useDeleteBook } from '@/features/book/composables/useDeleteBook'
import { useMetadataLocks } from '@/features/book/composables/useMetadataLocks'
import { usePersonalNote, PERSONAL_NOTE_MAX_LENGTH } from '@/features/book/composables/usePersonalNote'
@@ -255,6 +256,7 @@ onBeforeUnmount(() => {
})
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const { load: loadLocks, isLocked } = useMetadataLocks()
watch(
() => props.book,
@@ -265,7 +267,8 @@ watch(
const isRatingLocked = computed(() => isLocked('rating'))
const canViewKobo = computed(() => hasPermission('kobo_sync'))
const canViewKoreader = computed(() => hasPermission('koreader_sync'))
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const canManageReadingState = computed(() => hasPermission('library_edit_metadata'))
const resetReadingStateBookId = computed(() => props.book.id)
const {
open: resetReadingStateDialogOpen,
@@ -1030,6 +1033,7 @@ function handleCoverLightboxOpenChange(open: boolean) {
}
function openEditCover() {
if (!canEditMetadata.value) return
router.push({ name: 'book-detail', params: { bookId: props.book.id }, query: { tab: 'edit' } })
}
@@ -1246,7 +1250,7 @@ watch(
:aria-label="t('book.detail.details.viewCover')"
@click="handleCoverClick"
/>
<Tooltip>
<Tooltip v-if="canEditMetadata">
<TooltipTrigger as-child>
<button
class="absolute top-1.5 right-1.5 z-10 p-1 rounded-xs bg-black/50 text-white opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-opacity"
@@ -1428,7 +1432,11 @@ watch(
>
<Send class="size-3.5" />
</button>
<Popover v-if="canEditMetadata || hasPermission('library_delete_books')" :open="moreMenuOpen" @update:open="(v) => (moreMenuOpen = v)">
<Popover
v-if="canManageReadingState || canEditMetadata || hasPermission('library_delete_books')"
:open="moreMenuOpen"
@update:open="(v) => (moreMenuOpen = v)"
>
<PopoverTrigger as-child>
<button
class="flex flex-1 items-center justify-center h-9 rounded-md border border-border bg-background text-foreground hover:bg-muted transition-colors"
@@ -1438,7 +1446,7 @@ watch(
</PopoverTrigger>
<PopoverContent class="w-44 p-1" align="end">
<button
v-if="canEditMetadata"
v-if="canManageReadingState"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded-xs text-sm text-foreground hover:bg-muted transition-colors"
@click="handleOpenResetReadingState"
>
@@ -1446,7 +1454,7 @@ watch(
Reset reading state
</button>
<button
v-if="hasPermission('library_edit_metadata')"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 px-2 py-1.5 rounded-xs text-sm hover:bg-muted transition-colors"
@click="handleMoveFromMenu"
>
@@ -10,6 +10,7 @@ import { api } from '@/lib/api'
import { copyToClipboard } from '@/lib/clipboard'
import { useBookDownload } from '@/features/book/composables/useBookDownload'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useBookFileTree, type SortKey, type TreeFile } from '@/features/book/composables/useBookFileTree'
import { findFileWriteTarget } from '@/features/book/lib/file-write-targets'
import FilesHeroBar from '../files/FilesHeroBar.vue'
@@ -25,6 +26,8 @@ const { t } = useI18n()
const router = useRouter()
const { downloadFile: downloadBookFile } = useBookDownload()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const book = toRef(props, 'book')
const {
@@ -45,7 +48,7 @@ const {
const canUpload = computed(() => hasPermission(Permission.LibraryUpload))
const canDownload = computed(() => hasPermission('library_download'))
const canEdit = computed(() => hasPermission('library_edit_metadata'))
const canEdit = canEditMetadata
const canDelete = computed(() => hasPermission('library_delete_books'))
/**
@@ -388,7 +391,13 @@ async function confirmDelete() {
</div>
</div>
<AddBookFileModal v-if="addFileModalOpen" :book-id="book.id" @close="closeAddFileModal" @uploaded="onFilesAdded" />
<AddBookFileModal
v-if="addFileModalOpen"
:book-id="book.id"
:can-rename-files="canEditMetadata"
@close="closeAddFileModal"
@uploaded="onFilesAdded"
/>
</template>
<style scoped>
@@ -1,6 +1,6 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, shallowMount } from '@vue/test-utils'
import { defineComponent } from 'vue'
import { defineComponent, ref } from 'vue'
import type { BookDetail } from '@bookorbit/types'
import DetailsTab from '../DetailsTab.vue'
import BookReadingActivityCard from '../../details/BookReadingActivityCard.vue'
@@ -26,8 +26,17 @@ vi.mock('@/lib/api', () => ({
api: mocks.api,
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({
libraries: ref([
{ id: 1, accessLevel: 'editor' },
{ id: 2, accessLevel: 'viewer' },
]),
}),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: mocks.hasPermission }),
usePermissions: () => ({ hasPermission: mocks.hasPermission, isSuperuser: { value: false } }),
}))
vi.mock('@/features/auth/composables/useAuth', () => ({
@@ -724,6 +733,16 @@ describe('DetailsTab cover surface', () => {
expect(wrapper.findAll('button').some((button) => button.text().includes('Reset reading state'))).toBe(false)
})
it('keeps personal reading reset available to viewers while hiding library metadata controls', async () => {
mocks.hasPermission.mockImplementation((permission) => permission === 'library_edit_metadata')
const wrapper = mountDetails(makeBook({ libraryId: 2 }))
await flushPromises()
expect(wrapper.findAll('button').some((button) => button.text().includes('Reset reading state'))).toBe(true)
expect(wrapper.find('button[aria-label="Edit date added"]').exists()).toBe(false)
expect(wrapper.find('button[aria-label="Edit cover"]').exists()).toBe(false)
})
it('edits and saves the added date inline', async () => {
const updated = makeBook({ addedAt: '2020-06-15T00:00:00.000Z', updatedAt: '2026-08-05T12:00:00.000Z' })
mocks.api.mockImplementation(async (input) => {
@@ -1,5 +1,6 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { shallowMount } from '@vue/test-utils'
import { ref } from 'vue'
import type { BookDetail } from '@bookorbit/types'
const mocks = vi.hoisted(() => ({
@@ -16,7 +17,12 @@ vi.mock('vue-router', async (importOriginal) => {
})
vi.mock('@/lib/api', () => ({ api: mocks.api, setOnAuthFailure: vi.fn<(callback: () => void) => void>() }))
vi.mock('@/features/auth/composables/usePermissions', () => ({ usePermissions: () => ({ hasPermission: mocks.hasPermission }) }))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: mocks.hasPermission, isSuperuser: ref(false) }),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 3, accessLevel: 'editor' }]) }),
}))
vi.mock('@/features/book/composables/useDeleteBook', () => ({
useDeleteBook: (onDeleted: (id: number) => void) => {
@@ -6,6 +6,7 @@ import { BookOpen, ExternalLink, FolderPlus, MoreHorizontal, Pencil, RefreshCw,
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
import { Button } from '@/components/ui/button'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useRefreshMetadata } from '@/features/book/composables/useRefreshMetadata'
import { useBookRefreshFeedback } from '@/features/book/composables/useBookRefreshFeedback'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
@@ -27,6 +28,8 @@ const router = useRouter()
const { t } = useI18n()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const { refreshWithFeedback, refreshing } = useRefreshMetadata()
const refreshFeedback = useBookRefreshFeedback()
const { isRefreshing } = useRefreshingBooks()
@@ -67,15 +70,12 @@ async function handleRefresh() {
<ExternalLink :size="13" class="mr-2" />
{{ t('book.table.actions.bookDetails') }}
</DropdownMenuItem>
<DropdownMenuSeparator v-if="hasPermission('library_edit_metadata')" />
<DropdownMenuItem
v-if="hasPermission('library_edit_metadata')"
@click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })"
>
<DropdownMenuSeparator v-if="canEditMetadata" />
<DropdownMenuItem v-if="canEditMetadata" @click="router.push({ name: 'book-detail', params: { bookId: book.id }, query: { tab: 'edit' } })">
<Pencil :size="13" class="mr-2" />
{{ t('book.table.actions.editMetadata') }}
</DropdownMenuItem>
<DropdownMenuItem v-if="hasPermission('library_edit_metadata')" :disabled="anyRefreshing" @click="handleRefresh">
<DropdownMenuItem v-if="canEditMetadata" :disabled="anyRefreshing" @click="handleRefresh">
<RefreshCw :size="13" class="mr-2" :class="{ 'animate-spin': anyRefreshing }" />
{{ t('book.table.actions.refreshMetadata') }}
</DropdownMenuItem>
@@ -31,6 +31,7 @@ const props = defineProps<{
isLocked: boolean
isActive: boolean
isReadOnly: boolean
canEditMetadata: boolean
isFullyLocked: boolean
lockedFieldCount: number
selectionMode: boolean
@@ -86,6 +87,7 @@ const lockStateClass = computed(() => {
})
function handleToggleAllLocks() {
if (!props.canEditMetadata) return
if (props.isFullyLocked) {
emit('unlockAll')
return
@@ -104,8 +106,9 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
<TooltipTrigger as-child>
<button
type="button"
class="flex h-6 w-6 items-center justify-center rounded-xs transition-colors hover:bg-muted"
class="flex h-6 w-6 items-center justify-center rounded-xs transition-colors hover:bg-muted disabled:pointer-events-none"
:class="lockStateClass"
:disabled="!canEditMetadata"
:aria-label="isFullyLocked ? t('book.table.locks.unlockAll') : t('book.table.locks.lockAll')"
@click.stop="handleToggleAllLocks"
>
@@ -128,7 +131,13 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
@cover-click="emit('coverClick')"
/>
<BookTableLockableCell v-else-if="cellType === 'text'" :is-locked="isLocked" :has-lock-field="hasLockField" @toggle-lock="emit('toggleLock')">
<BookTableLockableCell
v-else-if="cellType === 'text'"
:is-locked="isLocked"
:has-lock-field="hasLockField"
:read-only="!canEditMetadata"
@toggle-lock="emit('toggleLock')"
>
<BookTableTextCell
:value="asString(value)"
:is-active="isActive"
@@ -146,7 +155,13 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
<BookTableMetadataScoreCell v-else-if="colId === 'metadataScore'" :value="asNumber(value)" />
<BookTableLockableCell v-else-if="cellType === 'number'" :is-locked="isLocked" :has-lock-field="hasLockField" @toggle-lock="emit('toggleLock')">
<BookTableLockableCell
v-else-if="cellType === 'number'"
:is-locked="isLocked"
:has-lock-field="hasLockField"
:read-only="!canEditMetadata"
@toggle-lock="emit('toggleLock')"
>
<BookTableNumberCell
:value="asNumberInput(value)"
:is-active="isActive"
@@ -159,11 +174,23 @@ const isComic = computed(() => primaryFile.value?.format != null && FORMAT_TO_GR
/>
</BookTableLockableCell>
<BookTableLockableCell v-else-if="cellType === 'rating'" :is-locked="isLocked" :has-lock-field="hasLockField" @toggle-lock="emit('toggleLock')">
<BookTableLockableCell
v-else-if="cellType === 'rating'"
:is-locked="isLocked"
:has-lock-field="hasLockField"
:read-only="!canEditMetadata"
@toggle-lock="emit('toggleLock')"
>
<BookTableRatingCell :value="asNumber(value)" :is-read-only="isReadOnly" @save="emit('save', $event)" />
</BookTableLockableCell>
<BookTableLockableCell v-else-if="cellType === 'chips'" :is-locked="isLocked" :has-lock-field="hasLockField" @toggle-lock="emit('toggleLock')">
<BookTableLockableCell
v-else-if="cellType === 'chips'"
:is-locked="isLocked"
:has-lock-field="hasLockField"
:read-only="!canEditMetadata"
@toggle-lock="emit('toggleLock')"
>
<BookTableChipsCell
:value="asStringArray(value)"
:is-active="isActive"
@@ -4,6 +4,7 @@ import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { BookOpen, ExternalLink, FolderInput, FolderPlus, Pencil, RefreshCw, Send, Trash2 } from '@lucide/vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useRefreshMetadata } from '@/features/book/composables/useRefreshMetadata'
import { useBookRefreshFeedback } from '@/features/book/composables/useBookRefreshFeedback'
import { useRefreshingBooks } from '@/features/book/composables/useRefreshingBooks'
@@ -27,6 +28,8 @@ const emit = defineEmits<{
const router = useRouter()
const { t } = useI18n()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', props.book?.libraryId, 'editor'))
const { refreshWithFeedback, refreshing } = useRefreshMetadata()
const refreshFeedback = useBookRefreshFeedback()
const { isRefreshing } = useRefreshingBooks()
@@ -159,7 +162,7 @@ onBeforeUnmount(() => {
{{ t('book.table.actions.bookDetails') }}
</button>
<button
v-if="hasPermission('library_edit_metadata')"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 rounded-xs px-2 py-1.5 text-left text-sm hover:bg-accent"
@click="openEditMetadata"
>
@@ -167,7 +170,7 @@ onBeforeUnmount(() => {
{{ t('book.table.actions.editMetadata') }}
</button>
<button
v-if="hasPermission('library_edit_metadata')"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 rounded-xs px-2 py-1.5 text-left text-sm hover:bg-accent"
:disabled="anyRefreshing"
@click="refreshMetadata"
@@ -183,7 +186,7 @@ onBeforeUnmount(() => {
{{ t('book.table.actions.addToCollection') }}
</button>
<button
v-if="hasPermission('library_edit_metadata')"
v-if="canEditMetadata"
class="flex w-full items-center gap-2 rounded-xs px-2 py-1.5 text-left text-sm hover:bg-accent"
@click="emitAction('move-to-library')"
>
@@ -8,6 +8,7 @@ const { t } = useI18n()
defineProps<{
isLocked: boolean
hasLockField: boolean
readOnly?: boolean
}>()
defineEmits<{
@@ -26,7 +27,8 @@ defineEmits<{
type="button"
:aria-pressed="isLocked"
:aria-label="isLocked ? t('book.table.locks.unlockField') : t('book.table.locks.lockField')"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors"
class="flex h-4 w-4 shrink-0 items-center justify-center rounded-sm transition-colors disabled:pointer-events-none"
:disabled="readOnly"
:class="
isLocked ? 'text-primary hover:text-primary' : 'opacity-0 text-muted-foreground group-hover/cell:opacity-100 hover:text-muted-foreground'
"
@@ -47,6 +47,7 @@ const baseProps = {
isLocked: false,
isActive: false,
isReadOnly: false,
canEditMetadata: true,
isFullyLocked: false,
lockedFieldCount: 0,
selectionMode: false,
@@ -102,6 +103,18 @@ describe('BookTableCellDispatcher lock row', () => {
expect(wrapper.emitted('unlockAll')).toBeFalsy()
})
it('disables the row lock toggle when the book sits in a library the user can only read', async () => {
const wrapper = mountDispatcher({ isFullyLocked: false, lockedFieldCount: 2, canEditMetadata: false })
const button = wrapper.get('button')
expect(button.attributes('disabled')).toBeDefined()
expect(button.classes()).toContain('text-amber-600/90')
await button.trigger('click')
expect(wrapper.emitted('lockAll')).toBeFalsy()
expect(wrapper.emitted('unlockAll')).toBeFalsy()
})
it('passes the comic flag to the cover cell for comic primary files', () => {
const book = makeBook({ files: [{ id: 1, format: 'cbz', role: 'primary', sizeBytes: null }] })
const wrapper = mountDispatcher({ book, colId: 'cover' as const, cellType: 'cover' as const })
@@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import { ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BookCard } from '@bookorbit/types'
@@ -6,8 +7,12 @@ import BookTableContextMenu from '../BookTableContextMenu.vue'
const { hasPermissionMock } = vi.hoisted(() => ({ hasPermissionMock: vi.fn<(permission: string) => boolean>() }))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 1, accessLevel: 'editor' }]) }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: hasPermissionMock, isDemoRestrictedAccount: { value: false } }),
usePermissions: () => ({ hasPermission: hasPermissionMock, isDemoRestrictedAccount: { value: false }, isSuperuser: { value: false } }),
}))
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn<(to: unknown) => void>() }) }))
@@ -28,7 +33,7 @@ vi.mock('@/features/book/composables/useRefreshingBooks', () => ({
useRefreshingBooks: () => ({ isRefreshing: () => false }),
}))
const book = { id: 7, title: 'Dune' } as BookCard
const book = { id: 7, libraryId: 1, title: 'Dune' } as BookCard
function mountMenu() {
return mount(BookTableContextMenu, {
@@ -0,0 +1,134 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { Permission, type BookCard, type Library } from '@bookorbit/types'
import type { QuerySelectionState } from '../useBookBulkActions'
const libraries = ref<Library[]>([])
const permissions = ref<string[]>([Permission.LibraryEditMetadata])
const isSuperuser = ref(false)
vi.mock('@/features/library/composables/useLibraries', () => ({ useLibraries: () => ({ libraries }) }))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: (permission: string) => isSuperuser.value || permissions.value.includes(permission), isSuperuser }),
}))
const { useBookSelectionPermissions } = await import('../useBookSelectionPermissions')
beforeEach(() => {
permissions.value = [Permission.LibraryEditMetadata]
isSuperuser.value = false
libraries.value = [
{ id: 7, type: 'books', accessLevel: 'editor' },
{ id: 8, type: 'books', accessLevel: 'viewer' },
{ id: 9, type: 'books', accessLevel: 'owner' },
] as Library[]
})
function context(ids: number[] = [1]) {
const books = ref<BookCard[]>([
{ id: 1, libraryId: 7 },
{ id: 2, libraryId: 8 },
{ id: 3, libraryId: 9 },
] as BookCard[])
const selectedIds = ref(new Set(ids))
const query = ref<QuerySelectionState | null>(null)
return { books, selectedIds, query, ...useBookSelectionPermissions(books, selectedIds, query) }
}
describe('metadata permissions for bulk selections', () => {
it('permits an editor and owner selection and rejects a mixed viewer selection', () => {
const ctx = context([1, 3])
expect(ctx.canEditSelectionMetadata.value).toBe(true)
ctx.selectedIds.value.add(2)
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it.each([{ ids: [] }, { ids: [99] }])('fails closed for empty or unresolved selection $ids', ({ ids }) => {
expect(context(ids).canEditSelectionMetadata.value).toBe(false)
})
it('fails closed when a card from an older server has no library ID', () => {
const ctx = context()
delete ctx.books.value[0]!.libraryId
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it('checks the query scope even when the visible page belongs to an editor', () => {
const ctx = context()
ctx.query.value = { libraryId: 8, total: 10000 } as QuerySelectionState
expect(ctx.canEditSelectionMetadata.value).toBe(false)
ctx.query.value = { libraryId: 7, total: 10000 } as QuerySelectionState
expect(ctx.canEditSelectionMetadata.value).toBe(true)
})
it('requires editor access to every accessible library for an unscoped query', () => {
const ctx = context()
ctx.query.value = { total: 10000 } as QuerySelectionState
expect(ctx.canEditSelectionMetadata.value).toBe(false)
libraries.value[1]!.accessLevel = 'editor'
expect(ctx.canEditSelectionMetadata.value).toBe(true)
libraries.value = []
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it('ignores podcast libraries when checking an unscoped book query', () => {
const ctx = context()
libraries.value = [
{ id: 7, type: 'books', accessLevel: 'editor' },
{ id: 10, type: 'podcasts', accessLevel: 'viewer' },
] as Library[]
ctx.query.value = { total: 10000 } as QuerySelectionState
expect(ctx.canEditSelectionMetadata.value).toBe(true)
})
it('reacts to global permission revocation and library-role downgrade', () => {
const ctx = context()
expect(ctx.canEditSelectionMetadata.value).toBe(true)
permissions.value = []
expect(ctx.canEditSelectionMetadata.value).toBe(false)
permissions.value = [Permission.LibraryEditMetadata]
libraries.value[0]!.accessLevel = 'viewer'
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it('retains editor scope when sorting or filtering replaces loaded cards', () => {
const ctx = context([1, 3])
expect(ctx.canEditSelectionMetadata.value).toBe(true)
ctx.books.value = [{ id: 2, libraryId: 8 }] as BookCard[]
expect(ctx.selectedIds.value.size).toBe(2)
expect(ctx.canEditSelectionMetadata.value).toBe(true)
libraries.value[0]!.accessLevel = 'viewer'
expect(ctx.canEditSelectionMetadata.value).toBe(false)
libraries.value[0]!.accessLevel = 'editor'
expect(ctx.canEditSelectionMetadata.value).toBe(true)
permissions.value = []
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it('retains viewer restrictions after the selected card leaves the window', () => {
const ctx = context([1, 2])
ctx.books.value = [{ id: 3, libraryId: 9 }] as BookCard[]
expect(ctx.canEditSelectionMetadata.value).toBe(false)
ctx.selectedIds.value.delete(2)
expect(ctx.canEditSelectionMetadata.value).toBe(true)
})
it('discards cached scope when a book is deselected', () => {
const ctx = context()
ctx.books.value = []
expect(ctx.canEditSelectionMetadata.value).toBe(true)
ctx.selectedIds.value.clear()
expect(ctx.canEditSelectionMetadata.value).toBe(false)
ctx.selectedIds.value.add(1)
expect(ctx.canEditSelectionMetadata.value).toBe(false)
ctx.books.value = [{ id: 1, libraryId: 8 }] as BookCard[]
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
it('updates retained scope when a selected book moves into a viewer library', () => {
const ctx = context()
expect(ctx.canEditSelectionMetadata.value).toBe(true)
ctx.books.value[0]!.libraryId = 8
expect(ctx.canEditSelectionMetadata.value).toBe(false)
ctx.books.value = []
expect(ctx.canEditSelectionMetadata.value).toBe(false)
})
})
@@ -33,6 +33,13 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mocks.routerPush }),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true, isSuperuser: ref(false) }),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([{ id: 7, accessLevel: 'editor' }]) }),
}))
vi.mock('../useBookNavigation', () => ({
useBookNavigation: () => ({ setBookContext: mocks.setBookContext }),
}))
@@ -78,6 +85,7 @@ import { useBookTableShell } from '../useBookTableShell'
function makeBook(overrides: Partial<BookCard> = {}): BookCard {
return {
id: 1,
libraryId: 7,
status: 'present',
coverAspectRatio: '2/3',
title: 'Test Book',
@@ -134,6 +134,15 @@ describe('useTableCellHelpers', () => {
expect(helpers.isCellReadOnly(book, { id: 'title', isEditable: true })).toBe(true)
})
it('locks metadata columns on rows whose library the user can only read, but keeps personal reading state editable', () => {
const canEdit = vi.fn<(book: BookCard) => boolean>((book) => book.libraryId === 1)
helpers = useTableCellHelpers(locks, () => false, undefined, canEdit)
expect(helpers.isCellReadOnly(makeBook({ libraryId: 2 }), { id: 'title', isEditable: true })).toBe(true)
expect(helpers.isCellReadOnly(makeBook({ libraryId: 2 }), { id: 'readStatus', isEditable: true })).toBe(false)
expect(helpers.isCellReadOnly(makeBook({ libraryId: 1 }), { id: 'title', isEditable: true })).toBe(false)
})
it('returns true when isReadOnly global flag is set', () => {
isReadOnly = true
const book = makeBook()
@@ -0,0 +1,40 @@
import { computed, shallowRef, watch, type Ref } from 'vue'
import { Permission, type BookCard } from '@bookorbit/types'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import type { QuerySelectionState } from './useBookBulkActions'
export function useBookSelectionPermissions(books: Ref<BookCard[]>, selectedIds: Ref<Set<number>>, querySelection?: Ref<QuerySelectionState | null>) {
const { hasLibraryPermission } = useLibraryPermissions()
const { libraries } = useLibraries()
const selectedLibraries = shallowRef(new Map<number, number | undefined>())
watch(
() => ({
ids: [...selectedIds.value],
loaded: books.value.filter((book) => selectedIds.value.has(book.id)).map((book) => ({ id: book.id, libraryId: book.libraryId })),
}),
({ ids, loaded }) => {
const next = new Map<number, number | undefined>()
for (const id of ids) {
if (selectedLibraries.value.has(id)) next.set(id, selectedLibraries.value.get(id))
}
for (const book of loaded) next.set(book.id, book.libraryId)
selectedLibraries.value = next
},
{ immediate: true, flush: 'sync' },
)
const canEditSelectionMetadata = computed(() => {
const query = querySelection?.value
if (query) {
if (query.libraryId != null) return hasLibraryPermission(Permission.LibraryEditMetadata, query.libraryId, 'editor')
const bookLibraries = libraries.value.filter((library) => library.type === 'books')
return bookLibraries.length > 0 && bookLibraries.every((library) => hasLibraryPermission(Permission.LibraryEditMetadata, library.id, 'editor'))
}
if (selectedIds.value.size === 0) return false
return [...selectedIds.value].every(
(id) => selectedLibraries.value.has(id) && hasLibraryPermission(Permission.LibraryEditMetadata, selectedLibraries.value.get(id), 'editor'),
)
})
return { canEditSelectionMetadata }
}
@@ -4,6 +4,7 @@ import { useRouter } from 'vue-router'
import type { BookCard } from '@bookorbit/types'
import { useTableViewControls } from './useTableViewControls'
import { useBookViewSelection } from './useBookViewSelection'
import { useBookSelectionPermissions } from './useBookSelectionPermissions'
import { useBookNavigation } from './useBookNavigation'
import { useDeleteBook } from './useDeleteBook'
import { useBookBulkActions, type QuerySelectionState } from './useBookBulkActions'
@@ -27,6 +28,7 @@ export function useBookTableShell({ books, selectionMode, querySelection, onMove
const { setBookContext } = useBookNavigation()
const tableControls = useTableViewControls()
const selection = useBookViewSelection(books, selectionMode)
const { canEditSelectionMetadata } = useBookSelectionPermissions(books, selection.selectedIds, querySelection)
const {
pendingId: deleteBookId,
@@ -100,6 +102,7 @@ export function useBookTableShell({ books, selectionMode, querySelection, onMove
return {
...tableControls,
...selection,
canEditSelectionMetadata,
deleteBookId,
deletingBook,
promptDelete,
@@ -3,10 +3,13 @@ import { isAudioFormat } from '@bookorbit/types'
import { COLUMN_DEF_MAP, isCustomColumnId, parseCustomFieldId, type ColumnDef } from './tableColumnSchema'
import type { useTableLocks } from './useTableLocks'
const PERSONAL_STATE_COLUMN_IDS = new Set(['readStatus'])
export function useTableCellHelpers(
locks: ReturnType<typeof useTableLocks>,
isReadOnly: () => boolean,
columnMapGetter?: () => Map<string, ColumnDef>,
canEditBookMetadata?: (book: BookCard) => boolean,
) {
function getCellValue(book: BookCard, colId: string): unknown {
const map = columnMapGetter ? columnMapGetter() : (COLUMN_DEF_MAP as Map<string, ColumnDef>)
@@ -26,6 +29,7 @@ export function useTableCellHelpers(
function isCellReadOnly(book: BookCard, col: { id: string; isEditable: boolean }): boolean {
if (!col.isEditable) return true
if (canEditBookMetadata && !PERSONAL_STATE_COLUMN_IDS.has(col.id) && !canEditBookMetadata(book)) return true
if (col.id === 'narrators' && !isBookAudio(book)) return true
if (isCellLocked(book, col.id)) return true
// Custom fields: block editing when the book has no entry for this field.
@@ -14,6 +14,7 @@ function toBookFileRef(file: BookDetail['files'][number]): BookFileRef {
export function mergeBookCardWithDetail(book: BookCard, detail: BookDetail): BookCard {
return {
...book,
libraryId: detail.libraryId,
status: detail.status,
title: detail.title,
subtitle: detail.subtitle,
@@ -208,5 +208,6 @@ onMounted(() => {
<ShieldCheck :size="13" class="shrink-0" aria-hidden="true" />
{{ t('library.creator.access.superusers') }}
</p>
<p class="text-xs text-muted-foreground">{{ t('library.creator.access.levelsHint') }}</p>
</div>
</template>
@@ -0,0 +1,96 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { Permission, type AccessLevel, type Library } from '@bookorbit/types'
const state = vi.hoisted(() => ({ superuser: false, permission: true }))
const libraries = ref<Library[]>([])
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
hasPermission: () => state.superuser || state.permission,
isSuperuser: {
get value() {
return state.superuser
},
},
}),
}))
vi.mock('../useLibraries', () => ({ useLibraries: () => ({ libraries }) }))
const { useLibraryPermissions } = await import('../useLibraryPermissions')
function library(id: number, accessLevel?: AccessLevel | null, type: Library['type'] = 'books'): Library {
return { id, type, accessLevel } as Library
}
beforeEach(() => {
state.superuser = false
state.permission = true
libraries.value = [library(1, 'editor'), library(2, 'viewer'), library(3, 'owner')]
})
describe('useLibraryPermissions', () => {
it.each([
[1, true],
[2, false],
[3, true],
[4, false],
[undefined, false],
[null, false],
] as const)('checks editor access for library %s', (id, expected) => {
expect(useLibraryPermissions().hasLibraryPermission(Permission.LibraryEditMetadata, id, 'editor')).toBe(expected)
})
it('requires the global permission even for editors and owners', () => {
state.permission = false
const { hasLibraryPermission } = useLibraryPermissions()
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 1, 'editor')).toBe(false)
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 3, 'editor')).toBe(false)
})
it('fails closed while libraries are loading or have no role', () => {
const { hasLibraryPermission } = useLibraryPermissions()
libraries.value = []
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 1, 'editor')).toBe(false)
libraries.value = [library(1), library(2, null)]
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 1, 'editor')).toBe(false)
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 2, 'editor')).toBe(false)
})
it('permits superusers without explicit permissions or membership', () => {
state.superuser = true
state.permission = false
libraries.value = []
expect(useLibraryPermissions().hasLibraryPermission(Permission.LibraryEditMetadata, 7, 'editor')).toBe(true)
})
it('rechecks a changed role and clears permissions on library reset', () => {
const { hasLibraryPermission } = useLibraryPermissions()
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 1, 'editor')).toBe(true)
libraries.value[0]!.accessLevel = 'viewer'
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 1, 'editor')).toBe(false)
libraries.value = []
expect(hasLibraryPermission(Permission.LibraryEditMetadata, 3, 'editor')).toBe(false)
})
it('preserves viewer-level access and requires owner for owner-level actions', () => {
const { hasLibraryPermission } = useLibraryPermissions()
expect(hasLibraryPermission(Permission.LibraryDownload, 2)).toBe(true)
expect(hasLibraryPermission(Permission.ManageLibraries, 1, 'owner')).toBe(false)
expect(hasLibraryPermission(Permission.ManageLibraries, 3, 'owner')).toBe(true)
})
it('reports whether any book library grants the permission at the required level', () => {
const { hasPermissionInAnyLibrary } = useLibraryPermissions()
expect(hasPermissionInAnyLibrary(Permission.LibraryEditMetadata, 'editor')).toBe(true)
libraries.value = [library(1, 'viewer'), library(2, 'editor', 'podcasts')]
expect(hasPermissionInAnyLibrary(Permission.LibraryEditMetadata, 'editor')).toBe(false)
state.superuser = true
expect(hasPermissionInAnyLibrary(Permission.LibraryEditMetadata, 'editor')).toBe(true)
state.superuser = false
state.permission = false
libraries.value = [library(1, 'owner')]
expect(hasPermissionInAnyLibrary(Permission.LibraryEditMetadata, 'editor')).toBe(false)
})
})
@@ -0,0 +1,29 @@
import { computed, type ComputedRef } from 'vue'
import type { AccessLevel } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraries } from './useLibraries'
let libraryAccess: ComputedRef<Map<number, AccessLevel | null | undefined>> | undefined
const accessRank: Record<AccessLevel, number> = { viewer: 1, editor: 2, owner: 3 }
export function useLibraryPermissions() {
const { hasPermission, isSuperuser } = usePermissions()
const { libraries } = useLibraries()
const access = (libraryAccess ??= computed(() => new Map(libraries.value.map((library) => [library.id, library.accessLevel]))))
function hasLibraryPermission(permission: string, libraryId: number | null | undefined, required: AccessLevel = 'viewer'): boolean {
if (!hasPermission(permission)) return false
if (isSuperuser.value) return true
if (libraryId == null) return false
const level = access.value.get(libraryId)
return level != null && accessRank[level] >= accessRank[required]
}
function hasPermissionInAnyLibrary(permission: string, required: AccessLevel = 'viewer'): boolean {
if (!hasPermission(permission)) return false
if (isSuperuser.value) return true
return libraries.value.some((library) => library.type === 'books' && hasLibraryPermission(permission, library.id, required))
}
return { hasLibraryPermission, hasPermissionInAnyLibrary }
}
@@ -31,6 +31,7 @@ const mocks = vi.hoisted(() => ({
libraryId: null as unknown as { value: number | null },
fetchSeriesBooks: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
api: vi.fn<(...args: unknown[]) => Promise<unknown>>(),
readOnlyLibraryIds: new Set<number>(),
}))
vi.mock('vue-router', async (importOriginal) => {
@@ -46,6 +47,14 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true }),
}))
vi.mock('@/features/library/composables/useLibraryPermissions', () => ({
useLibraryPermissions: () => ({
hasLibraryPermission: (_permission: string, libraryId: number | null | undefined) =>
libraryId == null || !mocks.readOnlyLibraryIds.has(libraryId),
hasPermissionInAnyLibrary: () => true,
}),
}))
vi.mock('@/features/book/composables/useBookNavigation', () => ({
useBookNavigation: () => ({ setBookContext: mocks.setBookContext }),
}))
@@ -274,6 +283,7 @@ describe('SeriesDetailView', () => {
mocks.order = ref('asc')
mocks.libraryId = ref<number | null>(null)
mocks.readOnlyLibraryIds.clear()
mocks.routerPush.mockReset()
mocks.routerPush.mockResolvedValue(undefined)
mocks.fetchLibraries.mockReset()
@@ -577,4 +587,22 @@ describe('SeriesDetailView', () => {
expect(mocks.loadBooks).not.toHaveBeenCalled()
expect(mocks.setBookContext).not.toHaveBeenCalled()
})
it('opens the series editor on the books the user can edit, skipping read-only libraries', async () => {
mocks.readOnlyLibraryIds.add(1)
mocks.items.value = [makeBook({ id: 101, libraryId: 1 }), makeBook({ id: 102, libraryId: 2 })]
mocks.total.value = 2
mocks.hasMore.value = false
const wrapper = mountView()
await nextTick()
mocks.setBookContext.mockClear()
const editBtn = wrapper.findAll('button').find((b) => b.text().includes('Edit Metadata'))
await editBtn!.trigger('click')
await flushPromises()
expect(mocks.setBookContext).toHaveBeenCalledWith([102], 1)
expect(mocks.routerPush).toHaveBeenCalledWith({ name: 'book-detail', params: { bookId: 102 }, query: { tab: 'edit' } })
})
})
@@ -13,9 +13,9 @@ import { bookCoverStyle } from '@/features/book/lib/book-cover'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
import { useScrollRestoreOnActivate } from '@/features/book/composables/useScrollRestoreOnActivate'
import { useDisplaySettings } from '@/composables/useDisplaySettings'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookNavigation } from '@/features/book/composables/useBookNavigation'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { usePageTitle } from '@/composables/usePageTitle'
import { useSafeHtml } from '@/features/book/composables/useSafeHtml'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
@@ -48,7 +48,6 @@ const route = useRoute()
const router = useRouter()
const mainRef = ref<HTMLElement | null>(null)
useScrollRestoreOnActivate(mainRef)
const { hasPermission } = usePermissions()
const { setBookContext } = useBookNavigation()
const { coverUrl } = useCoverVersions()
@@ -103,7 +102,11 @@ const leadBook = ref<{
let observer: IntersectionObserver | null = null
let leadBookRequestToken = 0
const canEditMetadata = computed(() => hasPermission('library_edit_metadata'))
const { hasLibraryPermission, hasPermissionInAnyLibrary } = useLibraryPermissions()
function canEditBookMetadata(book: BookCard): boolean {
return hasLibraryPermission('library_edit_metadata', book.libraryId, 'editor')
}
const canEditMetadata = computed(() => hasPermissionInAnyLibrary('library_edit_metadata', 'editor'))
const safeLeadDescription = useSafeHtml(() => leadBook.value?.description)
const visibleSeriesAuthors = computed(() => (seriesInfo.value?.authors ?? []).slice(0, 5))
const hiddenSeriesAuthorsCount = computed(() => Math.max(0, (seriesInfo.value?.authors.length ?? 0) - visibleSeriesAuthors.value.length))
@@ -378,7 +381,7 @@ async function editSeriesMetadata() {
return
}
const ids = books.value.map((book) => book.id)
const ids = books.value.filter(canEditBookMetadata).map((book) => book.id)
if (ids.length === 0) {
toast.error(t('series.detail.noBooksToEdit'))
return
+1
View File
@@ -9799,6 +9799,7 @@
"add": "Add",
"emptyCreate": "Nobody else yet. People you add here get access as soon as the library exists.",
"superusers": "Superusers always have full access.",
"levelsHint": "Viewers can browse and read. Editors and owners can also edit metadata when their account has the Edit metadata permission.",
"levelFor": "Access level for {name}",
"revokeFor": "Remove access for {name}",
"revokeQuestion": "Remove access?",
+9 -5
View File
@@ -12,7 +12,7 @@ import { useBookEvents } from '@/features/book/composables/useBookEvents'
import { useScanProgress } from '@/features/scanner/composables/useScanProgress'
import { usePageTitle } from '@/composables/usePageTitle'
import { normalizeBookDetailTab } from '@/features/book/lib/book-detail-tabs'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useLibraryPermissions } from '@/features/library/composables/useLibraryPermissions'
import { useLibraries } from '@/features/library/composables/useLibraries'
import { COVER_ASPECT_RATIO_KEY, DEFAULT_COVER_ASPECT_RATIO } from '@/features/book/lib/cover-aspect-ratio'
import { useCoverVersions } from '@/features/book/composables/useCoverVersions'
@@ -29,7 +29,7 @@ const KEPT_ALIVE_TABS = ['ReadingLogTab', 'HighlightsTab']
const { t } = useI18n()
const route = useRoute()
const { hasPermission } = usePermissions()
const { hasLibraryPermission } = useLibraryPermissions()
const { libraries } = useLibraries()
provide(
@@ -43,9 +43,13 @@ provide(
)
const bookId = computed(() => Number(route.params.bookId))
const tab = computed(() => normalizeBookDetailTab(route.query.tab))
const tab = computed(() => {
const requested = normalizeBookDetailTab(route.query.tab)
return requested === 'edit' && detail.value && !canEditMetadata.value ? 'details' : requested
})
const { detail, loading, notFound, fetch } = useBookDetail()
const canEditMetadata = computed(() => hasLibraryPermission('library_edit_metadata', detail.value?.libraryId, 'editor'))
const pageTitle = computed(() => {
const title = detail.value?.title?.trim()
const base = title || (Number.isFinite(bookId.value) ? t('views.bookDetail.titleWithId', { id: bookId.value }) : t('views.bookDetail.title'))
@@ -138,7 +142,7 @@ function onCoverChanged() {
</script>
<template>
<BookDetailLayout :book-id="bookId" :cover-tint="coverTint">
<BookDetailLayout :book-id="bookId" :cover-tint="coverTint" :can-edit-metadata="canEditMetadata">
<Transition name="content" mode="out-in">
<div v-if="detail" key="detail" class="@container/book-detail h-full">
<!--
@@ -149,7 +153,7 @@ function onCoverChanged() {
<KeepAlive :include="KEPT_ALIVE_TABS">
<DetailsTab v-if="tab === 'details'" :book="detail" @saved="onMetadataSaved" @moved="handleMovedToLibrary" />
<EditMetadataTab
v-else-if="tab === 'edit' && hasPermission('library_edit_metadata')"
v-else-if="tab === 'edit' && canEditMetadata"
:book="detail"
@saved="onMetadataSaved"
@locks-changed="onLocksChanged"
+2
View File
@@ -218,6 +218,7 @@ const {
handleImportPresetBackup,
selectedIds,
selectedCount,
canEditSelectionMetadata,
enterSelectionMode,
exitSelectionMode,
selectAll,
@@ -424,6 +425,7 @@ defineOptions({ name: 'CollectionView' })
/>
<SelectionActionBar
:metadata-editable="canEditSelectionMetadata"
:visible="selectionMode"
:count="selectedCount"
:in-collection="isCollectionOwner"
+2
View File
@@ -396,6 +396,7 @@ const {
handleImportPresetBackup,
selectedIds,
selectedCount,
canEditSelectionMetadata,
enterSelectionMode,
exitSelectionMode,
selectAll,
@@ -1058,6 +1059,7 @@ defineOptions({ name: 'HomeView' })
</Transition>
<SelectionActionBar
:metadata-editable="canEditSelectionMetadata"
:visible="selectionMode"
:count="querySelection ? querySelection.total : selectedCount"
:in-flight="inFlight"
+2
View File
@@ -224,6 +224,7 @@ const {
handleImportPresetBackup,
selectedIds,
selectedCount,
canEditSelectionMetadata,
enterSelectionMode,
exitSelectionMode,
selectAll,
@@ -475,6 +476,7 @@ defineOptions({ name: 'SmartScopeView' })
/>
<SelectionActionBar
:metadata-editable="canEditSelectionMetadata"
:visible="selectionMode"
:count="selectedCount"
:in-flight="inFlight"
@@ -9,6 +9,7 @@ const mockState = vi.hoisted(() => ({
detail: null as unknown as { value: BookDetail | null },
loading: null as unknown as { value: boolean },
notFound: null as unknown as { value: boolean },
libraries: null as unknown as { value: { id: number; accessLevel: string }[] },
fetch: vi.fn<(bookId: number) => void>(),
progressChangedCallback: null as ((event: BookProgressChangedEvent) => void) | null,
}))
@@ -52,12 +53,15 @@ vi.mock('@/composables/usePageTitle', () => ({
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({ hasPermission: () => true }),
usePermissions: () => ({ hasPermission: () => true, isSuperuser: { value: false } }),
}))
vi.mock('@/features/library/composables/useLibraries', () => ({
useLibraries: () => ({ libraries: ref([]) }),
}))
vi.mock('@/features/library/composables/useLibraries', async () => {
const { ref } = await import('vue')
const libraries = ref([{ id: 1, accessLevel: 'editor' }])
mockState.libraries = libraries
return { useLibraries: () => ({ libraries }) }
})
function makeBook(overrides: Partial<BookDetail> = {}): BookDetail {
return {
@@ -158,6 +162,7 @@ describe('BookDetailView', () => {
params: { bookId: '217' },
query: { tab: 'details' },
})
mockState.libraries.value = [{ id: 1, accessLevel: 'editor' }]
mockState.detail = ref(makeBook())
mockState.loading = ref(false)
mockState.notFound = ref(false)
@@ -165,6 +170,30 @@ describe('BookDetailView', () => {
mockState.progressChangedCallback = null
})
it('hides the editor for a viewer even when the URL explicitly selects edit', async () => {
mockState.libraries.value = [{ id: 1, accessLevel: 'viewer' }]
mockState.route.query = { tab: 'edit' }
const wrapper = mount(BookDetailView, {
global: {
stubs: {
BookDetailLayout: { name: 'BookDetailLayout', props: ['canEditMetadata'], template: '<div><slot /></div>' },
DetailsTab: DetailsTabStub,
EditMetadataTab: EditMetadataTabStub,
FilesTab: true,
},
},
})
expect(wrapper.find('[data-test="edit-rating"]').exists()).toBe(false)
expect(wrapper.find('[data-test="details-rating"]').exists()).toBe(true)
expect(wrapper.findComponent({ name: 'BookDetailLayout' }).props('canEditMetadata')).toBe(false)
mockState.libraries.value[0]!.accessLevel = 'owner'
await nextTick()
expect(wrapper.find('[data-test="edit-rating"]').exists()).toBe(true)
mockState.libraries.value = []
await nextTick()
expect(wrapper.find('[data-test="edit-rating"]').exists()).toBe(false)
})
it('keeps updated rating when switching details -> edit -> details tabs', async () => {
const wrapper = mount(BookDetailView, {
global: {