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