From c85379e4ff982319fc6762eab8cf4c8750dcfb22 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Sat, 25 Apr 2026 23:34:57 -0600 Subject: [PATCH] feat(auth): enforce edit restrictions on demo-restricted accounts --- client/src/components/AppHeader.vue | 13 +- client/src/components/SelectionActionBar.vue | 174 ++++++++++++---- .../__tests__/SelectionActionBar.spec.ts | 171 ++++++++++++++++ client/src/features/admin/UserFormDrawer.vue | 4 + .../__tests__/usePermissions.spec.ts | 49 +++++ .../auth/composables/usePermissions.ts | 9 +- .../src/features/author/views/AuthorsView.vue | 8 +- .../book-dock/components/BookDockToolbar.vue | 15 +- .../__tests__/BookDockToolbar.spec.ts | 187 ++++++++++++++++++ .../src/features/settings/AccountSettings.vue | 67 +++++-- .../src/features/settings/SettingsHeader.vue | 8 +- .../features/tools/components/ToolsHeader.vue | 2 +- client/src/router/index.ts | 11 ++ client/src/views/BookDockView.vue | 3 + packages/types/src/permissions.ts | 2 + .../decorators/common-decorators.test.ts | 8 + .../decorators/forbid-permission.decorator.ts | 12 ++ .../common/guards/permission.guard.test.ts | 75 ++++++- server/src/common/guards/permission.guard.ts | 11 +- .../services/permission.service.test.ts | 4 + .../src/common/services/permission.service.ts | 4 + .../src/modules/auth/auth.controller.test.ts | 30 +++ server/src/modules/auth/auth.controller.ts | 6 +- .../authors/authors.controller.test.ts | 10 + .../src/modules/authors/authors.controller.ts | 2 + .../book-dock/book-dock.controller.test.ts | 10 + .../modules/book-dock/book-dock.controller.ts | 2 + .../src/modules/book/book.controller.test.ts | 35 ++++ server/src/modules/book/book.controller.ts | 9 + .../notification.controller.test.ts | 8 + .../notification/notification.controller.ts | 2 + .../notification/notification.gateway.test.ts | 63 ++++++ .../notification/notification.gateway.ts | 3 + .../src/modules/user/user.controller.test.ts | 29 +++ server/src/modules/user/user.controller.ts | 4 + server/test/guard-mechanics.e2e-spec.ts | 42 +++- 36 files changed, 1010 insertions(+), 82 deletions(-) create mode 100644 client/src/components/__tests__/SelectionActionBar.spec.ts create mode 100644 client/src/features/auth/composables/__tests__/usePermissions.spec.ts create mode 100644 client/src/features/book-dock/components/__tests__/BookDockToolbar.spec.ts create mode 100644 server/src/common/decorators/forbid-permission.decorator.ts create mode 100644 server/src/modules/notification/notification.gateway.test.ts diff --git a/client/src/components/AppHeader.vue b/client/src/components/AppHeader.vue index 5534dfa8c..fc282bedc 100644 --- a/client/src/components/AppHeader.vue +++ b/client/src/components/AppHeader.vue @@ -41,7 +41,7 @@ const router = useRouter() const route = useRoute() const { user, logout } = useAuth() const { open: openChangePassword } = useChangePasswordDialog() -const { hasPermission } = usePermissions() +const { hasPermission, isDemoRestrictedAccount } = usePermissions() const { onLibraryUploadCompleted } = useLibraryUploadEvents() const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary() const { subscribe: subscribeNotifications } = useNotifications() @@ -51,7 +51,10 @@ const isBookDockActive = computed(() => route.name === 'book-dock') const isStatisticsActive = computed(() => route.name === 'statistics') const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) -const canChangePassword = computed(() => user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared') +const canChangePassword = computed( + () => !isDemoRestrictedAccount.value && user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared', +) +const canAccessNotifications = computed(() => hasPermission('notification_access') && !isDemoRestrictedAccount.value) function navigateToBookDock() { router.push({ name: 'book-dock' }) @@ -156,7 +159,7 @@ onMounted(() => { fetchBookDockSummary() subscribeBookDockSummary() } - if (hasPermission('notification_access')) { + if (canAccessNotifications.value) { subscribeNotifications() } }) @@ -442,7 +445,7 @@ function formatBadgeClass(fmt: string): string {
- +
@@ -546,7 +549,7 @@ function formatBadgeClass(fmt: string): string { - + diff --git a/client/src/components/SelectionActionBar.vue b/client/src/components/SelectionActionBar.vue index 1a4785f59..1109b123f 100644 --- a/client/src/components/SelectionActionBar.vue +++ b/client/src/components/SelectionActionBar.vue @@ -64,7 +64,7 @@ const emit = defineEmits<{ exit: [] }>() -const { hasPermission } = usePermissions() +const { hasPermission, isDemoRestrictedAccount } = usePermissions() const confirmingDelete = ref(false) const deleteInput = ref('') const exportMenuOpen = ref(false) @@ -72,6 +72,7 @@ const ratingMenuOpen = ref(false) const moreMenuOpen = ref(false) const slots = useSlots() const hasCustomContent = computed(() => Boolean(slots.content)) +const canBulkActions = computed(() => !isDemoRestrictedAccount.value) const canConfirmDelete = computed(() => props.count <= 50 || deleteInput.value === 'DELETE') @@ -163,16 +164,26 @@ watch( - Send via email - + - @@ -181,7 +192,12 @@ watch( - @@ -201,57 +217,84 @@ watch( Remove from collection - + - Edit metadata - - + + + + + + + + + + + {{ opt.label }} + + + + + + Set reading status + + + + - - - - - {{ opt.label }} - - - - - - - Set rating - + - Edit tags -
+
- + - @@ -262,7 +305,12 @@ watch( - @@ -304,18 +352,58 @@ watch( diff --git a/client/src/components/__tests__/SelectionActionBar.spec.ts b/client/src/components/__tests__/SelectionActionBar.spec.ts new file mode 100644 index 000000000..f6b8ad987 --- /dev/null +++ b/client/src/components/__tests__/SelectionActionBar.spec.ts @@ -0,0 +1,171 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { nextTick } from 'vue' +import SelectionActionBar from '../SelectionActionBar.vue' + +const permissionState = { + allowed: new Set(), + demoRestricted: false, +} + +vi.mock('@/features/auth/composables/usePermissions', () => ({ + usePermissions: () => ({ + hasPermission: (name: string) => permissionState.allowed.has(name), + isDemoRestrictedAccount: { + get value() { + return permissionState.demoRestricted + }, + }, + }), +})) + +const globalStubs = { + stubs: { + DropdownMenu: { template: '
' }, + DropdownMenuTrigger: { template: '
' }, + DropdownMenuContent: { template: '
' }, + DropdownMenuItem: { template: '' }, + Tooltip: { template: '
' }, + TooltipTrigger: { template: '
' }, + TooltipContent: { template: '
' }, + TooltipProvider: { template: '
' }, + }, +} + +function mountBar() { + return mount(SelectionActionBar, { + props: { + visible: true, + count: 3, + inCollection: false, + inFlight: null, + }, + global: globalStubs, + }) +} + +describe('SelectionActionBar demo restriction', () => { + beforeEach(() => { + permissionState.allowed = new Set(['library_edit_metadata', 'library_download', 'email_send', 'library_delete_books']) + permissionState.demoRestricted = false + }) + + it('shows bulk-edit controls for non-restricted accounts', async () => { + const wrapper = mountBar() + + expect(wrapper.find('[data-testid="action-export"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-edit-metadata"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-set-status"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-set-rating"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-edit-tags"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-more"]').exists()).toBe(true) + + await wrapper.find('[data-testid="action-bulk-more"]').trigger('click') + await nextTick() + + expect(wrapper.find('[data-testid="action-bulk-refresh-metadata"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-re-extract-cover"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-lock-metadata"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-unlock-metadata"]').exists()).toBe(true) + }) + + it('hides bulk-edit controls for demo-restricted accounts', () => { + permissionState.demoRestricted = true + const wrapper = mountBar() + + expect(wrapper.find('[data-testid="action-export"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-bulk-edit-metadata"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-bulk-set-status"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-bulk-set-rating"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-bulk-edit-tags"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-bulk-more"]').exists()).toBe(false) + expect(wrapper.find('[data-testid="action-add-to-collection"]').exists()).toBe(true) + }) + + it('emits add/send/export/status/rating/tag actions from default controls', async () => { + const wrapper = mountBar() + + await wrapper.find('[data-testid="action-add-to-collection"]').trigger('click') + expect(wrapper.emitted('add-to-collection')).toHaveLength(1) + + await wrapper.find('[data-testid="action-send-email"]').trigger('click') + await wrapper.find('[data-testid="action-export"]').trigger('click') + await wrapper + .findAll('button') + .find((btn) => btn.text() === 'Primary only') + ?.trigger('click') + expect(wrapper.emitted('send')).toHaveLength(1) + expect(wrapper.emitted('export')?.[0]).toEqual(['primary']) + + await wrapper.find('[data-testid="action-bulk-set-status"]').trigger('click') + await wrapper + .findAll('button') + .find((btn) => btn.text().includes('Reading')) + ?.trigger('click') + expect(wrapper.emitted('set-status')?.[0]).toEqual(['reading']) + + await wrapper.find('[data-testid="action-bulk-set-rating"]').trigger('click') + await nextTick() + await wrapper + .findAll('button') + .find((btn) => btn.text() === '5') + ?.trigger('click') + expect(wrapper.emitted('set-rating')?.[0]).toEqual([5]) + + await wrapper.find('[data-testid="action-bulk-edit-tags"]').trigger('click') + expect(wrapper.emitted('edit-tags')).toHaveLength(1) + }) + + it('emits bulk metadata actions from More menu', async () => { + const wrapper = mountBar() + await wrapper.find('[data-testid="action-bulk-more"]').trigger('click') + await nextTick() + + await wrapper.find('[data-testid="action-bulk-refresh-metadata"]').trigger('click') + await wrapper.find('[data-testid="action-bulk-re-extract-cover"]').trigger('click') + await wrapper.find('[data-testid="action-bulk-lock-metadata"]').trigger('click') + await wrapper.find('[data-testid="action-bulk-unlock-metadata"]').trigger('click') + + expect(wrapper.emitted('refresh-metadata')).toHaveLength(1) + expect(wrapper.emitted('re-extract-cover')).toHaveLength(1) + expect(wrapper.emitted('lock-metadata')?.map((args) => args[0])).toEqual([true, false]) + }) + + it('requires DELETE confirmation text when count exceeds 50', async () => { + const wrapper = mount(SelectionActionBar, { + props: { visible: true, count: 75, inCollection: false, inFlight: null }, + global: globalStubs, + }) + + await wrapper.find('[data-testid="action-delete"]').trigger('click') + await nextTick() + + const confirmButton = wrapper.findAll('button').find((btn) => btn.text() === 'Delete') + expect(confirmButton?.attributes('disabled')).toBeDefined() + + const input = wrapper.find('input[placeholder="Type DELETE"]') + await input.setValue('DELETE') + await nextTick() + expect( + wrapper + .findAll('button') + .find((btn) => btn.text() === 'Delete') + ?.attributes('disabled'), + ).toBeUndefined() + }) + + it('resets transient menu state when visibility is turned off', async () => { + const wrapper = mountBar() + await wrapper.find('[data-testid="action-bulk-more"]').trigger('click') + await nextTick() + expect(wrapper.find('[data-testid="action-bulk-refresh-metadata"]').exists()).toBe(true) + + await wrapper.setProps({ visible: false }) + await nextTick() + await wrapper.setProps({ visible: true }) + await nextTick() + + expect(wrapper.find('[data-testid="action-bulk-more"]').exists()).toBe(true) + expect(wrapper.find('[data-testid="action-bulk-refresh-metadata"]').exists()).toBe(false) + }) +}) diff --git a/client/src/features/admin/UserFormDrawer.vue b/client/src/features/admin/UserFormDrawer.vue index dc14a65f2..443f73fff 100644 --- a/client/src/features/admin/UserFormDrawer.vue +++ b/client/src/features/admin/UserFormDrawer.vue @@ -38,6 +38,10 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [ label: 'Administration', permissions: [Permission.ManageLibraries, Permission.ManageMetadataConfig, Permission.ManageAppSettings, Permission.ManageUsers], }, + { + label: 'Restrictions', + permissions: [Permission.DemoRestricted], + }, ] const name = ref('') diff --git a/client/src/features/auth/composables/__tests__/usePermissions.spec.ts b/client/src/features/auth/composables/__tests__/usePermissions.spec.ts new file mode 100644 index 000000000..88a43e363 --- /dev/null +++ b/client/src/features/auth/composables/__tests__/usePermissions.spec.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, beforeEach, vi } from 'vitest' +import { Permission } from '@bookorbit/types' +import { usePermissions } from '../usePermissions' + +let currentUser: { isSuperuser?: boolean; permissions?: string[] } | null = null + +vi.mock('../useAuth', () => ({ + useAuth: () => ({ + user: { + get value() { + return currentUser + }, + }, + }), +})) + +describe('usePermissions', () => { + beforeEach(() => { + currentUser = null + }) + + it('returns false permissions and marker flags for anonymous state', () => { + const { hasPermission, hasExplicitPermission, isDemoRestrictedAccount, isSuperuser, userPermissions } = usePermissions() + + expect(hasPermission(Permission.LibraryDownload)).toBe(false) + expect(hasExplicitPermission(Permission.LibraryDownload)).toBe(false) + expect(isDemoRestrictedAccount.value).toBe(false) + expect(isSuperuser.value).toBe(false) + expect(userPermissions.value).toEqual([]) + }) + + it('grants permission checks for explicit user permissions', () => { + currentUser = { isSuperuser: false, permissions: [Permission.LibraryDownload, Permission.DemoRestricted] } + const { hasPermission, hasExplicitPermission, isDemoRestrictedAccount } = usePermissions() + + expect(hasPermission(Permission.LibraryDownload)).toBe(true) + expect(hasExplicitPermission(Permission.LibraryDownload)).toBe(true) + expect(isDemoRestrictedAccount.value).toBe(true) + }) + + it('keeps explicit checks strict even for superusers', () => { + currentUser = { isSuperuser: true, permissions: [] } + const { hasPermission, hasExplicitPermission, isDemoRestrictedAccount } = usePermissions() + + expect(hasPermission(Permission.ManageUsers)).toBe(true) + expect(hasExplicitPermission(Permission.ManageUsers)).toBe(false) + expect(isDemoRestrictedAccount.value).toBe(false) + }) +}) diff --git a/client/src/features/auth/composables/usePermissions.ts b/client/src/features/auth/composables/usePermissions.ts index ea0388aba..ccd97f51b 100644 --- a/client/src/features/auth/composables/usePermissions.ts +++ b/client/src/features/auth/composables/usePermissions.ts @@ -1,4 +1,5 @@ import { computed } from 'vue' +import { Permission } from '@bookorbit/types' import { useAuth } from './useAuth' export function usePermissions() { @@ -11,5 +12,11 @@ export function usePermissions() { return isSuperuser.value || userPermissions.value.includes(name) } - return { hasPermission, isSuperuser, userPermissions } + function hasExplicitPermission(name: string): boolean { + return userPermissions.value.includes(name) + } + + const isDemoRestrictedAccount = computed(() => hasExplicitPermission(Permission.DemoRestricted)) + + return { hasPermission, hasExplicitPermission, isDemoRestrictedAccount, isSuperuser, userPermissions } } diff --git a/client/src/features/author/views/AuthorsView.vue b/client/src/features/author/views/AuthorsView.vue index df67c51fb..fa35bc04a 100644 --- a/client/src/features/author/views/AuthorsView.vue +++ b/client/src/features/author/views/AuthorsView.vue @@ -25,7 +25,7 @@ const router = useRouter() const route = useRoute() const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings() const { libraries, fetchLibraries } = useLibraries() -const { hasPermission, isSuperuser } = usePermissions() +const { hasPermission, isDemoRestrictedAccount, isSuperuser } = usePermissions() const { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, minBookCount, load } = useAuthorsList() const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors() const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleAuthor, rangeSelectTo, selectAll, isSelected } = @@ -52,7 +52,7 @@ const confirmingBulkDelete = ref(false) const pendingDeleteIds = ref([]) const deleteDialogOpen = ref(false) -const canRefreshMetadata = computed(() => hasPermission('library_edit_metadata')) +const canRefreshMetadata = computed(() => hasPermission('library_edit_metadata') && !isDemoRestrictedAccount.value) const canDeleteAuthors = computed(() => isSuperuser.value) const SORT_LABELS: Record = { @@ -256,7 +256,7 @@ async function clearFilters() { async function refreshSelectedAuthorsMetadata() { const ids = [...selectedIds.value] - if (ids.length === 0 || bulkRefreshing.value) return + if (ids.length === 0 || bulkRefreshing.value || !canRefreshMetadata.value) return bulkRefreshing.value = true markRefreshing(ids) @@ -766,7 +766,7 @@ watch( Select visible
- +
+

+ Demo-restricted account: editing profile, password, avatar, and linked identities is disabled. +

@@ -280,7 +309,11 @@ function closeUnlinkDialog() { v-model="formName" type="text" autocomplete="name" - class="w-full rounded-md border border-input bg-background px-3 py-2 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/50" + :readonly="accountEditBlocked" + :class="[ + 'w-full rounded-md border border-input px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/50', + accountEditBlocked ? 'bg-muted/50 text-muted-foreground cursor-not-allowed' : 'bg-background text-foreground', + ]" />
@@ -296,14 +329,14 @@ function closeUnlinkDialog() {
- + - @@ -409,7 +446,8 @@ function closeUnlinkDialog() {