From 59e3f36f5eb66fad647e08a93e9bdc4cf1c8ab7b Mon Sep 17 00:00:00 2001
From: neon <18559658+neonsolstice@users.noreply.github.com>
Date: Wed, 14 Oct 2026 03:57:51 +0000
Subject: [PATCH] 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
---
client/src/components/SelectionActionBar.vue | 27 +-
.../__tests__/SelectionActionBar.spec.ts | 8 +
.../author/views/AuthorDetailView.spec.ts | 4 +
.../author/views/AuthorDetailView.vue | 6 +-
.../features/author/views/AuthorsView.spec.ts | 4 +
.../src/features/author/views/AuthorsView.vue | 6 +-
.../book/components/BookCoverCard.test.ts | 6 +-
.../book/components/BookCoverCard.vue | 13 +-
.../features/book/components/BookListRow.vue | 12 +-
.../book/components/BookQuickView.vue | 5 +-
.../book/components/VirtualBookTable.vue | 20 +-
.../__tests__/BookListRowMoveAction.spec.ts | 8 +-
.../__tests__/BookQuickView.spec.ts | 5 +
.../components/detail/BookDetailHeader.vue | 4 +-
.../components/detail/BookDetailLayout.vue | 4 +-
.../book/components/detail/BookDetailTabs.vue | 11 +-
.../detail/tabs/AddBookFileModal.vue | 8 +-
.../components/detail/tabs/DetailsTab.vue | 18 +-
.../book/components/detail/tabs/FilesTab.vue | 13 +-
.../detail/tabs/__tests__/DetailsTab.spec.ts | 23 +-
.../tabs/__tests__/DetailsTabActions.spec.ts | 8 +-
.../components/table/BookTableActionsCell.vue | 12 +-
.../table/BookTableCellDispatcher.vue | 37 ++-
.../components/table/BookTableContextMenu.vue | 9 +-
.../table/BookTableLockableCell.vue | 4 +-
.../__tests__/BookTableCellDispatcher.spec.ts | 13 +
.../__tests__/BookTableContextMenu.spec.ts | 9 +-
.../useBookSelectionPermissions.spec.ts | 134 ++++++++
.../__tests__/useBookTableShell.spec.ts | 8 +
.../__tests__/useTableCellHelpers.spec.ts | 9 +
.../useBookSelectionPermissions.ts | 40 +++
.../book/composables/useBookTableShell.ts | 3 +
.../book/composables/useTableCellHelpers.ts | 4 +
.../src/features/book/lib/book-card-mapper.ts | 1 +
.../components/LibraryCreatorAccess.vue | 1 +
.../__tests__/useLibraryPermissions.spec.ts | 96 ++++++
.../composables/useLibraryPermissions.ts | 29 ++
.../series/views/SeriesDetailView.spec.ts | 28 ++
.../series/views/SeriesDetailView.vue | 11 +-
client/src/locales/en.json | 1 +
client/src/views/BookDetailView.vue | 14 +-
client/src/views/CollectionView.vue | 2 +
client/src/views/HomeView.vue | 2 +
client/src/views/SmartScopeView.vue | 2 +
.../views/__tests__/BookDetailView.spec.ts | 37 ++-
packages/types/src/book.ts | 1 +
.../modules/authors/authors.service.test.ts | 11 +
server/src/modules/authors/authors.service.ts | 6 +-
.../modules/book/book-metadata-access.test.ts | 310 ++++++++++++++++++
.../src/modules/book/book.controller.test.ts | 17 +-
server/src/modules/book/book.controller.ts | 4 +-
server/src/modules/book/book.repository.ts | 13 +
server/src/modules/book/book.service.test.ts | 82 +++--
server/src/modules/book/book.service.ts | 61 ++--
.../book/utils/assemble-book-cards.test.ts | 5 +
.../modules/book/utils/assemble-book-cards.ts | 2 +
.../src/modules/cover/cover.service.test.ts | 2 +-
server/src/modules/cover/cover.service.ts | 2 +-
.../entity-manager.service.test.ts | 22 ++
.../entity-manager/entity-manager.service.ts | 8 +-
.../strategies/entity-book-scope.ts | 2 +-
.../src/modules/library/library.constants.ts | 1 +
.../src/modules/library/library.repository.ts | 9 +-
.../modules/library/library.service.test.ts | 15 +
server/src/modules/library/library.service.ts | 8 +
.../scanner/scanner.repository.test.ts | 2 +-
.../src/modules/scanner/scanner.repository.ts | 1 +
.../src/modules/upload/upload.service.test.ts | 40 ++-
server/src/modules/upload/upload.service.ts | 2 +-
.../src/modules/user/user.repository.test.ts | 19 +-
server/src/modules/user/user.repository.ts | 18 +-
server/src/modules/user/user.service.test.ts | 6 +-
server/src/modules/user/user.service.ts | 2 +-
server/test/metadata-write.e2e-spec.ts | 142 ++++++++
server/test/users-admin-lifecycle.e2e-spec.ts | 33 ++
75 files changed, 1407 insertions(+), 158 deletions(-)
create mode 100644 client/src/features/book/composables/__tests__/useBookSelectionPermissions.spec.ts
create mode 100644 client/src/features/book/composables/useBookSelectionPermissions.ts
create mode 100644 client/src/features/library/composables/__tests__/useLibraryPermissions.spec.ts
create mode 100644 client/src/features/library/composables/useLibraryPermissions.ts
create mode 100644 server/src/modules/book/book-metadata-access.test.ts
diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue
index bbbda404b..d739a6bdf 100644
--- a/client/src/components/SelectionActionBar.vue
+++ b/client/src/components/SelectionActionBar.vue
@@ -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(
{{ t('components.selectionActionBar.setReadingStatus') }}
-
+
{{ t('book.actions.peek') }}
-
+