mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user