mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user