fix(settings): gate integration tabs by integration permissions

Prevent unauthorized users from seeing integrations they cannot use and keep settings routing consistent with API authorization behavior. Add hardcover_sync and enforce it server-side so tab visibility and endpoint access stay aligned.
This commit is contained in:
neon
2026-05-26 19:52:32 +00:00
parent 78b99d0b78
commit 3a72ece26d
10 changed files with 155 additions and 128 deletions
+2 -1
View File
@@ -37,7 +37,7 @@ const PERMISSION_GROUPS: { label: string; permissions: Permission[] }[] = [
},
{
label: 'Devices & Access',
permissions: [Permission.KoboSync, Permission.KoreaderSync, Permission.OpdsAccess, Permission.BookDockAccess],
permissions: [Permission.KoboSync, Permission.KoreaderSync, Permission.HardcoverSync, Permission.OpdsAccess, Permission.BookDockAccess],
},
{
label: 'Email',
@@ -106,6 +106,7 @@ function applyPreset(preset: 'standard' | 'admin' | 'clear') {
Permission.LibraryDownload,
Permission.KoboSync,
Permission.KoreaderSync,
Permission.HardcoverSync,
Permission.OpdsAccess,
Permission.BookDockAccess,
])
@@ -1,31 +1,40 @@
<script setup lang="ts">
import { ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SettingsPageHeader from './SettingsPageHeader.vue'
import KoboSettings from './KoboSettings.vue'
import KoreaderSettings from './KoreaderSettings.vue'
import HardcoverSettings from '@/features/hardcover/components/HardcoverSettings.vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { INTEGRATIONS_TAB_INFO, INTEGRATIONS_TABS, normalizeIntegrationsTab, type IntegrationsTab as Tab } from './lib/integrations-tabs'
const route = useRoute()
const router = useRouter()
const { hasPermission } = usePermissions()
const activeTab = ref<Tab>(normalizeIntegrationsTab(route.query.tab))
if (!route.query.tab) {
router.replace({ name: 'settings-integrations', query: { ...route.query, tab: activeTab.value } })
}
const allowedTabs = computed<Tab[]>(() => INTEGRATIONS_TABS.filter((tab) => hasPermission(INTEGRATIONS_TAB_INFO[tab].permission)))
const activeTab = ref<Tab>(normalizeIntegrationsTab(route.query.tab, allowedTabs.value))
watch(
() => route.query.tab,
(value) => {
activeTab.value = normalizeIntegrationsTab(value)
[() => route.query.tab, allowedTabs],
([value, tabs]) => {
if (tabs.length === 0) {
router.replace({ name: 'settings-appearance' })
return
}
const normalized = normalizeIntegrationsTab(value, tabs)
activeTab.value = normalized
if (value !== normalized) {
router.replace({ name: 'settings-integrations', query: { ...route.query, tab: normalized } })
}
},
{ immediate: true },
)
const tabs = INTEGRATIONS_TABS.map((id) => ({ id, label: INTEGRATIONS_TAB_INFO[id].navLabel }))
const tabs = computed(() => allowedTabs.value.map((id) => ({ id, label: INTEGRATIONS_TAB_INFO[id].navLabel })))
function selectTab(tab: Tab) {
if (!allowedTabs.value.includes(tab)) return
activeTab.value = tab
router.replace({ name: 'settings-integrations', query: { ...route.query, tab } })
}
@@ -35,6 +44,7 @@ function selectTab(tab: Tab) {
<SettingsPageHeader title="Integrations" subtitle="Configure connections to external devices and services." />
<div
v-if="tabs.length > 0"
class="flex gap-1 mb-5 md:mb-6 border-b border-border overflow-x-auto md:overflow-visible md:static sticky top-0 z-20 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80 snap-x"
>
<button
@@ -50,7 +60,7 @@ function selectTab(tab: Tab) {
</button>
</div>
<KoboSettings v-if="activeTab === 'kobo'" embedded />
<KoreaderSettings v-else-if="activeTab === 'koreader'" embedded />
<HardcoverSettings v-else-if="activeTab === 'hardcover'" embedded />
<KoboSettings v-if="tabs.length > 0 && activeTab === 'kobo'" embedded />
<KoreaderSettings v-else-if="tabs.length > 0 && activeTab === 'koreader'" embedded />
<HardcoverSettings v-else-if="tabs.length > 0 && activeTab === 'hardcover'" embedded />
</template>
@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Permission } from '@bookorbit/types'
import { usePermissions } from '@/features/auth/composables/usePermissions'
const route = useRoute()
@@ -33,7 +34,9 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'Metadata', routeName: 'settings-admin-metadata' })
}
result.push({ label: 'Integrations', routeName: 'settings-integrations' })
if (su || perms.includes(Permission.KoboSync) || perms.includes(Permission.KoreaderSync) || perms.includes(Permission.HardcoverSync)) {
result.push({ label: 'Integrations', routeName: 'settings-integrations' })
}
if (su || perms.includes('email_send') || perms.includes('manage_email')) {
result.push({ label: 'Email', routeName: 'settings-email' })
@@ -1,11 +1,16 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import { Permission } from '@bookorbit/types'
import IntegrationsAllSettings from '../IntegrationsAllSettings.vue'
// --- Router mock state ---
const permissionState = {
isSuperuser: false,
permissions: [] as string[],
}
const routerState = {
currentQuery: {} as Record<string, string>,
replacedQuery: null as Record<string, string> | null,
replaceCalls: [] as Array<{ name: string; query?: Record<string, string> }>,
}
vi.mock('vue-router', () => ({
@@ -13,12 +18,18 @@ vi.mock('vue-router', () => ({
query: routerState.currentQuery,
}),
useRouter: () => ({
replace: vi.fn<(to: { name: string; query: Record<string, string> }) => void>((to) => {
routerState.replacedQuery = to.query
replace: vi.fn<(to: { name: string; query?: Record<string, string> }) => void>((to) => {
routerState.replaceCalls.push(to)
}),
}),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
hasPermission: (name: string) => permissionState.isSuperuser || permissionState.permissions.includes(name),
}),
}))
vi.mock('../KoboSettings.vue', () => ({ default: { template: '<div data-testid="kobo-settings" />' } }))
vi.mock('../KoreaderSettings.vue', () => ({ default: { template: '<div data-testid="koreader-settings" />' } }))
vi.mock('@/features/hardcover/components/HardcoverSettings.vue', () => ({
@@ -26,123 +37,75 @@ vi.mock('@/features/hardcover/components/HardcoverSettings.vue', () => ({
}))
vi.mock('../SettingsPageHeader.vue', () => ({ default: { template: '<div />' } }))
function mountComponent(queryTab?: string) {
routerState.currentQuery = queryTab ? { tab: queryTab } : {}
routerState.replacedQuery = null
function mountComponent(opts?: { queryTab?: string; perms?: string[]; su?: boolean }) {
permissionState.permissions = opts?.perms ?? []
permissionState.isSuperuser = opts?.su ?? false
routerState.currentQuery = opts?.queryTab ? { tab: opts.queryTab } : {}
routerState.replaceCalls = []
return mount(IntegrationsAllSettings)
}
function labels(wrapper: ReturnType<typeof mount>): string[] {
return wrapper.findAll('button').map((button) => button.text())
}
function lastReplace() {
if (routerState.replaceCalls.length === 0) return null
return routerState.replaceCalls[routerState.replaceCalls.length - 1] ?? null
}
describe('IntegrationsAllSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
describe('default tab', () => {
it('shows kobo tab content when no ?tab param is present', () => {
const wrapper = mountComponent()
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
})
it('replaces URL with ?tab=kobo when no tab param is present', () => {
mountComponent()
expect(routerState.replacedQuery?.tab).toBe('kobo')
})
it('does not replace URL when tab param is already set', () => {
mountComponent('koreader')
expect(routerState.replacedQuery).toBeNull()
})
it('redirects to appearance when user has no integration permissions', () => {
const wrapper = mountComponent()
expect(labels(wrapper)).toEqual([])
expect(lastReplace()).toEqual({ name: 'settings-appearance' })
})
describe('tab rendering', () => {
it('renders KoboSettings when tab=kobo', () => {
const wrapper = mountComponent('kobo')
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="koreader-settings"]').exists()).toBe(false)
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(false)
})
it('renders KoreaderSettings when tab=koreader', () => {
const wrapper = mountComponent('koreader')
expect(wrapper.find('[data-testid="koreader-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(false)
})
it('renders HardcoverSettings when tab=hardcover', () => {
const wrapper = mountComponent('hardcover')
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(false)
})
it('falls back to kobo for unknown tab value', () => {
const wrapper = mountComponent('unknown-tab')
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
})
it('shows only Kobo tab for kobo_sync users and defaults URL to ?tab=kobo', () => {
const wrapper = mountComponent({ perms: [Permission.KoboSync] })
expect(labels(wrapper)).toEqual(['Kobo'])
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
expect(lastReplace()).toEqual({ name: 'settings-integrations', query: { tab: 'kobo' } })
})
describe('tab navigation', () => {
it('renders three tab buttons', () => {
const wrapper = mountComponent('kobo')
const buttons = wrapper.findAll('button')
expect(buttons).toHaveLength(3)
})
it('tab buttons have correct labels', () => {
const wrapper = mountComponent('kobo')
const labels = wrapper.findAll('button').map((b) => b.text())
expect(labels).toContain('Kobo')
expect(labels).toContain('KOReader')
expect(labels).toContain('Hardcover')
})
it('active tab button has border-primary class', () => {
const wrapper = mountComponent('kobo')
const koboBtn = wrapper.findAll('button').find((b) => b.text() === 'Kobo')
expect(koboBtn?.classes()).toContain('border-primary')
})
it('inactive tab buttons have border-transparent class', () => {
const wrapper = mountComponent('kobo')
const koreaderBtn = wrapper.findAll('button').find((b) => b.text() === 'KOReader')
expect(koreaderBtn?.classes()).toContain('border-transparent')
})
it('clicking a tab button calls router.replace with the correct tab', async () => {
routerState.currentQuery = { tab: 'kobo' }
routerState.replacedQuery = null
const wrapper = mount(IntegrationsAllSettings)
const koreaderBtn = wrapper.findAll('button').find((b) => b.text() === 'KOReader')
await koreaderBtn!.trigger('click')
expect(routerState.replacedQuery!['tab']).toBe('koreader')
})
it('clicking a tab updates the displayed component', async () => {
routerState.currentQuery = { tab: 'kobo' }
const wrapper = mount(IntegrationsAllSettings)
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
const hardcoverBtn = wrapper.findAll('button').find((b) => b.text() === 'Hardcover')
await hardcoverBtn!.trigger('click')
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(false)
})
it('shows only KOReader tab for koreader_sync users and defaults URL to ?tab=koreader', () => {
const wrapper = mountComponent({ perms: [Permission.KoreaderSync] })
expect(labels(wrapper)).toEqual(['KOReader'])
expect(wrapper.find('[data-testid="koreader-settings"]').exists()).toBe(true)
expect(lastReplace()).toEqual({ name: 'settings-integrations', query: { tab: 'koreader' } })
})
describe('tab nav sticky bar', () => {
it('tab nav container has sticky class', () => {
const wrapper = mountComponent('kobo')
const nav = wrapper.find('div.sticky')
expect(nav.exists()).toBe(true)
it('shows only Hardcover tab for hardcover_sync users and defaults URL to ?tab=hardcover', () => {
const wrapper = mountComponent({ perms: [Permission.HardcoverSync] })
expect(labels(wrapper)).toEqual(['Hardcover'])
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(true)
expect(lastReplace()).toEqual({ name: 'settings-integrations', query: { tab: 'hardcover' } })
})
it('falls back to the first allowed tab when query tab is not permitted', () => {
const wrapper = mountComponent({ queryTab: 'hardcover', perms: [Permission.KoreaderSync] })
expect(wrapper.find('[data-testid="koreader-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(false)
expect(lastReplace()).toEqual({ name: 'settings-integrations', query: { tab: 'koreader' } })
})
it('renders and switches all three tabs when user has all three permissions', async () => {
const wrapper = mountComponent({
queryTab: 'kobo',
perms: [Permission.KoboSync, Permission.KoreaderSync, Permission.HardcoverSync],
})
it('tab nav container has z-20 class', () => {
const wrapper = mountComponent('kobo')
const nav = wrapper.find('div.z-20')
expect(nav.exists()).toBe(true)
})
expect(labels(wrapper)).toEqual(['Kobo', 'KOReader', 'Hardcover'])
expect(wrapper.find('[data-testid="kobo-settings"]').exists()).toBe(true)
const hardcoverButton = wrapper.findAll('button').find((button) => button.text() === 'Hardcover')
await hardcoverButton!.trigger('click')
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(true)
expect(lastReplace()).toEqual({ name: 'settings-integrations', query: { tab: 'hardcover' } })
})
})
@@ -41,7 +41,7 @@ describe('SettingsHeader', () => {
vi.clearAllMocks()
})
describe('tabs always visible to all users', () => {
describe('tab visibility', () => {
it('shows Appearance tab', () => {
const labels = getTabLabels(mountHeader())
expect(labels).toContain('Appearance')
@@ -57,8 +57,28 @@ describe('SettingsHeader', () => {
expect(labels).toContain('Account')
})
it('shows Integrations tab for all users (Hardcover has no permission gate)', () => {
it('hides Integrations tab when user has no integration permissions', () => {
const labels = getTabLabels(mountHeader())
expect(labels).not.toContain('Integrations')
})
it('shows Integrations tab for users with kobo_sync', () => {
const labels = getTabLabels(mountHeader({ perms: ['kobo_sync'] }))
expect(labels).toContain('Integrations')
})
it('shows Integrations tab for users with koreader_sync', () => {
const labels = getTabLabels(mountHeader({ perms: ['koreader_sync'] }))
expect(labels).toContain('Integrations')
})
it('shows Integrations tab for users with hardcover_sync', () => {
const labels = getTabLabels(mountHeader({ perms: ['hardcover_sync'] }))
expect(labels).toContain('Integrations')
})
it('shows Integrations tab for superusers', () => {
const labels = getTabLabels(mountHeader({ su: true }))
expect(labels).toContain('Integrations')
})
})
@@ -210,7 +230,7 @@ describe('SettingsHeader', () => {
})
it('integrations tab is active when route is settings-integrations', () => {
const wrapper = mountHeader({ routeName: 'settings-integrations' })
const wrapper = mountHeader({ routeName: 'settings-integrations', perms: ['kobo_sync'] })
const btn = wrapper.findAll('button').find((b) => b.text() === 'Integrations')
expect(btn?.classes()).toContain('border-primary')
})
@@ -1,4 +1,5 @@
import { describe, expect, it } from 'vitest'
import { Permission } from '@bookorbit/types'
import { INTEGRATIONS_TABS, INTEGRATIONS_TAB_INFO, normalizeIntegrationsTab } from '../lib/integrations-tabs'
describe('integrations-tabs', () => {
@@ -19,7 +20,7 @@ describe('integrations-tabs', () => {
}
})
it('every entry has navLabel, titleLabel, and subtitle', () => {
it('every entry has navLabel, titleLabel, subtitle, and permission', () => {
for (const tab of INTEGRATIONS_TABS) {
const info = INTEGRATIONS_TAB_INFO[tab]
expect(typeof info.navLabel).toBe('string')
@@ -28,22 +29,26 @@ describe('integrations-tabs', () => {
expect(info.titleLabel.length).toBeGreaterThan(0)
expect(typeof info.subtitle).toBe('string')
expect(info.subtitle.length).toBeGreaterThan(0)
expect(typeof info.permission).toBe('string')
}
})
it('kobo entry has correct labels', () => {
expect(INTEGRATIONS_TAB_INFO.kobo.navLabel).toBe('Kobo')
expect(INTEGRATIONS_TAB_INFO.kobo.titleLabel).toBe('Kobo Sync')
expect(INTEGRATIONS_TAB_INFO.kobo.permission).toBe(Permission.KoboSync)
})
it('koreader entry has correct labels', () => {
expect(INTEGRATIONS_TAB_INFO.koreader.navLabel).toBe('KOReader')
expect(INTEGRATIONS_TAB_INFO.koreader.titleLabel).toBe('KOReader Sync')
expect(INTEGRATIONS_TAB_INFO.koreader.permission).toBe(Permission.KoreaderSync)
})
it('hardcover entry has correct labels', () => {
expect(INTEGRATIONS_TAB_INFO.hardcover.navLabel).toBe('Hardcover')
expect(INTEGRATIONS_TAB_INFO.hardcover.titleLabel).toBe('Hardcover')
expect(INTEGRATIONS_TAB_INFO.hardcover.permission).toBe(Permission.HardcoverSync)
})
})
@@ -87,5 +92,13 @@ describe('integrations-tabs', () => {
it('is case-sensitive (Kobo is not a valid tab)', () => {
expect(normalizeIntegrationsTab('Kobo')).toBe('kobo')
})
it('falls back to first allowed tab when current tab is not allowed', () => {
expect(normalizeIntegrationsTab('kobo', ['koreader', 'hardcover'])).toBe('koreader')
})
it('returns the allowed tab when current tab is allowed', () => {
expect(normalizeIntegrationsTab('hardcover', ['koreader', 'hardcover'])).toBe('hardcover')
})
})
})
@@ -1,3 +1,5 @@
import { Permission } from '@bookorbit/types'
export const INTEGRATIONS_TABS = ['kobo', 'koreader', 'hardcover'] as const
export type IntegrationsTab = (typeof INTEGRATIONS_TABS)[number]
@@ -6,6 +8,7 @@ type IntegrationsTabInfo = {
navLabel: string
titleLabel: string
subtitle: string
permission: Permission
}
export const INTEGRATIONS_TAB_INFO: Record<IntegrationsTab, IntegrationsTabInfo> = {
@@ -13,22 +16,26 @@ export const INTEGRATIONS_TAB_INFO: Record<IntegrationsTab, IntegrationsTabInfo>
navLabel: 'Kobo',
titleLabel: 'Kobo Sync',
subtitle: 'Pair your Kobo device to sync your library.',
permission: Permission.KoboSync,
},
koreader: {
navLabel: 'KOReader',
titleLabel: 'KOReader Sync',
subtitle: 'Sync reading progress between KOReader devices and BookOrbit.',
permission: Permission.KoreaderSync,
},
hardcover: {
navLabel: 'Hardcover',
titleLabel: 'Hardcover',
subtitle: 'Sync your reading progress, status, and ratings to Hardcover.',
permission: Permission.HardcoverSync,
},
}
export function normalizeIntegrationsTab(value: unknown): IntegrationsTab {
if (typeof value === 'string' && INTEGRATIONS_TABS.includes(value as IntegrationsTab)) {
export function normalizeIntegrationsTab(value: unknown, allowedTabs: readonly IntegrationsTab[] = INTEGRATIONS_TABS): IntegrationsTab {
const fallback = allowedTabs[0] ?? INTEGRATIONS_TABS[0]
if (typeof value === 'string' && allowedTabs.includes(value as IntegrationsTab)) {
return value as IntegrationsTab
}
return 'kobo'
return fallback
}
+2
View File
@@ -9,6 +9,7 @@ export enum Permission {
// Devices & Access
KoboSync = "kobo_sync",
KoreaderSync = "koreader_sync",
HardcoverSync = "hardcover_sync",
OpdsAccess = "opds_access",
BookDockAccess = "book_dock_access",
@@ -35,6 +36,7 @@ export const PERMISSION_LABELS: Record<Permission, string> = {
[Permission.DemoRestricted]: "Demo restricted",
[Permission.KoboSync]: "Kobo sync",
[Permission.KoreaderSync]: "KOReader sync",
[Permission.HardcoverSync]: "Hardcover sync",
[Permission.OpdsAccess]: "OPDS access",
[Permission.BookDockAccess]: "Book Dock",
[Permission.EmailSend]: "Send by email",
@@ -1,13 +1,16 @@
import { Permission } from '@bookorbit/types';
import { Body, Controller, Delete, Get, MessageEvent, Patch, Post, Sse } from '@nestjs/common';
import { map, Observable } from 'rxjs';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { RequirePermission } from '../../common/decorators/require-permission.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UpsertHardcoverSettingsDto, ValidateHardcoverTokenDto } from './dto';
import { HardcoverSettingsService } from './hardcover-settings.service';
import { HardcoverSyncService } from './hardcover-sync.service';
@Controller('hardcover')
@RequirePermission(Permission.HardcoverSync)
export class HardcoverController {
constructor(
private readonly settingsService: HardcoverSettingsService,
@@ -421,6 +421,11 @@ describe('Authorization matrix (e2e)', () => {
path: '/koreader/credentials',
token: 'allPerms',
},
[Permission.HardcoverSync]: {
method: 'GET',
path: '/hardcover/settings',
token: 'allPerms',
},
[Permission.DemoRestricted]: {
method: 'GET',
path: '/notifications',