chore(settings): unify integration views into a single settings page

Consolidate Hardcover, Readwise, and StoryGraph settings into a unified Integrations tab under /settings/integrations. Add sub-tab navigation to switch between the connected services, configure redirection of legacy settings routes, and secure access based on user permissions.
This commit is contained in:
neon
2026-07-10 00:14:22 +00:00
parent 957d415435
commit 513b3c505c
8 changed files with 283 additions and 61 deletions
@@ -0,0 +1,83 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import SettingsPageHeader from './SettingsPageHeader.vue'
import HardcoverSettings from '@/features/hardcover/components/HardcoverSettings.vue'
import ReadwiseSettings from '@/features/readwise/components/ReadwiseSettings.vue'
import StorygraphSettings from '@/features/storygraph/components/StorygraphSettings.vue'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { INTEGRATION_TAB_INFO, INTEGRATION_TABS, normalizeIntegrationTab, type IntegrationTab as Tab } from './lib/integration-tabs'
const route = useRoute()
const router = useRouter()
const { isSuperuser, userPermissions } = usePermissions()
const availableTabs = computed(() =>
INTEGRATION_TABS.filter((id) => isSuperuser.value || userPermissions.value.includes(INTEGRATION_TAB_INFO[id].permission)).map((id) => ({
id,
label: INTEGRATION_TAB_INFO[id].navLabel,
})),
)
function resolveTab(raw: unknown): Tab {
const normalized = normalizeIntegrationTab(raw)
if (availableTabs.value.some((tab) => tab.id === normalized)) return normalized
return availableTabs.value[0]?.id ?? 'hardcover'
}
const activeTab = ref<Tab>(resolveTab(route.query.tab))
if (!route.query.tab && availableTabs.value.length > 0) {
router.replace({ name: 'settings-integrations', query: { ...route.query, tab: activeTab.value } })
}
watch(
() => route.query.tab,
(value) => {
activeTab.value = resolveTab(value)
},
)
watch(availableTabs, (tabs) => {
if (!tabs.some((tab) => tab.id === activeTab.value)) {
const fallback = tabs[0]?.id ?? 'hardcover'
activeTab.value = fallback
if (tabs.length > 0) {
router.replace({ name: 'settings-integrations', query: { ...route.query, tab: fallback } })
}
}
})
function selectTab(tab: Tab) {
activeTab.value = tab
router.replace({ name: 'settings-integrations', query: { ...route.query, tab } })
}
</script>
<template>
<SettingsPageHeader title="Integrations" subtitle="Connect external services to sync your reading data and highlights." />
<div
v-if="availableTabs.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
v-for="tab in availableTabs"
:key="tab.id"
class="px-3 py-3 md:py-2 text-sm font-medium shrink-0 border-b-2 -mb-px transition-colors snap-start"
:class="
activeTab === tab.id ? 'border-primary text-foreground' : 'border-transparent text-muted-foreground hover:text-foreground hover:border-border'
"
@click="selectTab(tab.id)"
>
{{ tab.label }}
</button>
</div>
<HardcoverSettings v-if="activeTab === 'hardcover' && availableTabs.length > 0" embedded />
<ReadwiseSettings v-else-if="activeTab === 'readwise' && availableTabs.length > 0" embedded />
<StorygraphSettings v-else-if="activeTab === 'storygraph' && availableTabs.length > 0" embedded />
<div v-else class="rounded-lg border border-border bg-card px-4 py-5 text-sm text-muted-foreground">
You do not have permission to use any integrations.
</div>
</template>
@@ -50,16 +50,8 @@ const sections = computed<Section[]>(() => {
result.push({ label: 'KOReader', routeName: 'settings-koreader' })
}
if (su || perms.includes(Permission.HardcoverSync)) {
result.push({ label: 'Hardcover', routeName: 'settings-hardcover' })
}
if (su || perms.includes(Permission.ReadwiseSync)) {
result.push({ label: 'Readwise', routeName: 'settings-readwise' })
}
if (su || perms.includes(Permission.StorygraphSync)) {
result.push({ label: 'StoryGraph', routeName: 'settings-storygraph' })
if (su || perms.includes(Permission.HardcoverSync) || perms.includes(Permission.ReadwiseSync) || perms.includes(Permission.StorygraphSync)) {
result.push({ label: 'Integrations', routeName: 'settings-integrations' })
}
if (su || perms.includes('manage_users') || perms.includes('manage_app_settings')) {
@@ -0,0 +1,94 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { computed } from 'vue'
import { mount } from '@vue/test-utils'
import IntegrationAllSettings from '../IntegrationAllSettings.vue'
const permissionState = {
isSuperuser: false,
permissions: [] as string[],
}
const routerState = {
currentQuery: {} as Record<string, string>,
replacedQuery: null as Record<string, string> | null,
}
vi.mock('vue-router', () => ({
useRoute: () => ({ query: routerState.currentQuery }),
useRouter: () => ({
replace: vi.fn<(to: { name: string; query: Record<string, string> }) => void>((to) => {
routerState.replacedQuery = to.query
}),
}),
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
isSuperuser: computed(() => permissionState.isSuperuser),
userPermissions: computed(() => permissionState.permissions),
}),
}))
vi.mock('../SettingsPageHeader.vue', () => ({ default: { template: '<div data-testid="settings-page-header" />' } }))
vi.mock('@/features/hardcover/components/HardcoverSettings.vue', () => ({ default: { template: '<div data-testid="hardcover-settings" />' } }))
vi.mock('@/features/readwise/components/ReadwiseSettings.vue', () => ({ default: { template: '<div data-testid="readwise-settings" />' } }))
vi.mock('@/features/storygraph/components/StorygraphSettings.vue', () => ({ default: { template: '<div data-testid="storygraph-settings" />' } }))
function mountComponent(tab?: string, permissions: string[] = [], isSuperuser = false) {
permissionState.permissions = permissions
permissionState.isSuperuser = isSuperuser
routerState.currentQuery = tab ? { tab } : {}
routerState.replacedQuery = null
return mount(IntegrationAllSettings)
}
describe('IntegrationAllSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('shows only the integrations the user can access', () => {
const wrapper = mountComponent(undefined, ['readwise_sync'])
expect(wrapper.findAll('button').map((button) => button.text())).toEqual(['Readwise'])
expect(wrapper.find('[data-testid="readwise-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(false)
})
it('shows every integration to a superuser', () => {
const wrapper = mountComponent(undefined, [], true)
expect(wrapper.findAll('button').map((button) => button.text())).toEqual(['Hardcover', 'Readwise', 'StoryGraph'])
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(true)
})
it('shows the requested accessible sub-tab', () => {
const wrapper = mountComponent('storygraph', ['storygraph_sync'])
expect(wrapper.find('[data-testid="storygraph-settings"]').exists()).toBe(true)
expect(wrapper.find('[data-testid="hardcover-settings"]').exists()).toBe(false)
})
it('falls back to the first accessible sub-tab', () => {
const wrapper = mountComponent('hardcover', ['readwise_sync'])
expect(wrapper.find('[data-testid="readwise-settings"]').exists()).toBe(true)
})
it('uses a query parameter when selecting a sub-tab', async () => {
const wrapper = mountComponent('hardcover', ['hardcover_sync', 'readwise_sync'])
const readwiseButton = wrapper.findAll('button').find((button) => button.text() === 'Readwise')
await readwiseButton!.trigger('click')
expect(routerState.replacedQuery).toEqual({ tab: 'readwise' })
expect(wrapper.find('[data-testid="readwise-settings"]').exists()).toBe(true)
})
it('shows a permission message when no integration is available', () => {
const wrapper = mountComponent()
expect(wrapper.findAll('button')).toHaveLength(0)
expect(wrapper.text()).toContain('You do not have permission to use any integrations.')
})
})
@@ -77,47 +77,41 @@ describe('SettingsHeader', () => {
expect(labels).not.toContain('Integrations')
})
it('shows Hardcover tab for users with hardcover_sync', () => {
it('shows Integrations tab for users with hardcover_sync', () => {
const labels = getTabLabels(mountHeader({ perms: ['hardcover_sync'] }))
expect(labels).toContain('Hardcover')
expect(labels).not.toContain('Integrations')
expect(labels).toContain('Integrations')
expect(labels).not.toContain('Hardcover')
})
it('shows Readwise tab for users with readwise_sync', () => {
it('shows Integrations tab for users with readwise_sync', () => {
const labels = getTabLabels(mountHeader({ perms: ['readwise_sync'] }))
expect(labels).toContain('Readwise')
expect(labels).not.toContain('Integrations')
})
it('hides Readwise tab when user lacks readwise_sync', () => {
const labels = getTabLabels(mountHeader())
expect(labels).toContain('Integrations')
expect(labels).not.toContain('Readwise')
})
it('shows integration tabs for superusers', () => {
it('hides Integrations tab when user lacks integration permissions', () => {
const labels = getTabLabels(mountHeader())
expect(labels).not.toContain('Integrations')
})
it('shows Integrations tab for superusers', () => {
const labels = getTabLabels(mountHeader({ su: true }))
expect(labels).toContain('Kobo')
expect(labels).toContain('KOReader')
expect(labels).toContain('Hardcover')
expect(labels).toContain('Readwise')
expect(labels).not.toContain('Integrations')
expect(labels).toContain('Integrations')
expect(labels).not.toContain('Hardcover')
expect(labels).not.toContain('Readwise')
expect(labels).not.toContain('StoryGraph')
})
it('places integration tabs after OPDS', () => {
const labels = getTabLabels(mountHeader({ su: true }))
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual([
'OPDS',
'Kobo',
'KOReader',
'Hardcover',
'Readwise',
'StoryGraph',
])
expect(labels.slice(labels.indexOf('OPDS'), labels.indexOf('Admin'))).toEqual(['OPDS', 'Kobo', 'KOReader', 'Integrations'])
})
it('places Readwise tab after Hardcover', () => {
it('places Integrations after KOReader', () => {
const labels = getTabLabels(mountHeader({ su: true }))
expect(labels.indexOf('Readwise')).toBe(labels.indexOf('Hardcover') + 1)
expect(labels.indexOf('Integrations')).toBe(labels.indexOf('KOReader') + 1)
})
})
@@ -264,15 +258,9 @@ describe('SettingsHeader', () => {
expect(btn?.classes()).toContain('border-primary')
})
it('Hardcover tab is active when route is settings-hardcover', () => {
const wrapper = mountHeader({ routeName: 'settings-hardcover', perms: ['hardcover_sync'] })
const btn = wrapper.findAll('button').find((b) => b.text() === 'Hardcover')
expect(btn?.classes()).toContain('border-primary')
})
it('StoryGraph tab is active when route is settings-storygraph', () => {
const wrapper = mountHeader({ routeName: 'settings-storygraph', perms: ['storygraph_sync'] })
const btn = wrapper.findAll('button').find((b) => b.text() === 'StoryGraph')
it('Integrations tab is active when route is settings-integrations', () => {
const wrapper = mountHeader({ routeName: 'settings-integrations', perms: ['storygraph_sync'] })
const btn = wrapper.findAll('button').find((b) => b.text() === 'Integrations')
expect(btn?.classes()).toContain('border-primary')
})
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { Permission } from '@bookorbit/types'
import { INTEGRATION_TAB_INFO, INTEGRATION_TABS, normalizeIntegrationTab } from '../integration-tabs'
describe('integration-tabs', () => {
it('contains Hardcover, Readwise, and StoryGraph', () => {
expect(INTEGRATION_TABS).toEqual(['hardcover', 'readwise', 'storygraph'])
})
it('associates each tab with its sync permission', () => {
expect(INTEGRATION_TAB_INFO.hardcover.permission).toBe(Permission.HardcoverSync)
expect(INTEGRATION_TAB_INFO.readwise.permission).toBe(Permission.ReadwiseSync)
expect(INTEGRATION_TAB_INFO.storygraph.permission).toBe(Permission.StorygraphSync)
})
it('defaults to Hardcover for an invalid tab', () => {
expect(normalizeIntegrationTab(undefined)).toBe('hardcover')
expect(normalizeIntegrationTab('unknown')).toBe('hardcover')
})
it('accepts each integration tab', () => {
expect(normalizeIntegrationTab('hardcover')).toBe('hardcover')
expect(normalizeIntegrationTab('readwise')).toBe('readwise')
expect(normalizeIntegrationTab('storygraph')).toBe('storygraph')
})
})
@@ -0,0 +1,36 @@
import { Permission } from '@bookorbit/types'
export const INTEGRATION_TABS = ['hardcover', 'readwise', 'storygraph'] as const
export type IntegrationTab = (typeof INTEGRATION_TABS)[number]
type IntegrationTabInfo = {
navLabel: string
titleLabel: string
permission: Permission
}
export const INTEGRATION_TAB_INFO: Record<IntegrationTab, IntegrationTabInfo> = {
hardcover: {
navLabel: 'Hardcover',
titleLabel: 'Hardcover',
permission: Permission.HardcoverSync,
},
readwise: {
navLabel: 'Readwise',
titleLabel: 'Readwise',
permission: Permission.ReadwiseSync,
},
storygraph: {
navLabel: 'StoryGraph',
titleLabel: 'StoryGraph',
permission: Permission.StorygraphSync,
},
}
export function normalizeIntegrationTab(value: unknown): IntegrationTab {
if (typeof value === 'string' && INTEGRATION_TABS.includes(value as IntegrationTab)) {
return value as IntegrationTab
}
return 'hardcover'
}
@@ -45,11 +45,11 @@ describe('router title metadata', () => {
})
describe('router redirects', () => {
it('redirects legacy integrations readwise tab to the Readwise settings route', () => {
const route = findRoute(routes, 'settings-integrations')
it('redirects the legacy Readwise route to the Integrations sub-tab', () => {
const route = findRoute(routes, 'settings-readwise')
expect(route?.redirect).toBeTypeOf('function')
const redirect = route!.redirect as (to: { query: Record<string, unknown> }) => unknown
expect(redirect({ query: { tab: 'readwise' } })).toEqual({ name: 'settings-readwise' })
expect(redirect({ query: {} })).toEqual({ name: 'settings-integrations', query: { tab: 'readwise' } })
})
})
+18 -15
View File
@@ -6,6 +6,7 @@ import { ADMIN_TAB_INFO, normalizeAdminTab } from '@/features/settings/lib/admin
import { SYSTEM_TAB_INFO, normalizeSystemTab } from '@/features/settings/lib/system-tabs'
import { ACCOUNT_TAB_INFO, normalizeAccountTab } from '@/features/settings/lib/account-tabs'
import { APPEARANCE_TAB_TITLE_LABELS, normalizeAppearanceTab } from '@/features/settings/lib/appearance-tabs'
import { INTEGRATION_TAB_INFO, normalizeIntegrationTab } from '@/features/settings/lib/integration-tabs'
import { registerAuthGuard } from './guards/auth.guard'
import { registerRouteTitleHook } from './title-resolver'
@@ -62,23 +63,23 @@ function resolveAccountTitle(to: RouteLocationNormalizedLoaded): string {
return ACCOUNT_TAB_INFO[tab].titleLabel
}
function resolveIntegrationTitle(to: RouteLocationNormalizedLoaded): string {
const tab = normalizeIntegrationTab(to.query.tab)
return INTEGRATION_TAB_INFO[tab].titleLabel
}
function resolveStatisticsTitle(): string {
return 'Statistics'
}
function resolveLegacyIntegrationsRoute(tab: unknown): string {
function resolveLegacyIntegrationRoute(tab: unknown): string | null {
switch (firstText(tab)) {
case 'koreader':
return 'settings-koreader'
case 'hardcover':
return 'settings-hardcover'
case 'readwise':
return 'settings-readwise'
case 'storygraph':
return 'settings-storygraph'
case 'kobo':
default:
return 'settings-kobo'
default:
return null
}
}
@@ -130,7 +131,12 @@ export const routes: RouteRecordRaw[] = [
{
path: 'integrations',
name: 'settings-integrations',
redirect: (to) => ({ name: resolveLegacyIntegrationsRoute(to.query.tab) }),
component: () => import('@/features/settings/IntegrationAllSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: resolveIntegrationTitle },
beforeEnter: (to) => {
const legacyRoute = resolveLegacyIntegrationRoute(to.query.tab)
return legacyRoute ? { name: legacyRoute } : undefined
},
},
{
path: 'kobo',
@@ -147,20 +153,17 @@ export const routes: RouteRecordRaw[] = [
{
path: 'hardcover',
name: 'settings-hardcover',
component: () => import('@/features/hardcover/components/HardcoverSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'Hardcover' },
redirect: (to) => ({ name: 'settings-integrations', query: { ...to.query, tab: 'hardcover' } }),
},
{
path: 'readwise',
name: 'settings-readwise',
component: () => import('@/features/readwise/components/ReadwiseSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'Readwise' },
redirect: (to) => ({ name: 'settings-integrations', query: { ...to.query, tab: 'readwise' } }),
},
{
path: 'storygraph',
name: 'settings-storygraph',
component: () => import('@/features/storygraph/components/StorygraphSettings.vue'),
meta: { maxWidth: 'max-w-3xl', title: 'StoryGraph' },
redirect: (to) => ({ name: 'settings-integrations', query: { ...to.query, tab: 'storygraph' } }),
},
{
path: 'email',