mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
feat(auth): enforce edit restrictions on demo-restricted accounts
This commit is contained in:
@@ -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>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user