feat(auth): enforce edit restrictions on demo-restricted accounts

This commit is contained in:
neon
2026-04-25 23:34:57 -06:00
parent 1cd1291a1f
commit c85379e4ff
36 changed files with 1010 additions and 82 deletions
+8 -5
View File
@@ -41,7 +41,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const { user, logout } = useAuth() const { user, logout } = useAuth()
const { open: openChangePassword } = useChangePasswordDialog() const { open: openChangePassword } = useChangePasswordDialog()
const { hasPermission } = usePermissions() const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const { onLibraryUploadCompleted } = useLibraryUploadEvents() const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary() const { summary: bookDockSummary, fetchSummary: fetchBookDockSummary, subscribe: subscribeBookDockSummary } = useBookDockSummary()
const { subscribe: subscribeNotifications } = useNotifications() const { subscribe: subscribeNotifications } = useNotifications()
@@ -51,7 +51,10 @@ const isBookDockActive = computed(() => route.name === 'book-dock')
const isStatisticsActive = computed(() => route.name === 'statistics') const isStatisticsActive = computed(() => route.name === 'statistics')
const iconRadiusClass = computed(() => (themeStore.radius === 'sharp' ? 'rounded-none' : 'rounded-full')) 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() { function navigateToBookDock() {
router.push({ name: 'book-dock' }) router.push({ name: 'book-dock' })
@@ -156,7 +159,7 @@ onMounted(() => {
fetchBookDockSummary() fetchBookDockSummary()
subscribeBookDockSummary() subscribeBookDockSummary()
} }
if (hasPermission('notification_access')) { if (canAccessNotifications.value) {
subscribeNotifications() subscribeNotifications()
} }
}) })
@@ -442,7 +445,7 @@ function formatBadgeClass(fmt: string): string {
<!-- Mobile: Notifications bell --> <!-- Mobile: Notifications bell -->
<div class="md:hidden"> <div class="md:hidden">
<NotificationSheet v-if="hasPermission('notification_access')" :icon-radius-class="iconRadiusClass" /> <NotificationSheet v-if="canAccessNotifications" :icon-radius-class="iconRadiusClass" />
</div> </div>
<!-- Mobile: Kebab Menu --> <!-- Mobile: Kebab Menu -->
@@ -546,7 +549,7 @@ function formatBadgeClass(fmt: string): string {
</Tooltip> </Tooltip>
<!-- Notifications button --> <!-- Notifications button -->
<NotificationSheet v-if="hasPermission('notification_access')" :icon-radius-class="iconRadiusClass" /> <NotificationSheet v-if="canAccessNotifications" :icon-radius-class="iconRadiusClass" />
<!-- Statistics button --> <!-- Statistics button -->
<Tooltip> <Tooltip>
+131 -43
View File
@@ -64,7 +64,7 @@ const emit = defineEmits<{
exit: [] exit: []
}>() }>()
const { hasPermission } = usePermissions() const { hasPermission, isDemoRestrictedAccount } = usePermissions()
const confirmingDelete = ref(false) const confirmingDelete = ref(false)
const deleteInput = ref('') const deleteInput = ref('')
const exportMenuOpen = ref(false) const exportMenuOpen = ref(false)
@@ -72,6 +72,7 @@ const ratingMenuOpen = ref(false)
const moreMenuOpen = ref(false) const moreMenuOpen = ref(false)
const slots = useSlots() const slots = useSlots()
const hasCustomContent = computed(() => Boolean(slots.content)) const hasCustomContent = computed(() => Boolean(slots.content))
const canBulkActions = computed(() => !isDemoRestrictedAccount.value)
const canConfirmDelete = computed(() => props.count <= 50 || deleteInput.value === 'DELETE') const canConfirmDelete = computed(() => props.count <= 50 || deleteInput.value === 'DELETE')
@@ -163,16 +164,26 @@ watch(
<Tooltip v-if="hasPermission('email_send')"> <Tooltip v-if="hasPermission('email_send')">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('send')"> <button
data-testid="action-send-email"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('send')"
>
<Mail :size="ICON_SIZE" /> <Mail :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Send via email</TooltipContent> <TooltipContent side="top">Send via email</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="hasPermission('library_download')"> <Tooltip v-if="hasPermission('library_download') && canBulkActions">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="exportMenuOpen = true"> <button
data-testid="action-export"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="exportMenuOpen = true"
>
<Download :size="ICON_SIZE" /> <Download :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -181,7 +192,12 @@ watch(
<Tooltip> <Tooltip>
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('add-to-collection')"> <button
data-testid="action-add-to-collection"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('add-to-collection')"
>
<FolderPlus :size="ICON_SIZE" /> <FolderPlus :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -201,57 +217,84 @@ watch(
<TooltipContent side="top">Remove from collection</TooltipContent> <TooltipContent side="top">Remove from collection</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')"> <Tooltip v-if="hasPermission('library_edit_metadata') && canBulkActions">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('edit')"> <button
data-testid="action-bulk-edit-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit')"
>
<Pencil :size="ICON_SIZE" /> <Pencil :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Edit metadata</TooltipContent> <TooltipContent side="top">Edit metadata</TooltipContent>
</Tooltip> </Tooltip>
<DropdownMenu> <Tooltip v-if="canBulkActions">
<DropdownMenuTrigger as-child> <TooltipTrigger as-child>
<span class="inline-flex">
<DropdownMenu>
<DropdownMenuTrigger as-child>
<button
data-testid="action-bulk-set-status"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
aria-label="Set reading status"
>
<BookOpen :size="ICON_SIZE" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" class="w-48">
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="onSetStatus(opt.value)">
<component :is="STATUS_ICONS[opt.value]" :size="14" />
<span>{{ opt.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</span>
</TooltipTrigger>
<TooltipContent side="top">Set reading status</TooltipContent>
</Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata') && canBulkActions">
<TooltipTrigger as-child>
<button <button
data-testid="action-bulk-set-rating"
:disabled="count === 0" :disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
aria-label="Set reading status" @click="ratingMenuOpen = true"
title="Set reading status"
> >
<BookOpen :size="ICON_SIZE" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent side="top" align="center" class="w-48">
<DropdownMenuItem v-for="opt in STATUS_OPTIONS" :key="opt.value" @click="onSetStatus(opt.value)">
<component :is="STATUS_ICONS[opt.value]" :size="14" />
<span>{{ opt.label }}</span>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Tooltip v-if="hasPermission('library_edit_metadata')">
<TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="ratingMenuOpen = true">
<Star :size="ICON_SIZE" /> <Star :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Set rating</TooltipContent> <TooltipContent side="top">Set rating</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip v-if="hasPermission('library_edit_metadata')"> <Tooltip v-if="hasPermission('library_edit_metadata') && canBulkActions">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]" @click="emit('edit-tags')"> <button
data-testid="action-bulk-edit-tags"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_PRIMARY : BTN_DISABLED]"
@click="emit('edit-tags')"
>
<Tag :size="ICON_SIZE" /> <Tag :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
<TooltipContent side="top">Edit tags</TooltipContent> <TooltipContent side="top">Edit tags</TooltipContent>
</Tooltip> </Tooltip>
<div v-if="hasPermission('library_edit_metadata')" :class="DIVIDER" /> <div v-if="hasPermission('library_edit_metadata') && canBulkActions" :class="DIVIDER" />
<Tooltip v-if="hasPermission('library_edit_metadata')"> <Tooltip v-if="hasPermission('library_edit_metadata') && canBulkActions">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="moreMenuOpen = true"> <button
data-testid="action-bulk-more"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
@click="moreMenuOpen = true"
>
<MoreHorizontal :size="ICON_SIZE" /> <MoreHorizontal :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -262,7 +305,12 @@ watch(
<Tooltip v-if="hasPermission('library_delete_books')"> <Tooltip v-if="hasPermission('library_delete_books')">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]" @click="confirmingDelete = true"> <button
data-testid="action-delete"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_DESTRUCTIVE : BTN_DISABLED]"
@click="confirmingDelete = true"
>
<Trash2 :size="ICON_SIZE" /> <Trash2 :size="ICON_SIZE" />
</button> </button>
</TooltipTrigger> </TooltipTrigger>
@@ -304,18 +352,58 @@ watch(
<!-- More actions --> <!-- More actions -->
<template v-else-if="moreMenuOpen"> <template v-else-if="moreMenuOpen">
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('refresh-metadata')"> <Tooltip>
<RefreshCw :size="ICON_SIZE" /> <TooltipTrigger as-child>
</button> <button
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="emit('re-extract-cover')"> data-testid="action-bulk-refresh-metadata"
<ImageDown :size="ICON_SIZE" /> :disabled="count === 0"
</button> :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="lockAll"> @click="emit('refresh-metadata')"
<Lock :size="ICON_SIZE" /> >
</button> <RefreshCw :size="ICON_SIZE" />
<button :disabled="count === 0" :class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]" @click="unlockAll"> </button>
<Unlock :size="ICON_SIZE" /> </TooltipTrigger>
</button> <TooltipContent side="top">Refresh metadata</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-bulk-re-extract-cover"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
@click="emit('re-extract-cover')"
>
<ImageDown :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Re-extract cover</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-bulk-lock-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
@click="lockAll"
>
<Lock :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Lock metadata</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger as-child>
<button
data-testid="action-bulk-unlock-metadata"
:disabled="count === 0"
:class="[BTN_ICON, count > 0 ? BTN_MUTED : BTN_DISABLED]"
@click="unlockAll"
>
<Unlock :size="ICON_SIZE" />
</button>
</TooltipTrigger>
<TooltipContent side="top">Unlock metadata</TooltipContent>
</Tooltip>
<div :class="DIVIDER" /> <div :class="DIVIDER" />
<button :class="BTN_TEXT_CANCEL" @click="moreMenuOpen = false">Back</button> <button :class="BTN_TEXT_CANCEL" @click="moreMenuOpen = false">Back</button>
</template> </template>
@@ -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<string>(),
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: '<div><slot /></div>' },
DropdownMenuTrigger: { template: '<div><slot /></div>' },
DropdownMenuContent: { template: '<div><slot /></div>' },
DropdownMenuItem: { template: '<button><slot /></button>' },
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
TooltipProvider: { template: '<div><slot /></div>' },
},
}
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)
})
})
@@ -38,6 +38,10 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
label: 'Administration', label: 'Administration',
permissions: [Permission.ManageLibraries, Permission.ManageMetadataConfig, Permission.ManageAppSettings, Permission.ManageUsers], permissions: [Permission.ManageLibraries, Permission.ManageMetadataConfig, Permission.ManageAppSettings, Permission.ManageUsers],
}, },
{
label: 'Restrictions',
permissions: [Permission.DemoRestricted],
},
] ]
const name = ref('') const name = ref('')
@@ -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)
})
})
@@ -1,4 +1,5 @@
import { computed } from 'vue' import { computed } from 'vue'
import { Permission } from '@bookorbit/types'
import { useAuth } from './useAuth' import { useAuth } from './useAuth'
export function usePermissions() { export function usePermissions() {
@@ -11,5 +12,11 @@ export function usePermissions() {
return isSuperuser.value || userPermissions.value.includes(name) 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 }
} }
@@ -25,7 +25,7 @@ const router = useRouter()
const route = useRoute() const route = useRoute()
const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings() const { gridGap, viewMode, authorCoverSize, authorCoverShape } = useDisplaySettings()
const { libraries, fetchLibraries } = useLibraries() 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 { items, total, loading, error, hasMore, q, sort, order, libraryId, hasPhoto, minBookCount, load } = useAuthorsList()
const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors() const { markRefreshing, clearRefreshing, isRefreshing } = useRefreshingAuthors()
const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleAuthor, rangeSelectTo, selectAll, isSelected } = const { selectionMode, selectedIds, selectedCount, enterSelectionMode, exitSelectionMode, toggleAuthor, rangeSelectTo, selectAll, isSelected } =
@@ -52,7 +52,7 @@ const confirmingBulkDelete = ref(false)
const pendingDeleteIds = ref<number[]>([]) const pendingDeleteIds = ref<number[]>([])
const deleteDialogOpen = ref(false) 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 canDeleteAuthors = computed(() => isSuperuser.value)
const SORT_LABELS: Record<AuthorListSort, string> = { const SORT_LABELS: Record<AuthorListSort, string> = {
@@ -256,7 +256,7 @@ async function clearFilters() {
async function refreshSelectedAuthorsMetadata() { async function refreshSelectedAuthorsMetadata() {
const ids = [...selectedIds.value] const ids = [...selectedIds.value]
if (ids.length === 0 || bulkRefreshing.value) return if (ids.length === 0 || bulkRefreshing.value || !canRefreshMetadata.value) return
bulkRefreshing.value = true bulkRefreshing.value = true
markRefreshing(ids) markRefreshing(ids)
@@ -766,7 +766,7 @@ watch(
<TooltipContent side="top">Select visible</TooltipContent> <TooltipContent side="top">Select visible</TooltipContent>
</Tooltip> </Tooltip>
<Tooltip> <Tooltip v-if="canRefreshMetadata">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
:disabled="selectedCount === 0 || bulkRefreshing" :disabled="selectedCount === 0 || bulkRefreshing"
@@ -3,6 +3,7 @@ import { computed, onUnmounted, ref, watch } from 'vue'
import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus, Loader2 } from 'lucide-vue-next' import { Upload, RotateCw, Trash2, PenLine, FileText, Search, X, Wand2, RefreshCw, FolderPlus, Loader2 } from 'lucide-vue-next'
import type { BookDockFileStatus } from '@bookorbit/types' import type { BookDockFileStatus } from '@bookorbit/types'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { SUPPORTED_FORMATS_ACCEPT, useBookDockUpload } from '../composables/useBookDockUpload' import { SUPPORTED_FORMATS_ACCEPT, useBookDockUpload } from '../composables/useBookDockUpload'
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
@@ -29,6 +30,7 @@ const emit = defineEmits<{
}>() }>()
const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useBookDockUpload() const { files: uploadFiles, isUploading, addFiles, clearCompleted } = useBookDockUpload()
const { isDemoRestrictedAccount } = usePermissions()
const fileInput = ref<HTMLInputElement | null>(null) const fileInput = ref<HTMLInputElement | null>(null)
const rescanning = ref(false) const rescanning = ref(false)
const searchQuery = ref('') const searchQuery = ref('')
@@ -39,6 +41,7 @@ const uploadTotal = computed(() => uploadFiles.value.length)
const uploadDone = computed(() => uploadFiles.value.filter((f) => f.status === 'done').length) const uploadDone = computed(() => uploadFiles.value.filter((f) => f.status === 'done').length)
const uploadError = computed(() => uploadFiles.value.filter((f) => f.status === 'error').length) const uploadError = computed(() => uploadFiles.value.filter((f) => f.status === 'error').length)
const uploadProgress = computed(() => (uploadTotal.value > 0 ? Math.round((uploadDone.value / uploadTotal.value) * 100) : 0)) const uploadProgress = computed(() => (uploadTotal.value > 0 ? Math.round((uploadDone.value / uploadTotal.value) * 100) : 0))
const canBulkEdit = computed(() => !isDemoRestrictedAccount.value)
let popoverTimer: ReturnType<typeof setTimeout> | null = null let popoverTimer: ReturnType<typeof setTimeout> | null = null
@@ -130,12 +133,13 @@ function clearSearch() {
class="h-full w-32 sm:w-44 bg-transparent text-xs outline-none placeholder:text-muted-foreground/80" class="h-full w-32 sm:w-44 bg-transparent text-xs outline-none placeholder:text-muted-foreground/80"
@input="onSearchInput" @input="onSearchInput"
/> />
<button class="text-muted-foreground hover:text-foreground shrink-0" @click="clearSearch"> <button data-testid="book-dock-search-clear" class="text-muted-foreground hover:text-foreground shrink-0" @click="clearSearch">
<X class="size-3" /> <X class="size-3" />
</button> </button>
</div> </div>
<button <button
v-else v-else
data-testid="book-dock-search-toggle"
class="flex items-center justify-center size-7 rounded-lg bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95" class="flex items-center justify-center size-7 rounded-lg bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
@click="showSearch = true" @click="showSearch = true"
> >
@@ -147,6 +151,7 @@ function clearSearch() {
<input ref="fileInput" type="file" :accept="SUPPORTED_FORMATS_ACCEPT" multiple class="hidden" @change="onFilesSelected" /> <input ref="fileInput" type="file" :accept="SUPPORTED_FORMATS_ACCEPT" multiple class="hidden" @change="onFilesSelected" />
<button <button
data-testid="book-dock-rescan"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-muted text-muted-foreground hover:text-foreground transition-all active:scale-95"
:disabled="rescanning" :disabled="rescanning"
@click="rescan" @click="rescan"
@@ -157,6 +162,7 @@ function clearSearch() {
<div class="relative"> <div class="relative">
<button <button
data-testid="book-dock-upload"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95"
@click="openFilePicker" @click="openFilePicker"
> >
@@ -195,6 +201,7 @@ function clearSearch() {
<span class="text-xs font-medium text-foreground">{{ selectionCount }} selected</span> <span class="text-xs font-medium text-foreground">{{ selectionCount }} selected</span>
<div class="flex-1" /> <div class="flex-1" />
<button <button
data-testid="book-dock-finalize"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-primary text-primary-foreground hover:opacity-90 transition-all active:scale-95"
@click="$emit('finalize')" @click="$emit('finalize')"
> >
@@ -202,6 +209,7 @@ function clearSearch() {
Finalize Finalize
</button> </button>
<button <button
data-testid="book-dock-set-destination"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-emerald-500/12 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-emerald-500/12 text-emerald-700 dark:text-emerald-300 hover:bg-emerald-500/20 transition-all active:scale-95"
@click="$emit('setDestination')" @click="$emit('setDestination')"
> >
@@ -211,6 +219,7 @@ function clearSearch() {
<Tooltip v-if="fetchedCount > 0"> <Tooltip v-if="fetchedCount > 0">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
data-testid="book-dock-apply-fetched"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-amber-500/10 text-amber-600 dark:text-amber-400 hover:bg-amber-500/20 transition-all active:scale-95"
@click="$emit('applyFetched')" @click="$emit('applyFetched')"
> >
@@ -227,6 +236,7 @@ function clearSearch() {
<Tooltip v-if="errorCount > 0"> <Tooltip v-if="errorCount > 0">
<TooltipTrigger as-child> <TooltipTrigger as-child>
<button <button
data-testid="book-dock-retry-errors"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-sky-500/12 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-sky-500/12 text-sky-700 dark:text-sky-300 hover:bg-sky-500/20 transition-all active:scale-95"
@click="$emit('retryFetch')" @click="$emit('retryFetch')"
> >
@@ -241,6 +251,8 @@ function clearSearch() {
<TooltipContent>Retry metadata fetch for {{ errorCount }} error file{{ errorCount !== 1 ? 's' : '' }}</TooltipContent> <TooltipContent>Retry metadata fetch for {{ errorCount }} error file{{ errorCount !== 1 ? 's' : '' }}</TooltipContent>
</Tooltip> </Tooltip>
<button <button
v-if="canBulkEdit"
data-testid="book-dock-bulk-edit"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-violet-500/12 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium bg-violet-500/12 text-violet-700 dark:text-violet-300 hover:bg-violet-500/20 transition-all active:scale-95"
@click="$emit('bulkEdit')" @click="$emit('bulkEdit')"
> >
@@ -248,6 +260,7 @@ function clearSearch() {
Bulk Edit Bulk Edit
</button> </button>
<button <button
data-testid="book-dock-bulk-discard"
class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-all active:scale-95" class="flex items-center gap-1.5 h-7 px-3 rounded-lg text-xs font-medium text-red-600 dark:text-red-400 bg-red-500/10 hover:bg-red-500/20 transition-all active:scale-95"
@click="$emit('bulkDiscard')" @click="$emit('bulkDiscard')"
> >
@@ -0,0 +1,187 @@
import { mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { nextTick, ref } from 'vue'
import BookDockToolbar from '../BookDockToolbar.vue'
const permissionState = {
demoRestricted: false,
}
const files = ref<{ status: 'done' | 'error' | 'uploading' }[]>([])
const isUploading = ref(false)
const addFiles = vi.fn<(...args: unknown[]) => unknown>()
const clearCompleted = vi.fn<() => void>()
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean }>>()
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
isDemoRestrictedAccount: {
get value() {
return permissionState.demoRestricted
},
},
}),
}))
vi.mock('../../composables/useBookDockUpload', () => ({
SUPPORTED_FORMATS_ACCEPT: '.epub,.pdf',
useBookDockUpload: () => ({
files,
isUploading,
addFiles,
clearCompleted,
}),
}))
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
}))
function mountToolbar() {
return mount(BookDockToolbar, {
props: {
activeStatus: undefined,
selectionCount: 2,
hasSelection: true,
fetchedCount: 1,
errorCount: 1,
},
global: {
stubs: {
Tooltip: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
},
},
})
}
describe('BookDockToolbar demo restriction', () => {
beforeEach(() => {
vi.useRealTimers()
permissionState.demoRestricted = false
files.value = []
isUploading.value = false
addFiles.mockReset()
clearCompleted.mockReset()
apiMock.mockReset()
})
it('shows bulk edit action when account is not demo restricted', () => {
const wrapper = mountToolbar()
expect(wrapper.find('[data-testid="book-dock-bulk-edit"]').exists()).toBe(true)
expect(wrapper.text()).toContain('Finalize')
})
it('hides bulk edit action when account is demo restricted', () => {
permissionState.demoRestricted = true
const wrapper = mountToolbar()
expect(wrapper.find('[data-testid="book-dock-bulk-edit"]').exists()).toBe(false)
expect(wrapper.text()).toContain('Finalize')
})
it('emits status filter and search events', async () => {
vi.useFakeTimers()
const wrapper = mountToolbar()
await wrapper
.findAll('button')
.find((btn) => btn.text() === 'Ready')
?.trigger('click')
expect(wrapper.emitted('statusFilter')?.[0]).toEqual(['ready'])
await wrapper.find('[data-testid="book-dock-search-toggle"]').trigger('click')
const searchInput = wrapper.find('input[placeholder="Search files..."]')
await searchInput.setValue('abc')
vi.advanceTimersByTime(301)
await nextTick()
expect(wrapper.emitted('search')?.[0]).toEqual(['abc'])
await wrapper.find('[data-testid="book-dock-search-clear"]').trigger('click')
expect(wrapper.emitted('search')?.[1]).toEqual([''])
})
it('calls upload composable when files are selected', async () => {
const wrapper = mountToolbar()
const uploadButton = wrapper.find('[data-testid="book-dock-upload"]')
const input = wrapper.find('input[type="file"]')
const clickSpy = vi.fn<() => void>()
;(input.element as HTMLInputElement).click = clickSpy
await uploadButton.trigger('click')
expect(clearCompleted).toHaveBeenCalledTimes(1)
expect(clickSpy).toHaveBeenCalledTimes(1)
const wrapperWithFile = mountToolbar()
const inputWithFile = wrapperWithFile.find('input[type="file"]')
const file = new File(['book'], 'book.epub', { type: 'application/epub+zip' })
const filesList = {
length: 1,
item: () => file,
0: file,
[Symbol.iterator]: function* () {
yield file
},
} as unknown as FileList
Object.defineProperty(inputWithFile.element, 'files', {
value: filesList,
configurable: true,
})
await inputWithFile.trigger('change')
expect(addFiles).toHaveBeenCalledWith(filesList)
})
it('emits rescan on successful API response', async () => {
apiMock.mockResolvedValue({ ok: true })
const wrapper = mountToolbar()
await wrapper.find('[data-testid="book-dock-rescan"]').trigger('click')
await nextTick()
expect(apiMock).toHaveBeenCalledWith('/api/v1/book-dock/rescan', { method: 'POST' })
expect(wrapper.emitted('rescan')).toHaveLength(1)
})
it('emits rescanError when API call fails', async () => {
apiMock.mockRejectedValue(new Error('network down'))
const wrapper = mountToolbar()
await wrapper.find('[data-testid="book-dock-rescan"]').trigger('click')
await nextTick()
expect(wrapper.emitted('rescanError')).toHaveLength(1)
})
it('emits selection action events from the action row', async () => {
const wrapper = mountToolbar()
await wrapper.find('[data-testid="book-dock-finalize"]').trigger('click')
await wrapper.find('[data-testid="book-dock-set-destination"]').trigger('click')
await wrapper.find('[data-testid="book-dock-apply-fetched"]').trigger('click')
await wrapper.find('[data-testid="book-dock-retry-errors"]').trigger('click')
await wrapper.find('[data-testid="book-dock-bulk-edit"]').trigger('click')
await wrapper.find('[data-testid="book-dock-bulk-discard"]').trigger('click')
expect(wrapper.emitted('finalize')).toHaveLength(1)
expect(wrapper.emitted('setDestination')).toHaveLength(1)
expect(wrapper.emitted('applyFetched')).toHaveLength(1)
expect(wrapper.emitted('retryFetch')).toHaveLength(1)
expect(wrapper.emitted('bulkEdit')).toHaveLength(1)
expect(wrapper.emitted('bulkDiscard')).toHaveLength(1)
})
it('shows and auto-hides upload progress popover', async () => {
vi.useFakeTimers()
const wrapper = mountToolbar()
files.value = [{ status: 'done' }, { status: 'error' }]
isUploading.value = true
await nextTick()
expect(wrapper.text()).toContain('Uploading...')
expect(wrapper.text()).toContain('2 total')
isUploading.value = false
await nextTick()
expect(wrapper.text()).toContain('Done')
vi.advanceTimersByTime(3001)
await nextTick()
expect(wrapper.text()).not.toContain('2 total')
})
})
@@ -7,6 +7,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { generatePkce } from '@/features/auth/composables/useOidc' import { generatePkce } from '@/features/auth/composables/useOidc'
import { useAuth } from '@/features/auth/composables/useAuth' import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/composables/useProfileAvatar' import { MAX_PROFILE_AVATAR_BYTES, useProfileAvatar } from '@/features/auth/composables/useProfileAvatar'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog' import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import SettingsPageHeader from './SettingsPageHeader.vue' import SettingsPageHeader from './SettingsPageHeader.vue'
@@ -14,6 +15,7 @@ import { useMediaQuery } from '@vueuse/core'
import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour' import { useOnboardingTour } from '@/features/onboarding/composables/useOnboardingTour'
const { user, me } = useAuth() const { user, me } = useAuth()
const { isDemoRestrictedAccount } = usePermissions()
const { open: openChangePassword } = useChangePasswordDialog() const { open: openChangePassword } = useChangePasswordDialog()
const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar() const { uploading, removing, uploadAvatar, removeAvatar } = useProfileAvatar()
const { resetTour } = useOnboardingTour() const { resetTour } = useOnboardingTour()
@@ -26,6 +28,7 @@ const removeAvatarConfirmOpen = ref(false)
const profileCardOpen = ref(true) const profileCardOpen = ref(true)
const avatarCardOpen = ref(false) const avatarCardOpen = ref(false)
const isMobile = useMediaQuery('(max-width: 767px)') const isMobile = useMediaQuery('(max-width: 767px)')
const DEMO_RESTRICTED_ACCOUNT_MESSAGE = 'Demo-restricted account cannot edit account settings'
const formName = ref('') const formName = ref('')
@@ -51,12 +54,24 @@ const saveFeedback = computed(() => {
if (profileState.value === 'saved') return 'All changes saved' if (profileState.value === 'saved') return 'All changes saved'
return '' return ''
}) })
const accountEditBlocked = computed(() => isDemoRestrictedAccount.value)
const canChangePassword = computed(
() => !accountEditBlocked.value && user.value?.provisioningMethod !== 'oidc' && user.value?.provisioningMethod !== 'shared',
)
function shouldBlockAccountEdit(): boolean {
if (!accountEditBlocked.value) return false
toast.error(DEMO_RESTRICTED_ACCOUNT_MESSAGE)
return true
}
function triggerFileDialog() { function triggerFileDialog() {
if (shouldBlockAccountEdit()) return
fileInput.value?.click() fileInput.value?.click()
} }
async function onFileSelected(event: Event) { async function onFileSelected(event: Event) {
if (shouldBlockAccountEdit()) return
const input = event.target as HTMLInputElement const input = event.target as HTMLInputElement
const file = input.files?.[0] const file = input.files?.[0]
input.value = '' input.value = ''
@@ -72,6 +87,10 @@ async function onFileSelected(event: Event) {
} }
async function onRemoveAvatar() { async function onRemoveAvatar() {
if (shouldBlockAccountEdit()) {
removeAvatarConfirmOpen.value = false
return
}
removeAvatarConfirmOpen.value = false removeAvatarConfirmOpen.value = false
try { try {
await removeAvatar() await removeAvatar()
@@ -83,6 +102,7 @@ async function onRemoveAvatar() {
} }
async function saveProfile() { async function saveProfile() {
if (shouldBlockAccountEdit()) return
if (!user.value) return if (!user.value) return
profileError.value = null profileError.value = null
const trimmedName = formName.value.trim() const trimmedName = formName.value.trim()
@@ -174,6 +194,7 @@ function availableForLinking(): OidcProviderPublic[] {
} }
async function initiateOidcLink(provider: OidcProviderPublic) { async function initiateOidcLink(provider: OidcProviderPublic) {
if (shouldBlockAccountEdit()) return
linkingSlug.value = provider.slug linkingSlug.value = provider.slug
try { try {
const stateRes = await api(`/api/v1/auth/oidc/${provider.slug}/link-state`, { method: 'POST' }) const stateRes = await api(`/api/v1/auth/oidc/${provider.slug}/link-state`, { method: 'POST' })
@@ -204,6 +225,7 @@ async function initiateOidcLink(provider: OidcProviderPublic) {
} }
async function confirmUnlink() { async function confirmUnlink() {
if (shouldBlockAccountEdit()) return
if (!unlinkPassword.value || !unlinkTarget.value) return if (!unlinkPassword.value || !unlinkTarget.value) return
unlinking.value = true unlinking.value = true
try { try {
@@ -229,6 +251,7 @@ async function confirmUnlink() {
} }
function openUnlinkDialog(identity: LinkedIdentity) { function openUnlinkDialog(identity: LinkedIdentity) {
if (shouldBlockAccountEdit()) return
unlinkTarget.value = identity unlinkTarget.value = identity
unlinkPassword.value = '' unlinkPassword.value = ''
unlinkDialogOpen.value = true unlinkDialogOpen.value = true
@@ -264,6 +287,12 @@ function closeUnlinkDialog() {
</button> </button>
<div v-show="profileCardOpen || !isMobile" class="space-y-4"> <div v-show="profileCardOpen || !isMobile" class="space-y-4">
<p
v-if="accountEditBlocked"
class="rounded-md border border-amber-500/30 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-400"
>
Demo-restricted account: editing profile, password, avatar, and linked identities is disabled.
</p>
<div class="grid gap-4 sm:grid-cols-2"> <div class="grid gap-4 sm:grid-cols-2">
<div class="space-y-1.5 sm:col-span-2"> <div class="space-y-1.5 sm:col-span-2">
<label class="settings-label">Username</label> <label class="settings-label">Username</label>
@@ -280,7 +309,11 @@ function closeUnlinkDialog() {
v-model="formName" v-model="formName"
type="text" type="text"
autocomplete="name" 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',
]"
/> />
</div> </div>
<div class="space-y-1.5"> <div class="space-y-1.5">
@@ -296,14 +329,14 @@ function closeUnlinkDialog() {
</div> </div>
<div class="hidden md:flex flex-wrap items-center gap-2"> <div class="hidden md:flex flex-wrap items-center gap-2">
<button class="settings-btn-primary" :disabled="!profileChanged || profileBusy" @click="saveProfile"> <button class="settings-btn-primary" :disabled="!profileChanged || profileBusy || accountEditBlocked" @click="saveProfile">
<Save :size="14" /> <Save :size="14" />
{{ savingProfile ? 'Saving...' : 'Save profile' }} {{ savingProfile ? 'Saving...' : 'Save profile' }}
</button> </button>
<button <button
v-if="user?.provisioningMethod !== 'oidc' && user?.provisioningMethod !== 'shared'" v-if="canChangePassword"
class="settings-btn-outline inline-flex items-center gap-2" class="settings-btn-outline inline-flex items-center gap-2"
:disabled="profileBusy" :disabled="profileBusy || accountEditBlocked"
@click="openChangePassword()" @click="openChangePassword()"
> >
<KeyRound :size="14" /> <KeyRound :size="14" />
@@ -317,9 +350,9 @@ function closeUnlinkDialog() {
<div class="md:hidden flex flex-wrap items-center gap-2"> <div class="md:hidden flex flex-wrap items-center gap-2">
<button <button
v-if="user?.provisioningMethod !== 'oidc' && user?.provisioningMethod !== 'shared'" v-if="canChangePassword"
class="settings-btn-outline inline-flex items-center gap-2" class="settings-btn-outline inline-flex items-center gap-2"
:disabled="profileBusy" :disabled="profileBusy || accountEditBlocked"
@click="openChangePassword()" @click="openChangePassword()"
> >
<KeyRound :size="14" /> <KeyRound :size="14" />
@@ -355,16 +388,16 @@ function closeUnlinkDialog() {
</div> </div>
<div class="flex flex-wrap items-center gap-2"> <div class="flex flex-wrap items-center gap-2">
<input ref="fileInput" type="file" accept="image/*" class="hidden" :disabled="profileBusy" @change="onFileSelected" /> <input ref="fileInput" type="file" accept="image/*" class="hidden" :disabled="profileBusy || accountEditBlocked" @change="onFileSelected" />
<button class="settings-btn-primary" :disabled="profileBusy" @click="triggerFileDialog"> <button class="settings-btn-primary" :disabled="profileBusy || accountEditBlocked" @click="triggerFileDialog">
<Upload :size="14" /> <Upload :size="14" />
{{ uploading ? 'Uploading...' : hasAvatar ? 'Replace picture' : 'Upload picture' }} {{ uploading ? 'Uploading...' : hasAvatar ? 'Replace picture' : 'Upload picture' }}
</button> </button>
<button <button
class="settings-btn-outline inline-flex items-center gap-2" class="settings-btn-outline inline-flex items-center gap-2"
:disabled="profileBusy || !hasAvatar" :disabled="profileBusy || !hasAvatar || accountEditBlocked"
@click="removeAvatarConfirmOpen = true" @click="removeAvatarConfirmOpen = true"
> >
<Trash2 :size="14" /> <Trash2 :size="14" />
@@ -376,7 +409,11 @@ function closeUnlinkDialog() {
<div class="md:hidden sticky bottom-2 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2"> <div class="md:hidden sticky bottom-2 z-20 border border-border/60 bg-card/95 backdrop-blur rounded-lg px-3 py-2">
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<button class="settings-btn-primary flex-1 min-h-10 justify-center" :disabled="!profileChanged || profileBusy" @click="saveProfile"> <button
class="settings-btn-primary flex-1 min-h-10 justify-center"
:disabled="!profileChanged || profileBusy || accountEditBlocked"
@click="saveProfile"
>
<Save :size="14" /> <Save :size="14" />
{{ savingProfile ? 'Saving...' : 'Save profile' }} {{ savingProfile ? 'Saving...' : 'Save profile' }}
</button> </button>
@@ -409,7 +446,8 @@ function closeUnlinkDialog() {
</div> </div>
<button <button
type="button" type="button"
class="shrink-0 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/5 transition-colors" :disabled="accountEditBlocked"
class="shrink-0 rounded-md border border-destructive/40 px-2 py-1 text-xs font-medium text-destructive hover:bg-destructive/5 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
@click="openUnlinkDialog(identity)" @click="openUnlinkDialog(identity)"
> >
Unlink Unlink
@@ -425,7 +463,7 @@ function closeUnlinkDialog() {
v-for="provider in availableForLinking()" v-for="provider in availableForLinking()"
:key="provider.slug" :key="provider.slug"
type="button" type="button"
:disabled="linkingSlug !== null" :disabled="linkingSlug !== null || accountEditBlocked"
class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors" class="flex items-center gap-1.5 rounded-md border border-border px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50 transition-colors"
@click="initiateOidcLink(provider)" @click="initiateOidcLink(provider)"
> >
@@ -459,7 +497,8 @@ function closeUnlinkDialog() {
Cancel Cancel
</button> </button>
<button <button
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90" :disabled="accountEditBlocked"
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50 disabled:cursor-not-allowed"
@click="onRemoveAvatar" @click="onRemoveAvatar"
> >
Remove Remove
@@ -502,7 +541,7 @@ function closeUnlinkDialog() {
Cancel Cancel
</button> </button>
<button <button
:disabled="unlinking || !unlinkPassword" :disabled="unlinking || !unlinkPassword || accountEditBlocked"
class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50" class="rounded-md bg-destructive px-3 py-2 text-sm font-medium text-destructive-foreground hover:bg-destructive/90 disabled:opacity-50"
@click="confirmUnlink" @click="confirmUnlink"
> >
@@ -5,7 +5,7 @@ import { usePermissions } from '@/features/auth/composables/usePermissions'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
const { isSuperuser, userPermissions } = usePermissions() const { isSuperuser, userPermissions, isDemoRestrictedAccount } = usePermissions()
interface Section { interface Section {
label: string label: string
@@ -55,7 +55,9 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Account', routeName: 'settings-account' }) result.push({ label: 'Account', routeName: 'settings-account' })
result.push({ label: 'Notifications', routeName: 'settings-notifications' }) if (!isDemoRestrictedAccount.value) {
result.push({ label: 'Notifications', routeName: 'settings-notifications' })
}
if (su) { if (su) {
result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' }) result.push({ label: 'Audit Log', routeName: 'settings-admin-audit-log' })
@@ -66,7 +68,7 @@ const sections = computed<Section[]>(() => {
</script> </script>
<template> <template>
<div class="flex items-stretch h-11 px-6 border-b overflow-x-auto shrink-0 scrollbar-none snap-x snap-mandatory md:snap-none"> <div class="flex items-stretch h-11 px-4 border-b overflow-x-auto shrink-0 scrollbar-none snap-x snap-mandatory md:snap-none">
<button <button
v-for="section in sections" v-for="section in sections"
:key="section.routeName" :key="section.routeName"
@@ -28,7 +28,7 @@ function navigateToSection(section: ToolSection): void {
</script> </script>
<template> <template>
<div class="flex items-stretch h-11 px-6 border-b overflow-x-auto shrink-0 scrollbar-none snap-x snap-mandatory md:snap-none"> <div class="flex items-stretch h-11 px-4 border-b overflow-x-auto shrink-0 scrollbar-none snap-x snap-mandatory md:snap-none">
<button <button
v-for="section in sections" v-for="section in sections"
:key="section.routeName" :key="section.routeName"
+11
View File
@@ -2,6 +2,8 @@ import { createRouter, createWebHistory, type RouteLocationNormalizedLoaded, typ
import { EMAIL_TAB_LABELS, normalizeEmailTab } from '@/features/email/lib/email-tabs' import { EMAIL_TAB_LABELS, normalizeEmailTab } from '@/features/email/lib/email-tabs'
import { METADATA_TAB_INFO, normalizeMetadataTab } from '@/features/settings/lib/metadata-tabs' import { METADATA_TAB_INFO, normalizeMetadataTab } from '@/features/settings/lib/metadata-tabs'
import { READER_TAB_TITLE_LABELS, normalizeReaderTab } from '@/features/settings/lib/reader-tabs' import { READER_TAB_TITLE_LABELS, normalizeReaderTab } from '@/features/settings/lib/reader-tabs'
import { Permission } from '@bookorbit/types'
import { useAuth } from '@/features/auth/composables/useAuth'
import { registerAuthGuard } from './guards/auth.guard' import { registerAuthGuard } from './guards/auth.guard'
import { registerRouteTitleHook } from './title-resolver' import { registerRouteTitleHook } from './title-resolver'
@@ -38,6 +40,14 @@ function resolveMetadataTitle(to: RouteLocationNormalizedLoaded): string {
return METADATA_TAB_INFO[tab].titleLabel return METADATA_TAB_INFO[tab].titleLabel
} }
function blockDemoRestrictedNotificationsRoute() {
const { user } = useAuth()
if (user.value?.permissions?.includes(Permission.DemoRestricted)) {
return { name: 'settings-account' }
}
return true
}
export const routes: RouteRecordRaw[] = [ export const routes: RouteRecordRaw[] = [
{ {
path: '/', path: '/',
@@ -64,6 +74,7 @@ export const routes: RouteRecordRaw[] = [
path: 'notifications', path: 'notifications',
name: 'settings-notifications', name: 'settings-notifications',
component: () => import('@/features/notifications/components/NotificationPreferences.vue'), component: () => import('@/features/notifications/components/NotificationPreferences.vue'),
beforeEnter: blockDemoRestrictedNotificationsRoute,
meta: { title: 'Notifications' }, meta: { title: 'Notifications' },
}, },
{ {
+3
View File
@@ -4,6 +4,7 @@ import { PackageOpen, CheckCircle2, AlertCircle } from 'lucide-vue-next'
import type { BookDockFile } from '@bookorbit/types' import type { BookDockFile } from '@bookorbit/types'
import { api } from '@/lib/api' import { api } from '@/lib/api'
import { formatBytes } from '@/lib/formatting' import { formatBytes } from '@/lib/formatting'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookDockFiles } from '@/features/book-dock/composables/useBookDockFiles' import { useBookDockFiles } from '@/features/book-dock/composables/useBookDockFiles'
import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary' import { useBookDockSummary } from '@/features/book-dock/composables/useBookDockSummary'
import { useBookDockStatistics } from '@/features/book-dock/composables/useBookDockStatistics' import { useBookDockStatistics } from '@/features/book-dock/composables/useBookDockStatistics'
@@ -45,6 +46,7 @@ const {
const { summary, fetchSummary, subscribe, onBookDockChange, socketConnected } = useBookDockSummary() const { summary, fetchSummary, subscribe, onBookDockChange, socketConnected } = useBookDockSummary()
const { statistics, fetchStatistics } = useBookDockStatistics() const { statistics, fetchStatistics } = useBookDockStatistics()
const { addFiles, isUploading } = useBookDockUpload() const { addFiles, isUploading } = useBookDockUpload()
const { isDemoRestrictedAccount } = usePermissions()
const selectedFile = ref<BookDockFile | null>(null) const selectedFile = ref<BookDockFile | null>(null)
const showFinalizeDialog = ref(false) const showFinalizeDialog = ref(false)
@@ -231,6 +233,7 @@ function openFinalize() {
} }
function openBulkEdit() { function openBulkEdit() {
if (isDemoRestrictedAccount.value) return
showBulkEditDialog.value = true showBulkEditDialog.value = true
} }
+2
View File
@@ -4,6 +4,7 @@ export enum Permission {
LibraryUpload = "library_upload", LibraryUpload = "library_upload",
LibraryEditMetadata = "library_edit_metadata", LibraryEditMetadata = "library_edit_metadata",
LibraryDeleteBooks = "library_delete_books", LibraryDeleteBooks = "library_delete_books",
DemoRestricted = "demo_restricted",
// Devices & Access // Devices & Access
KoboSync = "kobo_sync", KoboSync = "kobo_sync",
@@ -30,6 +31,7 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.LibraryUpload]: "Upload books", [Permission.LibraryUpload]: "Upload books",
[Permission.LibraryEditMetadata]: "Edit metadata", [Permission.LibraryEditMetadata]: "Edit metadata",
[Permission.LibraryDeleteBooks]: "Delete books", [Permission.LibraryDeleteBooks]: "Delete books",
[Permission.DemoRestricted]: "Demo restricted",
[Permission.KoboSync]: "Kobo sync", [Permission.KoboSync]: "Kobo sync",
[Permission.OpdsAccess]: "OPDS access", [Permission.OpdsAccess]: "OPDS access",
[Permission.BookDockAccess]: "Book Dock", [Permission.BookDockAccess]: "Book Dock",
@@ -4,6 +4,7 @@ import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { AllowDefaultPassword, ALLOW_DEFAULT_PASSWORD_KEY } from './allow-default-password.decorator'; import { AllowDefaultPassword, ALLOW_DEFAULT_PASSWORD_KEY } from './allow-default-password.decorator';
import { Auditable, AUDITABLE_KEY } from './auditable.decorator'; import { Auditable, AUDITABLE_KEY } from './auditable.decorator';
import { ForbidPermission, FORBIDDEN_PERMISSION_KEY } from './forbid-permission.decorator';
import { Public, IS_PUBLIC_KEY } from './public.decorator'; import { Public, IS_PUBLIC_KEY } from './public.decorator';
import { RequireLibraryAccess, LIBRARY_ACCESS_KEY } from './require-library-access.decorator'; import { RequireLibraryAccess, LIBRARY_ACCESS_KEY } from './require-library-access.decorator';
import { RequirePermission, PERMISSION_KEY } from './require-permission.decorator'; import { RequirePermission, PERMISSION_KEY } from './require-permission.decorator';
@@ -26,6 +27,9 @@ describe('common decorators', () => {
@RequirePermission(Permission.ManageUsers) @RequirePermission(Permission.ManageUsers)
withPermission() {} withPermission() {}
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
withoutPermission() {}
@RequireLibraryAccess('viewer') @RequireLibraryAccess('viewer')
withLibraryAccess() {} withLibraryAccess() {}
@@ -36,6 +40,10 @@ describe('common decorators', () => {
expect(Reflect.getMetadata(IS_PUBLIC_KEY, DecoratedController.prototype.open)).toBe(true); expect(Reflect.getMetadata(IS_PUBLIC_KEY, DecoratedController.prototype.open)).toBe(true);
expect(Reflect.getMetadata(ALLOW_DEFAULT_PASSWORD_KEY, DecoratedController.prototype.allowDefaultPassword)).toBe(true); expect(Reflect.getMetadata(ALLOW_DEFAULT_PASSWORD_KEY, DecoratedController.prototype.allowDefaultPassword)).toBe(true);
expect(Reflect.getMetadata(PERMISSION_KEY, DecoratedController.prototype.withPermission)).toBe(Permission.ManageUsers); expect(Reflect.getMetadata(PERMISSION_KEY, DecoratedController.prototype.withPermission)).toBe(Permission.ManageUsers);
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, DecoratedController.prototype.withoutPermission)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot perform bulk edits',
});
expect(Reflect.getMetadata(LIBRARY_ACCESS_KEY, DecoratedController.prototype.withLibraryAccess)).toBe('viewer'); expect(Reflect.getMetadata(LIBRARY_ACCESS_KEY, DecoratedController.prototype.withLibraryAccess)).toBe('viewer');
expect(Reflect.getMetadata(AUDITABLE_KEY, DecoratedController.prototype.audited)).toEqual(auditableOptions); expect(Reflect.getMetadata(AUDITABLE_KEY, DecoratedController.prototype.audited)).toEqual(auditableOptions);
}); });
@@ -0,0 +1,12 @@
import { SetMetadata } from '@nestjs/common';
import { Permission } from '@bookorbit/types';
export const FORBIDDEN_PERMISSION_KEY = 'forbiddenPermission';
export type ForbiddenPermissionRule = {
permission: Permission;
message?: string;
};
export const ForbidPermission = (permission: Permission, message?: string) =>
SetMetadata(FORBIDDEN_PERMISSION_KEY, { permission, message } satisfies ForbiddenPermissionRule);
@@ -1,6 +1,7 @@
import { ExecutionContext, ForbiddenException } from '@nestjs/common'; import { ExecutionContext, ForbiddenException } from '@nestjs/common';
import { Permission } from '@bookorbit/types'; import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY } from '../decorators/forbid-permission.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/require-permission.decorator'; import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
import type { RequestUser } from '../types/request-user'; import type { RequestUser } from '../types/request-user';
@@ -39,22 +40,88 @@ describe('PermissionGuard', () => {
const reflector = { const reflector = {
getAllAndOverride: vi.fn((key: string) => (key === IS_PUBLIC_KEY ? true : undefined)), getAllAndOverride: vi.fn((key: string) => (key === IS_PUBLIC_KEY ? true : undefined)),
}; };
const permissionService = { userHas: vi.fn() }; const permissionService = { userHas: vi.fn(), userHasExplicit: vi.fn() };
const guard = new PermissionGuard(reflector as never, permissionService as never); const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(guard.canActivate(makeContext(makeUser()))).toBe(true); expect(guard.canActivate(makeContext(makeUser()))).toBe(true);
expect(permissionService.userHas).not.toHaveBeenCalled(); expect(permissionService.userHas).not.toHaveBeenCalled();
expect(permissionService.userHasExplicit).not.toHaveBeenCalled();
}); });
it('allows routes without required permission metadata', () => { it('allows routes without required permission metadata', () => {
const reflector = { const reflector = {
getAllAndOverride: vi.fn((key: string) => (key === IS_PUBLIC_KEY ? false : undefined)), getAllAndOverride: vi.fn((key: string) => (key === IS_PUBLIC_KEY ? false : undefined)),
}; };
const permissionService = { userHas: vi.fn() }; const permissionService = { userHas: vi.fn(), userHasExplicit: vi.fn() };
const guard = new PermissionGuard(reflector as never, permissionService as never); const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(guard.canActivate(makeContext(makeUser()))).toBe(true); expect(guard.canActivate(makeContext(makeUser()))).toBe(true);
expect(permissionService.userHas).not.toHaveBeenCalled(); expect(permissionService.userHas).not.toHaveBeenCalled();
expect(permissionService.userHasExplicit).not.toHaveBeenCalled();
});
it('throws ForbiddenException when forbidden marker permission is present', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === FORBIDDEN_PERMISSION_KEY) {
return {
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot perform bulk edits',
};
}
return undefined;
}),
};
const permissionService = {
userHas: vi.fn(),
userHasExplicit: vi.fn().mockReturnValue(true),
};
const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(() => guard.canActivate(makeContext(makeUser()))).toThrow('Demo-restricted account cannot perform bulk edits');
expect(permissionService.userHas).not.toHaveBeenCalled();
});
it('evaluates required permission when user does not have the forbidden marker permission', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === FORBIDDEN_PERMISSION_KEY) {
return {
permission: Permission.DemoRestricted,
};
}
if (key === PERMISSION_KEY) return Permission.ManageUsers;
return undefined;
}),
};
const permissionService = {
userHas: vi.fn().mockReturnValue(true),
userHasExplicit: vi.fn().mockReturnValue(false),
};
const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(guard.canActivate(makeContext(makeUser()))).toBe(true);
expect(permissionService.userHasExplicit).toHaveBeenCalledWith(expect.any(Object), Permission.DemoRestricted);
expect(permissionService.userHas).toHaveBeenCalledWith(expect.any(Object), Permission.ManageUsers);
});
it('uses default fallback message when ForbidPermission has no custom message', () => {
const reflector = {
getAllAndOverride: vi.fn((key: string) => {
if (key === IS_PUBLIC_KEY) return false;
if (key === FORBIDDEN_PERMISSION_KEY) return { permission: Permission.DemoRestricted };
return undefined;
}),
};
const permissionService = {
userHas: vi.fn(),
userHasExplicit: vi.fn().mockReturnValue(true),
};
const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(() => guard.canActivate(makeContext(makeUser()))).toThrow(`Forbidden permission: ${Permission.DemoRestricted}`);
}); });
it('throws ForbiddenException when user lacks required permission', () => { it('throws ForbiddenException when user lacks required permission', () => {
@@ -65,7 +132,7 @@ describe('PermissionGuard', () => {
return undefined; return undefined;
}), }),
}; };
const permissionService = { userHas: vi.fn().mockReturnValue(false) }; const permissionService = { userHas: vi.fn().mockReturnValue(false), userHasExplicit: vi.fn().mockReturnValue(false) };
const guard = new PermissionGuard(reflector as never, permissionService as never); const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(() => guard.canActivate(makeContext(makeUser()))).toThrow(ForbiddenException); expect(() => guard.canActivate(makeContext(makeUser()))).toThrow(ForbiddenException);
@@ -79,7 +146,7 @@ describe('PermissionGuard', () => {
return undefined; return undefined;
}), }),
}; };
const permissionService = { userHas: vi.fn().mockReturnValue(true) }; const permissionService = { userHas: vi.fn().mockReturnValue(true), userHasExplicit: vi.fn().mockReturnValue(false) };
const guard = new PermissionGuard(reflector as never, permissionService as never); const guard = new PermissionGuard(reflector as never, permissionService as never);
expect(guard.canActivate(makeContext(makeUser()))).toBe(true); expect(guard.canActivate(makeContext(makeUser()))).toBe(true);
+10 -1
View File
@@ -2,6 +2,7 @@ import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from '@
import { Reflector } from '@nestjs/core'; import { Reflector } from '@nestjs/core';
import { Permission } from '@bookorbit/types'; import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY, type ForbiddenPermissionRule } from '../decorators/forbid-permission.decorator';
import { IS_PUBLIC_KEY } from '../decorators/public.decorator'; import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
import { PERMISSION_KEY } from '../decorators/require-permission.decorator'; import { PERMISSION_KEY } from '../decorators/require-permission.decorator';
import { PermissionService } from '../services/permission.service'; import { PermissionService } from '../services/permission.service';
@@ -18,10 +19,18 @@ export class PermissionGuard implements CanActivate {
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]); const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [context.getHandler(), context.getClass()]);
if (isPublic) return true; if (isPublic) return true;
const user = context.switchToHttp().getRequest<{ user: RequestUser }>().user;
const forbidden = this.reflector.getAllAndOverride<ForbiddenPermissionRule | undefined>(FORBIDDEN_PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
if (forbidden && this.permissionService.userHasExplicit(user, forbidden.permission)) {
throw new ForbiddenException(forbidden.message ?? `Forbidden permission: ${forbidden.permission}`);
}
const required = this.reflector.getAllAndOverride<Permission | undefined>(PERMISSION_KEY, [context.getHandler(), context.getClass()]); const required = this.reflector.getAllAndOverride<Permission | undefined>(PERMISSION_KEY, [context.getHandler(), context.getClass()]);
if (!required) return true; if (!required) return true;
const user = context.switchToHttp().getRequest<{ user: RequestUser }>().user;
if (!this.permissionService.userHas(user, required)) { if (!this.permissionService.userHas(user, required)) {
throw new ForbiddenException(`Missing permission: ${required}`); throw new ForbiddenException(`Missing permission: ${required}`);
} }
@@ -28,6 +28,7 @@ describe('PermissionService', () => {
const user = makeUser({ isSuperuser: true, permissions: [] }); const user = makeUser({ isSuperuser: true, permissions: [] });
expect(service.userHas(user, Permission.ManageUsers)).toBe(true); expect(service.userHas(user, Permission.ManageUsers)).toBe(true);
expect(service.userHasExplicit(user, Permission.ManageUsers)).toBe(false);
}); });
it('returns true when user has the specific permission', () => { it('returns true when user has the specific permission', () => {
@@ -36,6 +37,7 @@ describe('PermissionService', () => {
}); });
expect(service.userHas(user, Permission.KoboSync)).toBe(true); expect(service.userHas(user, Permission.KoboSync)).toBe(true);
expect(service.userHasExplicit(user, Permission.KoboSync)).toBe(true);
}); });
it('returns false when permission does not exist', () => { it('returns false when permission does not exist', () => {
@@ -49,5 +51,7 @@ describe('PermissionService', () => {
expect(() => service.userHas(malformedUser, Permission.LibraryDownload)).not.toThrow(); expect(() => service.userHas(malformedUser, Permission.LibraryDownload)).not.toThrow();
expect(service.userHas(malformedUser, Permission.LibraryDownload)).toBe(false); expect(service.userHas(malformedUser, Permission.LibraryDownload)).toBe(false);
expect(() => service.userHasExplicit(malformedUser, Permission.LibraryDownload)).not.toThrow();
expect(service.userHasExplicit(malformedUser, Permission.LibraryDownload)).toBe(false);
}); });
}); });
@@ -6,6 +6,10 @@ import { RequestUser } from '../types/request-user';
export class PermissionService { export class PermissionService {
userHas(user: RequestUser, permission: Permission): boolean { userHas(user: RequestUser, permission: Permission): boolean {
if (user?.isSuperuser) return true; if (user?.isSuperuser) return true;
return this.userHasExplicit(user, permission);
}
userHasExplicit(user: RequestUser, permission: Permission): boolean {
return Array.isArray(user?.permissions) && user.permissions.includes(permission); return Array.isArray(user?.permissions) && user.permissions.includes(permission);
} }
} }
@@ -1,5 +1,7 @@
import 'reflect-metadata'; import 'reflect-metadata';
import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { AuthController } from './auth.controller'; import { AuthController } from './auth.controller';
function makeController() { function makeController() {
@@ -178,4 +180,32 @@ describe('AuthController', () => {
expect(loginMlLimit).toBe(10); expect(loginMlLimit).toBe(10);
expect(loginMlTtl).toBe(60_000); expect(loginMlTtl).toBe(60_000);
}); });
it('defines forbidden-permission metadata for account edit endpoints', () => {
const changePasswordForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, AuthController.prototype.changePassword) as {
permission: Permission;
message?: string;
};
const oidcLinkForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, AuthController.prototype.oidcGenerateLinkState) as {
permission: Permission;
message?: string;
};
const oidcUnlinkForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, AuthController.prototype.oidcUnlinkIdentity) as {
permission: Permission;
message?: string;
};
expect(changePasswordForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
expect(oidcLinkForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
expect(oidcUnlinkForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
});
}); });
+5 -1
View File
@@ -17,10 +17,11 @@ import {
import { Throttle } from '@nestjs/throttler'; import { Throttle } from '@nestjs/throttler';
import type { FastifyReply, FastifyRequest } from 'fastify'; import type { FastifyReply, FastifyRequest } from 'fastify';
import { AuditAction, AuditResource } from '@bookorbit/types'; import { AuditAction, AuditResource, Permission } from '@bookorbit/types';
import { AllowDefaultPassword } from '../../common/decorators/allow-default-password.decorator'; import { AllowDefaultPassword } from '../../common/decorators/allow-default-password.decorator';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { AuthService } from './auth.service'; import { AuthService } from './auth.service';
@@ -134,6 +135,7 @@ export class AuthController {
@Post('change-password') @Post('change-password')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@AllowDefaultPassword() @AllowDefaultPassword()
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
changePassword( changePassword(
@CurrentUser() user: RequestUser, @CurrentUser() user: RequestUser,
@Body() dto: ChangePasswordDto, @Body() dto: ChangePasswordDto,
@@ -171,6 +173,7 @@ export class AuthController {
@Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } }) @Throttle({ default: { limit: 5, ttl: ONE_MINUTE_MS } })
@Post('oidc/:slug/link-state') @Post('oidc/:slug/link-state')
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
oidcGenerateLinkState(@CurrentUser() user: RequestUser, @Param('slug') slug: string) { oidcGenerateLinkState(@CurrentUser() user: RequestUser, @Param('slug') slug: string) {
return this.oidcService.generateLinkState(user.id, slug); return this.oidcService.generateLinkState(user.id, slug);
} }
@@ -189,6 +192,7 @@ export class AuthController {
@Delete('oidc/identities/:providerId') @Delete('oidc/identities/:providerId')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Param('providerId', ParseIntPipe) providerId: number, @Body() dto: OidcUnlinkDto) { oidcUnlinkIdentity(@CurrentUser() user: RequestUser, @Param('providerId', ParseIntPipe) providerId: number, @Body() dto: OidcUnlinkDto) {
return this.oidcService.unlinkIdentity(user.id, providerId, dto.password); return this.oidcService.unlinkIdentity(user.id, providerId, dto.password);
} }
@@ -3,8 +3,10 @@ import 'reflect-metadata';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import { of } from 'rxjs'; import { of } from 'rxjs';
import { Permission } from '@bookorbit/types';
import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { AuthorsController } from './authors.controller'; import { AuthorsController } from './authors.controller';
@@ -178,6 +180,14 @@ describe('AuthorsController', () => {
expect(raw.end).toHaveBeenCalled(); expect(raw.end).toHaveBeenCalled();
}); });
it('marks bulk metadata refresh endpoint as demo-restricted', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, AuthorsController.prototype.bulkRefreshMetadata)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot perform bulk edits',
});
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, AuthorsController.prototype.refreshEnrichment)).toBeUndefined();
});
it('returns enrichment config values and updates them', async () => { it('returns enrichment config values and updates them', async () => {
const { controller, enrichmentConfig } = makeController(); const { controller, enrichmentConfig } = makeController();
const config = { const config = {
@@ -25,6 +25,7 @@ import { map, Observable } from 'rxjs';
import { Permission, AuditAction, AuditResource } from '@bookorbit/types'; import { Permission, AuditAction, AuditResource } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type'; import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
@@ -80,6 +81,7 @@ export class AuthorsController {
@Post('bulk-refresh-metadata') @Post('bulk-refresh-metadata')
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
async bulkRefreshMetadata(@Body() dto: BulkAuthorIdsDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) { async bulkRefreshMetadata(@Body() dto: BulkAuthorIdsDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
reply.raw.writeHead(200, { reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream', 'Content-Type': 'text/event-stream',
@@ -1,6 +1,8 @@
import { BadRequestException, NotFoundException } from '@nestjs/common'; import { BadRequestException, NotFoundException } from '@nestjs/common';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { BookDockController } from './book-dock.controller'; import { BookDockController } from './book-dock.controller';
vi.mock('fs', () => ({ vi.mock('fs', () => ({
@@ -161,4 +163,12 @@ describe('BookDockController', () => {
expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, [1], false, [], 2, 3, [], undefined, undefined); expect(finalizeService.finalize).toHaveBeenCalledWith(99, true, [1], false, [], 2, 3, [], undefined, undefined);
expect(watcherService.rescan).toHaveBeenCalled(); expect(watcherService.rescan).toHaveBeenCalled();
}); });
it('marks bulk edit endpoint as demo-restricted', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, BookDockController.prototype.bulkEdit)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot perform bulk edits',
});
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, BookDockController.prototype.finalize)).toBeUndefined();
});
}); });
@@ -25,6 +25,7 @@ import type { BookDockMetadata } from '@bookorbit/types';
import { AuditAction, AuditResource } from '@bookorbit/types'; import { AuditAction, AuditResource } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type'; import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { MultipartRequest } from '../../common/types/multipart-request'; import type { MultipartRequest } from '../../common/types/multipart-request';
@@ -163,6 +164,7 @@ export class BookDockController {
} }
@Post('files/bulk-edit') @Post('files/bulk-edit')
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
bulkEdit(@CurrentUser() user: RequestUser, @Body() dto: BulkEditBookDockDto) { bulkEdit(@CurrentUser() user: RequestUser, @Body() dto: BulkEditBookDockDto) {
return this.service.bulkEdit( return this.service.bulkEdit(
dto.fileIds, dto.fileIds,
@@ -3,9 +3,11 @@ import type { MockedFunction } from 'vitest';
import archiver from 'archiver'; import archiver from 'archiver';
import { createReadStream } from 'fs'; import { createReadStream } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import { Permission } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { BookController } from './book.controller'; import { BookController } from './book.controller';
vi.mock('archiver', () => ({ vi.mock('archiver', () => ({
@@ -589,6 +591,39 @@ describe('BookController', () => {
expect(refreshAudit.description({ params: { id: '44' } })).toBe('Refreshed metadata for book #44'); expect(refreshAudit.description({ params: { id: '44' } })).toBe('Refreshed metadata for book #44');
}); });
it('marks bulk-edit endpoints as demo-restricted', () => {
const message = 'Demo-restricted account cannot perform bulk edits';
const bulkMethods = [
BookController.prototype.bulkRefreshMetadata,
BookController.prototype.bulkReExtractCover,
BookController.prototype.bulkSetStatus,
BookController.prototype.bulkSetRating,
BookController.prototype.bulkUpdateTags,
BookController.prototype.bulkSetMetadataLock,
];
for (const method of bulkMethods) {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, method)).toEqual({
permission: Permission.DemoRestricted,
message,
});
}
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, BookController.prototype.refreshMetadata)).toBeUndefined();
});
it('marks bulk-download endpoints as demo-restricted', () => {
const message = 'Demo-restricted account cannot perform bulk downloads';
const bulkDownloadMethods = [BookController.prototype.exportBooks, BookController.prototype.exportBooksDownload];
for (const method of bulkDownloadMethods) {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, method)).toEqual({
permission: Permission.DemoRestricted,
message,
});
}
});
it('preserves valid surrogate pairs while stripping lone surrogates in download filenames', async () => { it('preserves valid surrogate pairs while stripping lone surrogates in download filenames', async () => {
const { controller, bookService } = makeController(); const { controller, bookService } = makeController();
const { reply, headers } = makeReply(); const { reply, headers } = makeReply();
@@ -24,6 +24,7 @@ import type { FastifyReply } from 'fastify';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { imageContentTypeFromPath } from '../../common/image-content-type'; import { imageContentTypeFromPath } from '../../common/image-content-type';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { FileWriteService } from '../file-write/file-write.service'; import { FileWriteService } from '../file-write/file-write.service';
@@ -146,6 +147,7 @@ export class BookController {
@Post('bulk-refresh-metadata') @Post('bulk-refresh-metadata')
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkMetadataRefresh, action: AuditAction.BookBulkMetadataRefresh,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -175,6 +177,7 @@ export class BookController {
@Post('bulk-re-extract-cover') @Post('bulk-re-extract-cover')
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkCoverReextract, action: AuditAction.BookBulkCoverReextract,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -210,6 +213,7 @@ export class BookController {
@Post('export') @Post('export')
@RequirePermission(Permission.LibraryDownload) @RequirePermission(Permission.LibraryDownload)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk downloads')
async exportBooks(@Body() dto: ExportBooksDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) { async exportBooks(@Body() dto: ExportBooksDto, @CurrentUser() user: RequestUser, @Res() reply: FastifyReply) {
const scope = dto.audioOnly ? 'audio' : dto.allFormats ? 'all' : 'primary'; const scope = dto.audioOnly ? 'audio' : dto.allFormats ? 'all' : 'primary';
await this.streamBookExport(dto.bookIds, scope, user, reply); await this.streamBookExport(dto.bookIds, scope, user, reply);
@@ -217,6 +221,7 @@ export class BookController {
@Get('export/download') @Get('export/download')
@RequirePermission(Permission.LibraryDownload) @RequirePermission(Permission.LibraryDownload)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk downloads')
async exportBooksDownload( async exportBooksDownload(
@Query('bookIds') bookIdsQuery: string | undefined, @Query('bookIds') bookIdsQuery: string | undefined,
@Query('scope') scopeQuery: string | undefined, @Query('scope') scopeQuery: string | undefined,
@@ -501,6 +506,7 @@ export class BookController {
@Post('bulk-set-status') @Post('bulk-set-status')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkSetStatus, action: AuditAction.BookBulkSetStatus,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -517,6 +523,7 @@ export class BookController {
@Post('bulk-set-rating') @Post('bulk-set-rating')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkSetRating, action: AuditAction.BookBulkSetRating,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -533,6 +540,7 @@ export class BookController {
@Post('bulk-update-tags') @Post('bulk-update-tags')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkUpdateTags, action: AuditAction.BookBulkUpdateTags,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -549,6 +557,7 @@ export class BookController {
@Post('bulk-set-metadata-lock') @Post('bulk-set-metadata-lock')
@HttpCode(HttpStatus.NO_CONTENT) @HttpCode(HttpStatus.NO_CONTENT)
@RequirePermission(Permission.LibraryEditMetadata) @RequirePermission(Permission.LibraryEditMetadata)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
@Auditable({ @Auditable({
action: AuditAction.BookBulkSetMetadataLock, action: AuditAction.BookBulkSetMetadataLock,
resource: AuditResource.Book, resource: AuditResource.Book,
@@ -1,6 +1,7 @@
import { NotFoundException } from '@nestjs/common'; import { NotFoundException } from '@nestjs/common';
import { Permission } from '@bookorbit/types'; import { Permission } from '@bookorbit/types';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { NotificationController } from './notification.controller'; import { NotificationController } from './notification.controller';
@@ -124,4 +125,11 @@ describe('NotificationController', () => {
expect(service.clearAll).toHaveBeenCalledWith(42); expect(service.clearAll).toHaveBeenCalledWith(42);
}); });
}); });
it('marks controller as demo-restricted', () => {
expect(Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, NotificationController)).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot access notifications',
});
});
}); });
@@ -2,6 +2,7 @@ import { Controller, Delete, Get, HttpCode, HttpStatus, NotFoundException, Param
import { Permission } from '@bookorbit/types'; import { Permission } from '@bookorbit/types';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { ListNotificationsDto } from './dto/list-notifications.dto'; import { ListNotificationsDto } from './dto/list-notifications.dto';
@@ -9,6 +10,7 @@ import { NotificationService } from './notification.service';
@Controller('notifications') @Controller('notifications')
@RequirePermission(Permission.NotificationAccess) @RequirePermission(Permission.NotificationAccess)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot access notifications')
export class NotificationController { export class NotificationController {
constructor(private readonly notificationService: NotificationService) {} constructor(private readonly notificationService: NotificationService) {}
@@ -0,0 +1,63 @@
import { Permission } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { NotificationGateway } from './notification.gateway';
function makeGateway() {
return new NotificationGateway({} as never, {} as never, {} as never, { get: vi.fn().mockReturnValue('http://localhost:5173') } as never);
}
function makeUser(overrides: Partial<RequestUser> = {}): RequestUser {
return {
id: 7,
username: 'user',
name: 'User',
email: null,
active: true,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
isSuperuser: false,
permissions: [],
...overrides,
};
}
describe('NotificationGateway', () => {
it('allows users with notification access', () => {
const gateway = makeGateway();
const user = makeUser({ permissions: [Permission.NotificationAccess] });
expect(() => (gateway as any).assertHasAccess(user)).not.toThrow();
});
it('allows superusers without explicit notification_access', () => {
const gateway = makeGateway();
const user = makeUser({ isSuperuser: true });
expect(() => (gateway as any).assertHasAccess(user)).not.toThrow();
});
it('denies demo-restricted users even when notification access exists', () => {
const gateway = makeGateway();
const user = makeUser({ permissions: [Permission.NotificationAccess, Permission.DemoRestricted] });
expect(() => (gateway as any).assertHasAccess(user)).toThrow('Demo-restricted account cannot access notifications');
});
it('denies demo-restricted superusers even with superuser flag set', () => {
const gateway = makeGateway();
const user = makeUser({ isSuperuser: true, permissions: [Permission.DemoRestricted] });
expect(() => (gateway as any).assertHasAccess(user)).toThrow('Demo-restricted account cannot access notifications');
});
it('denies users without notification access', () => {
const gateway = makeGateway();
const user = makeUser();
expect(() => (gateway as any).assertHasAccess(user)).toThrow('Missing permission: notification_access');
});
});
@@ -85,6 +85,9 @@ export class NotificationGateway implements OnGatewayInit, OnGatewayConnection,
} }
private assertHasAccess(user: RequestUser): void { private assertHasAccess(user: RequestUser): void {
if (user.permissions.includes(Permission.DemoRestricted)) {
throw new Error('Demo-restricted account cannot access notifications');
}
if (user.isSuperuser) return; if (user.isSuperuser) return;
if (user.permissions.includes(Permission.NotificationAccess)) return; if (user.permissions.includes(Permission.NotificationAccess)) return;
throw new Error('Missing permission: notification_access'); throw new Error('Missing permission: notification_access');
@@ -12,6 +12,7 @@ import { createReadStream } from 'fs';
import { stat } from 'fs/promises'; import { stat } from 'fs/promises';
import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator'; import { AUDITABLE_KEY } from '../../common/decorators/auditable.decorator';
import { FORBIDDEN_PERMISSION_KEY } from '../../common/decorators/forbid-permission.decorator';
import { UserController } from './user.controller'; import { UserController } from './user.controller';
import { MAX_USER_AVATAR_BYTES } from './user-avatar.service'; import { MAX_USER_AVATAR_BYTES } from './user-avatar.service';
@@ -201,4 +202,32 @@ describe('UserController', () => {
expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: true } }, null)).toBe('Enabled superuser for user #17'); expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: true } }, null)).toBe('Enabled superuser for user #17');
expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: false } }, null)).toBe('Disabled superuser for user #17'); expect(superuserAudit.description({ params: { id: '17' }, body: { isSuperuser: false } }, null)).toBe('Disabled superuser for user #17');
}); });
it('defines forbidden-permission metadata for self account edit endpoints', () => {
const updateProfileForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, UserController.prototype.updateMe) as {
permission: Permission;
message?: string;
};
const uploadAvatarForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, UserController.prototype.uploadMyAvatar) as {
permission: Permission;
message?: string;
};
const deleteAvatarForbidden = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, UserController.prototype.deleteMyAvatar) as {
permission: Permission;
message?: string;
};
expect(updateProfileForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
expect(uploadAvatarForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
expect(deleteAvatarForbidden).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot edit account settings',
});
});
}); });
@@ -26,6 +26,7 @@ import { stat } from 'fs/promises';
import { CurrentUser } from '../../common/decorators/current-user.decorator'; import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator'; import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import { Auditable } from '../../common/decorators/auditable.decorator'; import { Auditable } from '../../common/decorators/auditable.decorator';
import { ForbidPermission } from '../../common/decorators/forbid-permission.decorator';
import type { MultipartRequest } from '../../common/types/multipart-request'; import type { MultipartRequest } from '../../common/types/multipart-request';
import type { RequestUser } from '../../common/types/request-user'; import type { RequestUser } from '../../common/types/request-user';
import { CreateUserDto } from './dto/create-user.dto'; import { CreateUserDto } from './dto/create-user.dto';
@@ -64,6 +65,7 @@ export class UserController {
} }
@Patch('me') @Patch('me')
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
@Auditable({ @Auditable({
action: AuditAction.UserSelfUpdate, action: AuditAction.UserSelfUpdate,
resource: AuditResource.User, resource: AuditResource.User,
@@ -81,6 +83,7 @@ export class UserController {
} }
@Post('me/avatar') @Post('me/avatar')
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
@Auditable({ @Auditable({
action: AuditAction.UserSelfUpdate, action: AuditAction.UserSelfUpdate,
resource: AuditResource.User, resource: AuditResource.User,
@@ -111,6 +114,7 @@ export class UserController {
} }
@Delete('me/avatar') @Delete('me/avatar')
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot edit account settings')
@Auditable({ @Auditable({
action: AuditAction.UserSelfUpdate, action: AuditAction.UserSelfUpdate,
resource: AuditResource.User, resource: AuditResource.User,
+38 -4
View File
@@ -14,7 +14,9 @@ import {
import { APP_GUARD, Reflector } from '@nestjs/core'; import { APP_GUARD, Reflector } from '@nestjs/core';
import { Test, TestingModule } from '@nestjs/testing'; import { Test, TestingModule } from '@nestjs/testing';
import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify'; import { FastifyAdapter, type NestFastifyApplication } from '@nestjs/platform-fastify';
import { Permission } from '@bookorbit/types';
import { ForbidPermission } from '../src/common/decorators/forbid-permission.decorator';
import { IS_PUBLIC_KEY, Public } from '../src/common/decorators/public.decorator'; import { IS_PUBLIC_KEY, Public } from '../src/common/decorators/public.decorator';
import { RequirePermission } from '../src/common/decorators/require-permission.decorator'; import { RequirePermission } from '../src/common/decorators/require-permission.decorator';
import { PermissionGuard } from '../src/common/guards/permission.guard'; import { PermissionGuard } from '../src/common/guards/permission.guard';
@@ -35,7 +37,7 @@ const userRole = {
permissions: [] as Array<{ id: number; name: string }>, permissions: [] as Array<{ id: number; name: string }>,
}; };
function makeUser(permissionNames: string[]): RequestUser { function makeUser(permissionNames: Permission[]): RequestUser {
const permissions = permissionNames as RequestUser['permissions']; const permissions = permissionNames as RequestUser['permissions'];
return { return {
id: 1, id: 1,
@@ -70,7 +72,7 @@ class TestJwtAuthGuard implements CanActivate {
const auth = request.headers.authorization; const auth = request.headers.authorization;
if (auth === 'Bearer admin-token') { if (auth === 'Bearer admin-token') {
request.user = makeUser(['manage_users']); request.user = makeUser([Permission.ManageUsers]);
return true; return true;
} }
@@ -79,6 +81,11 @@ class TestJwtAuthGuard implements CanActivate {
return true; return true;
} }
if (auth === 'Bearer demo-admin-token') {
request.user = makeUser([Permission.ManageUsers, Permission.DemoRestricted]);
return true;
}
throw new UnauthorizedException(); throw new UnauthorizedException();
} }
} }
@@ -90,7 +97,7 @@ class SmokeAuthController {
@HttpCode(HttpStatus.OK) @HttpCode(HttpStatus.OK)
login(@Body() body: LoginBody) { login(@Body() body: LoginBody) {
if (body.username === 'admin' && body.password === 'admin') { if (body.username === 'admin' && body.password === 'admin') {
return { accessToken: 'admin-token', user: makeUser(['manage_users']) }; return { accessToken: 'admin-token', user: makeUser([Permission.ManageUsers]) };
} }
return { accessToken: 'user-token', user: makeUser([]) }; return { accessToken: 'user-token', user: makeUser([]) };
} }
@@ -104,10 +111,17 @@ class SmokeProtectedController {
} }
@Get('admin') @Get('admin')
@RequirePermission('manage_users') @RequirePermission(Permission.ManageUsers)
getAdmin() { getAdmin() {
return { ok: true }; return { ok: true };
} }
@Get('bulk-like')
@RequirePermission(Permission.ManageUsers)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot perform bulk edits')
getBulkLike() {
return { ok: true };
}
} }
@Module({ @Module({
@@ -172,4 +186,24 @@ describe('Guard mechanics (e2e)', () => {
expect(res.statusCode).toBe(200); expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true }); expect(res.json()).toEqual({ ok: true });
}); });
it('GET /api/v1/smoke/bulk-like denies users with demo restriction marker', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/smoke/bulk-like',
headers: { authorization: 'Bearer demo-admin-token' },
});
expect(res.statusCode).toBe(403);
expect(res.json().message).toBe('Demo-restricted account cannot perform bulk edits');
});
it('GET /api/v1/smoke/bulk-like allows users without demo restriction marker', async () => {
const res = await app.inject({
method: 'GET',
url: '/api/v1/smoke/bulk-like',
headers: { authorization: 'Bearer admin-token' },
});
expect(res.statusCode).toBe(200);
expect(res.json()).toEqual({ ok: true });
});
}); });