From d29b06c6e85cb4076e1c8bb4523950de3f289914 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 24 Oct 2026 03:56:25 +0000 Subject: [PATCH] fix(auth): restore metadata editing lost to the 3.5.0 library roles Promote Viewer grants to Editor on book libraries for non-superusers who hold Edit metadata but can edit nowhere, which is how every such account was left after the upgrade. Accounts with any Editor or Owner role are left alone. The Users drawer now says where Edit metadata takes effect, read from a new GET /users/:id/library-access route. Closes #1902 --- client/src/features/admin/UserFormDrawer.vue | 25 +- .../admin/__tests__/UserFormDrawer.spec.ts | 74 +- .../components/UserPermissionsSection.vue | 68 +- .../features/admin/composables/useUserForm.ts | 24 +- .../features/admin/composables/useUsers.ts | 2 +- client/src/locales/en.json | 4 + packages/types/src/library.ts | 5 + scripts/e2e/suite-registry.mjs | 17 + .../0131_restore_metadata_editor_access.sql | 25 + .../src/db/migrations/meta/0131_snapshot.json | 25806 ++++++++++++++++ server/src/db/migrations/meta/_journal.json | 7 + .../src/modules/user/user.controller.test.ts | 3 + server/src/modules/user/user.controller.ts | 6 + .../src/modules/user/user.repository.test.ts | 5 + server/src/modules/user/user.repository.ts | 16 +- server/src/modules/user/user.service.test.ts | 21 + server/src/modules/user/user.service.ts | 8 +- .../route-inventory/manifest.json | 4 +- .../route-inventory/routes-06.json | 12 + ...tadata-editor-access-migration.e2e-spec.ts | 111 + 20 files changed, 26199 insertions(+), 44 deletions(-) create mode 100644 server/src/db/migrations/0131_restore_metadata_editor_access.sql create mode 100644 server/src/db/migrations/meta/0131_snapshot.json create mode 100644 server/test/metadata-editor-access-migration.e2e-spec.ts diff --git a/client/src/features/admin/UserFormDrawer.vue b/client/src/features/admin/UserFormDrawer.vue index bcb22ed04..4344aeedb 100644 --- a/client/src/features/admin/UserFormDrawer.vue +++ b/client/src/features/admin/UserFormDrawer.vue @@ -14,11 +14,12 @@ import { X, type LucideIcon, } from '@lucide/vue' +import { Permission, type LibraryType } from '@bookorbit/types' import { Button } from '@/components/ui/button' import { Sheet, SheetContent, SheetDescription, SheetTitle } from '@/components/ui/sheet' import Badge from '@/components/ui/badge/Badge.vue' import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' -import { formatDate, formatDateTime, formatNumber } from '@/i18n/formatters' +import { formatDate, formatDateTime, formatList, formatNumber } from '@/i18n/formatters' import StatusPill from './components/StatusPill.vue' import UserPermissionsSection from './components/UserPermissionsSection.vue' import UserRestrictionsSection from './components/UserRestrictionsSection.vue' @@ -27,6 +28,7 @@ import { useUserForm, type UserFormSection, type UserFormTarget } from './compos interface LibraryOption { id: number name: string + type?: LibraryType bookCount?: number } @@ -57,6 +59,7 @@ const { isSharedAccount, selectedPermissionNames, selectedLibraryIds, + editorLibraryIds, includeTagItems, excludeTagItems, includeGenreItems, @@ -183,6 +186,25 @@ const libraryReach = computed(() => { return { selected: formatNumber(selected), total: formatNumber(total) } }) +/** + * Edit metadata only works on book libraries where the account is Editor or Owner, which is set on + * each library rather than here. Say so wherever that leaves the permission short of the selection. + */ +const permissionNotes = computed(() => { + if (isSuperuserTarget.value || !selectedPermissionNames.value.has(Permission.LibraryEditMetadata)) return {} + const bookLibraries = props.libraries.filter((library) => library.type !== 'podcasts' && selectedLibraryIds.value.has(library.id)) + const viewerOnly = bookLibraries.filter((library) => !editorLibraryIds.value.has(library.id)) + if (bookLibraries.length > 0 && viewerOnly.length === 0) return {} + const note = + viewerOnly.length === bookLibraries.length + ? { text: t('adminFeature.userForm.permissionNotes.editMetadataNowhere'), warning: true } + : { + text: t('adminFeature.userForm.permissionNotes.editMetadataExcept', { libraries: formatList(viewerOnly.map((library) => library.name)) }), + warning: false, + } + return { [Permission.LibraryEditMetadata]: note } +}) + const errorMessage = computed(() => { if (error.value) return error.value if (!errorKey.value) return null @@ -540,6 +562,7 @@ async function handleSubmit() { :selected="selectedPermissionNames" :granted="grantedCount" :total="totalPermissions" + :notes="permissionNotes" @toggle="togglePermission" @preset="applyPreset" /> diff --git a/client/src/features/admin/__tests__/UserFormDrawer.spec.ts b/client/src/features/admin/__tests__/UserFormDrawer.spec.ts index 87e8e2e08..c60067582 100644 --- a/client/src/features/admin/__tests__/UserFormDrawer.spec.ts +++ b/client/src/features/admin/__tests__/UserFormDrawer.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { flushPromises, mount } from '@vue/test-utils' -import { Permission } from '@bookorbit/types' +import { Permission, type UserLibraryGrant } from '@bookorbit/types' import UserFormDrawer from '../UserFormDrawer.vue' import { grantingPermissions } from '../lib/permission-presets' @@ -20,9 +20,9 @@ vi.mock('@/components/ui/sheet', () => ({ })) const LIBRARIES = [ - { id: 1, name: 'Novels', bookCount: 120 }, - { id: 2, name: 'Comics', bookCount: 30 }, - { id: 3, name: 'Podcasts', bookCount: 0 }, + { id: 1, name: 'Novels', type: 'books' as const, bookCount: 120 }, + { id: 2, name: 'Comics', type: 'books' as const, bookCount: 30 }, + { id: 3, name: 'Podcasts', type: 'podcasts' as const, bookCount: 0 }, ] const USER = { @@ -41,9 +41,9 @@ function jsonResponse(body: unknown) { return { ok: true, json: async () => body } } -function stubApi(overrides: { libraryIds?: number[]; filters?: unknown } = {}) { +function stubApi(overrides: { grants?: UserLibraryGrant[]; filters?: unknown } = {}) { apiMock.mockImplementation(async (input: string) => { - if (input.endsWith('/libraries')) return jsonResponse(overrides.libraryIds ?? [1]) + if (input.endsWith('/library-access')) return jsonResponse(overrides.grants ?? [{ libraryId: 1, accessLevel: 'viewer' }]) if (input.endsWith('/content-filters')) { return jsonResponse(overrides.filters ?? { includeTags: [], excludeTags: [], includeGenres: [], excludeGenres: [] }) } @@ -132,7 +132,7 @@ describe('UserFormDrawer sections', () => { }) it('warns when no library is selected at all', async () => { - stubApi({ libraryIds: [] }) + stubApi({ grants: [] }) const wrapper = await mountDrawer() await openSection(wrapper, 'Libraries') @@ -203,6 +203,64 @@ describe('UserFormDrawer permissions', () => { expect(wrapper.text()).not.toContain('Upload books') }) + describe('Edit metadata note', () => { + const editor = { ...USER, permissions: [Permission.LibraryEditMetadata] } + + function noteFor(wrapper: Drawer) { + const id = checkboxFor(wrapper, 'Edit metadata')?.attributes('aria-describedby') + return id ? wrapper.find(`[id="${id}"]`) : undefined + } + + it('warns that the permission does nothing while every book library is Viewer only', async () => { + stubApi({ + grants: [ + { libraryId: 1, accessLevel: 'viewer' }, + { libraryId: 3, accessLevel: 'owner' }, + ], + }) + const wrapper = await mountDrawer({ user: editor }) + await openSection(wrapper, 'Permissions') + + expect(noteFor(wrapper)?.text()).toContain('No effect yet') + }) + + it('names the libraries where the account is still a Viewer', async () => { + stubApi({ + grants: [ + { libraryId: 1, accessLevel: 'editor' }, + { libraryId: 2, accessLevel: 'viewer' }, + ], + }) + const wrapper = await mountDrawer({ user: editor }) + await openSection(wrapper, 'Permissions') + + expect(noteFor(wrapper)?.text()).toContain('Not in Comics, where this account is a Viewer.') + }) + + it('treats a library ticked in this form as Viewer, since that is how it saves', async () => { + stubApi({ grants: [{ libraryId: 1, accessLevel: 'owner' }] }) + const wrapper = await mountDrawer({ user: editor }) + await openSection(wrapper, 'Permissions') + expect(noteFor(wrapper)).toBeUndefined() + + await openSection(wrapper, 'Libraries') + await checkboxFor(wrapper, 'Comics')?.trigger('change') + await openSection(wrapper, 'Permissions') + + expect(noteFor(wrapper)?.text()).toContain('Not in Comics') + }) + + it('stays quiet without the permission or for a superuser', async () => { + const wrapper = await mountDrawer() + await openSection(wrapper, 'Permissions') + expect(wrapper.text()).not.toContain('No effect yet') + + const superuser = await mountDrawer({ user: { ...editor, isSuperuser: true } }) + await openSection(superuser, 'Permissions') + expect(superuser.text()).not.toContain('No effect yet') + }) + }) + it('says so when a filter matches nothing', async () => { const wrapper = await mountDrawer() await openSection(wrapper, 'Permissions') @@ -246,7 +304,7 @@ describe('UserFormDrawer saving', () => { if (input.endsWith('/permissions') && init?.method === 'PUT') { return { ok: false, json: async () => ({ message: 'Permission denied' }) } } - if (input.endsWith('/libraries')) return jsonResponse([1]) + if (input.endsWith('/library-access')) return jsonResponse([{ libraryId: 1, accessLevel: 'viewer' }]) if (input.endsWith('/content-filters')) return jsonResponse({}) return jsonResponse({}) }) diff --git a/client/src/features/admin/components/UserPermissionsSection.vue b/client/src/features/admin/components/UserPermissionsSection.vue index 38a7e98e4..78fea5407 100644 --- a/client/src/features/admin/components/UserPermissionsSection.vue +++ b/client/src/features/admin/components/UserPermissionsSection.vue @@ -1,7 +1,7 @@