feat(settings): add per-user Requests visibility toggle

Let users hide request entry points and notifications while existing requests continue running. Keep the setting optional so older clients retain their current behavior.

Ref #1476
This commit is contained in:
neon
2026-09-26 18:46:10 +00:00
parent 8dfb093301
commit 55110d5b44
18 changed files with 224 additions and 16 deletions
+9 -3
View File
@@ -40,6 +40,7 @@ import SettingsSidebar from '@/features/settings/components/SettingsSidebar.vue'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
import { useBookRequestSummary } from '@/features/book-requests/composables/useBookRequestSummary'
import { useBookRequestProgress } from '@/features/book-requests/composables/useBookRequestProgress'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
const { t } = useI18n()
const route = useRoute()
@@ -50,6 +51,7 @@ const { libraries, fetchLibraries, refreshLibraries, reorderLibraries } = useLib
const { bookScopes, podcastScopes, fetchSmartScopes, reorderSmartScopes } = useSmartScopes()
const { bookCollections, podcastCollections, fetchCollections, reorderCollections } = useCollections()
const { hasPermission } = usePermissions()
const { showBookRequests } = useBookRequestVisibility()
const { subscribeLibrary, getProgress } = useScanProgress()
const podcastImportProgress = APP_FEATURES.podcasts ? usePodcastImportProgress() : null
const { handleLibraryCreated } = useLibraryCreationRedirect()
@@ -268,7 +270,7 @@ onMounted(async () => {
void fetchSmartScopes()
void fetchCollections()
void fetchBrowseCounts()
if (hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
if (showBookRequests.value && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
void loadAppInfo()
if (hasPermission('book_dock_access')) {
void fetchBookDockSummary()
@@ -277,15 +279,19 @@ onMounted(async () => {
})
requestProgress?.onRequestsChanged(() => {
void refreshBookRequestSummary()
if (showBookRequests.value) void refreshBookRequestSummary()
})
if (requestProgress) {
watch(requestProgress.connected, (connected) => {
if (connected) void refreshBookRequestSummary()
if (connected && showBookRequests.value) void refreshBookRequestSummary()
})
}
watch(showBookRequests, (visible) => {
if (visible && hasPermission(Permission.BookRequestAccess)) void fetchBookRequestSummary()
})
const { onLibraryUploadCompleted } = useLibraryUploadEvents()
const stopLibraryUploadListener = onLibraryUploadCompleted((event) => {
if (event.uploadedCount === 0) return
@@ -31,6 +31,7 @@ vi.mock('@/features/auth/composables/usePermissions', () => ({
function makeContext(
overrides: Partial<{
permissions: string[]
showBookRequests: boolean
bookDockTotal: number
outstandingRequestTotal: number
outstandingRequestLabel: string
@@ -41,6 +42,7 @@ function makeContext(
const permissions = overrides.permissions ?? []
return {
hasPermission: (name: string) => permissions.includes(name),
showBookRequests: overrides.showBookRequests,
bookDockTotal: overrides.bookDockTotal ?? 0,
outstandingRequestTotal: overrides.outstandingRequestTotal ?? 0,
outstandingRequestLabel: overrides.outstandingRequestLabel ?? '',
@@ -97,6 +99,12 @@ describe('sidebar nav registry', () => {
expect(allowedIds(makeContext())).not.toContain('book-requests')
})
it('hides Requests without changing permission when the user turns visibility off', () => {
const context = makeContext({ permissions: ['book_request_access'], showBookRequests: false })
expect(allowedIds(context)).not.toContain('book-requests')
expect(context.hasPermission('book_request_access')).toBe(true)
})
it.each(['book-requests', 'book-request-detail', 'book-request-releases'])('keeps Requests active on %s', (routeName) => {
const resolved = resolveNavEntry(entry('book-requests'), makeContext({ permissions: ['book_request_access'] }), makeRoute(routeName), 'Requests')
+5
View File
@@ -8,12 +8,14 @@ import { useBookDockSummary } from '@/features/book-dock/composables/useBookDock
import { useLibraries } from '@/features/library/composables/useLibraries'
import { useBrowseCounts } from '@/composables/useBrowseCounts'
import { useMediaMode } from '@/composables/useMediaMode'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
export const SIDEBAR_ZONE_IDS = ['primary', 'browse'] as const
export type SidebarZoneId = (typeof SIDEBAR_ZONE_IDS)[number]
interface NavContext {
hasPermission: (name: string) => boolean
showBookRequests?: boolean
bookDockTotal: number
outstandingRequestTotal: number
outstandingRequestLabel: string
@@ -115,6 +117,7 @@ export const SIDEBAR_NAV_REGISTRY: readonly SidebarNavEntry[] = [
// The drawer routes are children of this one, so the row has to stay lit while one is open.
isActive: (route) => routeNameStartsWith(route, 'book-request'),
permission: 'book_request_access',
visible: (context) => context.showBookRequests !== false,
badge: (context) =>
context.outstandingRequestTotal > 0 ? { value: context.outstandingRequestTotal, label: context.outstandingRequestLabel, tone: 'accent' } : null,
},
@@ -203,6 +206,7 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0
const { counts: browseCounts } = useBrowseCounts()
const { libraries } = useLibraries()
const { mode } = useMediaMode()
const { showBookRequests } = useBookRequestVisibility()
const context = computed<NavContext>(() => {
const outstandingRequestTotal = getOutstandingRequestTotal()
@@ -212,6 +216,7 @@ export function useSidebarNav(getOutstandingRequestTotal: () => number = () => 0
return {
hasPermission,
showBookRequests: showBookRequests.value,
bookDockTotal: bookDockSummary.value.total,
outstandingRequestTotal,
outstandingRequestLabel: t(outstandingRequestLabelKey, { count: outstandingRequestTotal }),
@@ -0,0 +1,48 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { useBookRequestVisibility } from './useBookRequestVisibility'
const state = vi.hoisted(() => ({
user: null as { id: number; settings: { showBookRequests?: boolean } } | null,
api: vi.fn<() => Promise<{ ok: boolean }>>(),
fetchNotifications: vi.fn<() => Promise<void>>(),
fetchUnreadCount: vi.fn<() => Promise<void>>(),
}))
vi.mock('@/features/auth/composables/useAuth', () => ({ useAuth: () => ({ user: ref(state.user) }) }))
vi.mock('@/lib/api', () => ({ api: state.api }))
vi.mock('@/features/notifications/composables/useNotifications', () => ({
useNotifications: () => ({ fetchNotifications: state.fetchNotifications, fetchUnreadCount: state.fetchUnreadCount }),
}))
beforeEach(() => {
vi.clearAllMocks()
state.user = { id: 7, settings: {} }
state.api.mockResolvedValue({ ok: true })
})
describe('useBookRequestVisibility', () => {
it('shows Requests when the preference is absent, including older server responses', () => {
expect(useBookRequestVisibility().showBookRequests.value).toBe(true)
})
it('saves only the new setting and updates the current account after success', async () => {
const visibility = useBookRequestVisibility()
expect(await visibility.setShowBookRequests(false)).toBe(true)
expect(state.api).toHaveBeenCalledWith('/api/v1/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { showBookRequests: false } }),
})
expect(visibility.showBookRequests.value).toBe(false)
expect(state.fetchNotifications).toHaveBeenCalledWith(true)
expect(state.fetchUnreadCount).toHaveBeenCalled()
})
it('leaves visibility unchanged when the save fails', async () => {
state.api.mockResolvedValue({ ok: false })
const visibility = useBookRequestVisibility()
expect(await visibility.setShowBookRequests(false)).toBe(false)
expect(visibility.showBookRequests.value).toBe(true)
})
})
@@ -0,0 +1,37 @@
import { computed, ref } from 'vue'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api } from '@/lib/api'
import { useNotifications } from '@/features/notifications/composables/useNotifications'
const saving = ref(false)
export function useBookRequestVisibility() {
const { user } = useAuth()
const showBookRequests = computed(() => user.value?.settings?.showBookRequests !== false)
async function setShowBookRequests(show: boolean): Promise<boolean> {
if (!user.value || saving.value) return false
if (showBookRequests.value === show) return true
const userId = user.value.id
saving.value = true
try {
const response = await api('/api/v1/users/me/settings', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: { showBookRequests: show } }),
})
if (!response.ok || user.value?.id !== userId) return false
user.value = { ...user.value, settings: { ...user.value.settings, showBookRequests: show } }
const { fetchNotifications, fetchUnreadCount } = useNotifications()
void Promise.all([fetchNotifications(true), fetchUnreadCount()])
return true
} catch {
return false
} finally {
saving.value = false
}
}
return { showBookRequests, saving, setShowBookRequests }
}
@@ -12,12 +12,14 @@ import SettingsPageHeader from '@/features/settings/SettingsPageHeader.vue'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import NotificationLevelSegmented from './NotificationLevelSegmented.vue'
import { NOTIFICATION_CATEGORY_GROUPS, NOTIFICATION_CATEGORY_ICONS } from '../lib/notification-category-groups'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
const props = withDefaults(defineProps<{ embedded?: boolean }>(), { embedded: false })
const { t } = useI18n()
const { user, me } = useAuth()
const { showBookRequests } = useBookRequestVisibility()
const { popupEnabled, setPopupEnabled, loadPrefs } = useWhatsNew()
const saving = ref(false)
@@ -54,8 +56,15 @@ const hasChanges = computed(() => {
return NOTIFICATION_CATEGORY_IDS.some((key) => preferences.value[key] !== resolveNotificationLevel(userPrefs?.[key]))
})
const enabledCount = computed(() => NOTIFICATION_CATEGORY_IDS.filter((key) => preferences.value[key] !== NotificationLevel.Off).length)
const totalCount = computed(() => NOTIFICATION_CATEGORY_IDS.length)
const visibleGroups = computed(() =>
NOTIFICATION_CATEGORY_GROUPS.map((group) => ({
...group,
categories: group.categories.filter((category) => category !== 'bookRequests' || showBookRequests.value),
})).filter((group) => group.categories.length > 0),
)
const visibleCategoryIds = computed(() => visibleGroups.value.flatMap((group) => group.categories))
const enabledCount = computed(() => visibleCategoryIds.value.filter((key) => preferences.value[key] !== NotificationLevel.Off).length)
const totalCount = computed(() => visibleCategoryIds.value.length)
function categoryLabel(category: NotificationCategory): string {
return t(`notifications.preferences.categories.${category}.label`)
@@ -126,7 +135,7 @@ async function handleSave() {
</div>
<div class="space-y-4" :class="{ 'mt-5 md:mt-0': !props.embedded }">
<section v-for="group in NOTIFICATION_CATEGORY_GROUPS" :key="group.id" :aria-labelledby="`notification-group-${group.id}`" class="space-y-2">
<section v-for="group in visibleGroups" :key="group.id" :aria-labelledby="`notification-group-${group.id}`" class="space-y-2">
<div class="flex items-baseline justify-between gap-3">
<h2 :id="`notification-group-${group.id}`" class="settings-group-label mb-0">
{{ t(`notifications.preferences.groups.${group.id}`) }}
@@ -2,14 +2,17 @@
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { BookOpen, BookText } from '@lucide/vue'
import { toast } from 'vue-sonner'
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
import { useDisplaySettings, type BookThumbnailClickAction } from '@/composables/useDisplaySettings'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
const { t } = useI18n()
const { smartScopeFilterExpanded, thumbnailClickAction } = useDisplaySettings()
const { prefs, setPreference } = useSeriesCollapsePreference()
const { showBookRequests, saving: savingBookRequestVisibility, setShowBookRequests } = useBookRequestVisibility()
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
@@ -39,6 +42,10 @@ async function handleGlobalCollapseToggle(value: boolean) {
await setPreference('global', value)
}
async function handleBookRequestVisibilityToggle(value: boolean) {
if (!(await setShowBookRequests(value))) toast.error(t('settings.appearance.behavior.bookRequests.saveFailed'))
}
function setThumbnailClickAction(action: BookThumbnailClickAction) {
thumbnailClickAction.value = action
}
@@ -100,6 +107,18 @@ function setThumbnailClickAction(action: BookThumbnailClickAction) {
</div>
<ToggleSwitch :model-value="globalCollapseEnabled" @update:model-value="handleGlobalCollapseToggle" />
</div>
<div class="flex items-center justify-between gap-3 px-4 py-3 md:px-5 md:py-3.5 bg-card">
<div class="min-w-0">
<p class="settings-label">{{ t('settings.appearance.behavior.bookRequests.label') }}</p>
<p class="settings-hint">{{ t('settings.appearance.behavior.bookRequests.hint') }}</p>
</div>
<ToggleSwitch
:model-value="showBookRequests"
:disabled="savingBookRequestVisibility"
:aria-label="t('settings.appearance.behavior.bookRequests.label')"
@update:model-value="handleBookRequestVisibilityToggle"
/>
</div>
</div>
</div>
</template>
@@ -57,6 +57,13 @@ describe('visibleSettingsNav', () => {
expect(visibleSettingsNav(SUPERUSER).map((group) => group.id)).toEqual(['you', 'library', 'devices', 'accounts', 'server'])
})
it('hides only request administration when Requests are hidden', () => {
const normal = visibleSettingsNav(SUPERUSER).flatMap((group) => group.items.map((item) => item.id))
const hidden = visibleSettingsNav({ ...SUPERUSER, showBookRequests: false }).flatMap((group) => group.items.map((item) => item.id))
expect(normal).toContain('requests')
expect(hidden).toEqual(normal.filter((id) => id !== 'requests'))
})
it('hides notifications without notification access', () => {
const you = visibleSettingsNav(NOBODY)[0]
expect(you?.items.map((item) => item.id)).not.toContain('notifications')
@@ -6,6 +6,7 @@ import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft, ChevronRight, Search, X } from '@lucide/vue'
import { useModifierKey } from '@/composables/useModifierKey'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
import { useSettingsNavStatus } from '../composables/useSettingsNavStatus'
import { visibleSettingsNav, type SettingsNavGroup, type SettingsNavItem } from '../lib/settings-nav'
interface SearchHit {
@@ -18,6 +19,7 @@ const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const { isSuperuser, userPermissions, isDemoRestrictedAccount } = usePermissions()
const { showBookRequests } = useBookRequestVisibility()
const { isMac, modifierKey } = useModifierKey()
/** Apple writes the combo as one glyph pair, everyone else separates the two keys. */
@@ -31,6 +33,7 @@ const groups = computed(() =>
isSuperuser: isSuperuser.value,
permissions: userPermissions.value,
isDemoRestricted: isDemoRestrictedAccount.value,
showBookRequests: showBookRequests.value,
}),
)
@@ -5,6 +5,7 @@ import { useRoute } from 'vue-router'
import { ArrowLeft } from '@lucide/vue'
import SidebarSectionPopover from '@/components/sidebar/SidebarSectionPopover.vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { useBookRequestVisibility } from '@/features/book-requests/composables/useBookRequestVisibility'
import { visibleSettingsNav, type SettingsNavItem } from '../lib/settings-nav'
import SettingsNav from './SettingsNav.vue'
@@ -13,12 +14,14 @@ withDefaults(defineProps<{ isRail?: boolean }>(), { isRail: false })
const { t } = useI18n()
const route = useRoute()
const { isSuperuser, userPermissions, isDemoRestrictedAccount } = usePermissions()
const { showBookRequests } = useBookRequestVisibility()
const groups = computed(() =>
visibleSettingsNav({
isSuperuser: isSuperuser.value,
permissions: userPermissions.value,
isDemoRestricted: isDemoRestrictedAccount.value,
showBookRequests: showBookRequests.value,
}),
)
@@ -49,6 +49,7 @@ export interface SettingsNavContext {
isSuperuser: boolean
permissions: readonly string[]
isDemoRestricted: boolean
showBookRequests?: boolean
}
/** Live signals the rail renders beside a row. Resolved by the nav, not stored in this file. */
@@ -496,7 +497,7 @@ export const SETTINGS_NAV: readonly SettingsNavGroup[] = [
descriptionKey: 'settings.nav.descriptions.requests',
icon: DownloadCloud,
keywords: 'request download client qbittorrent torrent magnet path mapping hardlink',
isVisible: anyPermission(Permission.ManageAppSettings),
isVisible: (context) => context.showBookRequests !== false && anyPermission(Permission.ManageAppSettings)(context),
},
{
id: 'admin-tts',
+5
View File
@@ -428,6 +428,11 @@
"collapseSeries": {
"label": "Collapse series by default",
"hint": "Group books in the same series into a single card in library and collection views"
},
"bookRequests": {
"label": "Show Book Requests",
"hint": "Show request pages, prompts, and settings in this account. Existing requests keep running when hidden.",
"saveFailed": "Could not update Book Requests visibility"
}
},
"icons": {
@@ -51,6 +51,10 @@ function makeRouter() {
{ path: '/setup', name: 'setup', component: blank, meta: { public: true } },
{ path: '/forgot-password', name: 'forgot-password', component: blank, meta: { public: true } },
{ path: '/reset-password', name: 'reset-password', component: blank, meta: { public: true } },
{ path: '/requests', name: 'book-requests', component: blank },
{ path: '/requests/:id', name: 'book-request-detail', component: blank },
{ path: '/settings/admin/requests', name: 'settings-admin-requests', component: blank },
{ path: '/settings/appearance/behavior', name: 'settings-appearance-behavior', component: blank },
],
})
registerAuthGuard(router)
@@ -65,6 +69,22 @@ beforeEach(() => {
statusState.passwordLoginEnabled = true
})
describe('auth guard: hidden book requests', () => {
it.each(['/requests', '/requests/42', '/settings/admin/requests'])('redirects %s to the visibility setting', async (path) => {
authState.user = { id: 1, isDefaultPassword: false, permissions: [], settings: { showBookRequests: false } }
const router = makeRouter()
await router.push(path)
expect(router.currentRoute.value.name).toBe('settings-appearance-behavior')
})
it('preserves existing request links when the preference is absent', async () => {
authState.user = { id: 1, isDefaultPassword: false, permissions: [], settings: {} }
const router = makeRouter()
await router.push('/requests')
expect(router.currentRoute.value.name).toBe('book-requests')
})
})
describe('auth guard: /register', () => {
it('redirects a visitor to /login when self-registration is closed', async () => {
const router = makeRouter()
+10
View File
@@ -72,6 +72,16 @@ export function registerAuthGuard(router: Router): void {
return { name: 'settings-account-profile' }
}
if (
user.value.settings.showBookRequests === false &&
(to.name === 'book-requests' ||
to.name === 'book-request-detail' ||
to.name === 'book-request-releases' ||
to.name === 'settings-admin-requests')
) {
return { name: 'settings-appearance-behavior' }
}
return true
})
}