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 @@