mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
security(settings): gate Book Dock settings behind manage_book_dock
Require manage_book_dock for the settings UI and the scoped API so regular Book Dock users cannot view or modify administrative configuration. Reject every Book Dock key on the generic PATCH /app-settings/:key route as well, including book_dock_paused. That route accepted the same keys with any string value, so an operator with manage_app_settings could store an out-of-range threshold or an unknown metadata mode and bypass the validation the scoped endpoints apply. Closes #1003
This commit is contained in:
@@ -3,12 +3,11 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { Loader2 } from '@lucide/vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { BookDockAutoFinalizeMetadataMode } from '@bookorbit/types'
|
||||
import type { BookDockAutoFinalizeMetadataMode, BookDockSettings, UpdateBookDockSettingsRequest } from '@bookorbit/types'
|
||||
import ToggleSwitch from '@/components/ui/ToggleSwitch.vue'
|
||||
import SettingsPageHeader from './SettingsPageHeader.vue'
|
||||
import { api } from '@/lib/api'
|
||||
import { useLibraries } from '@/features/library/composables/useLibraries'
|
||||
import { useAppInfo } from './composables/useAppInfo'
|
||||
|
||||
const { t } = useI18n()
|
||||
const props = withDefaults(defineProps<{ embedded?: boolean }>(), {
|
||||
@@ -20,11 +19,11 @@ const autoFinalizeThreshold = ref(85)
|
||||
const autoFinalizeLibraryId = ref<number | null>(null)
|
||||
const autoFinalizeFolderId = ref<number | null>(null)
|
||||
const autoFinalizeMetadataMode = ref<BookDockAutoFinalizeMetadataMode>('safe_merge')
|
||||
const bookDockPath = ref('')
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const { libraries, fetchLibraries } = useLibraries()
|
||||
const { bookDockPath, loadAppInfo } = useAppInfo()
|
||||
|
||||
const autoFinalizeLibrary = computed(() => libraries.value.find((l) => l.id === autoFinalizeLibraryId.value))
|
||||
const autoFinalizeFolders = computed(() => autoFinalizeLibrary.value?.folders ?? [])
|
||||
@@ -32,107 +31,97 @@ const isThresholdApplicable = computed(() => autoFinalizeMetadataMode.value !==
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [res] = await Promise.all([api('/api/v1/app-settings'), fetchLibraries(), loadAppInfo()])
|
||||
const [res] = await Promise.all([api('/api/v1/book-dock/settings'), fetchLibraries()])
|
||||
if (res.ok) {
|
||||
const settings: { key: string; value: string }[] = await res.json()
|
||||
const get = (key: string) => settings.find((s) => s.key === key)?.value
|
||||
autoFetch.value = get('book_dock_auto_fetch_metadata') !== 'false'
|
||||
autoFinalizeEnabled.value = get('book_dock_auto_finalize_enabled') === 'true'
|
||||
autoFinalizeThreshold.value = parseInt(get('book_dock_auto_finalize_threshold') ?? '85', 10)
|
||||
const libId = parseInt(get('book_dock_auto_finalize_library_id') ?? '', 10)
|
||||
const folderId = parseInt(get('book_dock_auto_finalize_folder_id') ?? '', 10)
|
||||
const metadataMode = get('book_dock_auto_finalize_metadata_mode')
|
||||
autoFinalizeLibraryId.value = isNaN(libId) ? null : libId
|
||||
autoFinalizeFolderId.value = isNaN(folderId) ? null : folderId
|
||||
autoFinalizeMetadataMode.value = metadataMode === 'fetched_only' || metadataMode === 'embedded_only' ? metadataMode : 'safe_merge'
|
||||
applySettings(await res.json())
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveSetting(key: string, value: string) {
|
||||
const res = await api(`/api/v1/app-settings/${key}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value }),
|
||||
})
|
||||
if (!res.ok) {
|
||||
toast.error(t('settings.reader.bookDock.saveSettingFailed'))
|
||||
function applySettings(settings: BookDockSettings) {
|
||||
bookDockPath.value = settings.bookDockPath
|
||||
autoFetch.value = settings.autoFetchMetadata
|
||||
autoFinalizeEnabled.value = settings.autoFinalizeEnabled
|
||||
autoFinalizeThreshold.value = settings.autoFinalizeThreshold
|
||||
autoFinalizeLibraryId.value = settings.autoFinalizeLibraryId
|
||||
autoFinalizeFolderId.value = settings.autoFinalizeFolderId
|
||||
autoFinalizeMetadataMode.value = settings.autoFinalizeMetadataMode
|
||||
}
|
||||
|
||||
function settingsPayload(overrides: Partial<UpdateBookDockSettingsRequest> = {}): UpdateBookDockSettingsRequest {
|
||||
return {
|
||||
autoFetchMetadata: autoFetch.value,
|
||||
autoFinalizeEnabled: autoFinalizeEnabled.value,
|
||||
autoFinalizeThreshold: autoFinalizeThreshold.value,
|
||||
autoFinalizeLibraryId: autoFinalizeLibraryId.value,
|
||||
autoFinalizeFolderId: autoFinalizeFolderId.value,
|
||||
autoFinalizeMetadataMode: autoFinalizeMetadataMode.value,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
async function toggle() {
|
||||
if (saving.value) return
|
||||
const newVal = !autoFetch.value
|
||||
async function saveSettings(overrides: Partial<UpdateBookDockSettingsRequest>): Promise<boolean> {
|
||||
if (saving.value) return false
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/book_dock_auto_fetch_metadata', {
|
||||
method: 'PATCH',
|
||||
const res = await api('/api/v1/book-dock/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
body: JSON.stringify(settingsPayload(overrides)),
|
||||
})
|
||||
if (res.ok) {
|
||||
autoFetch.value = newVal
|
||||
toast.success(newVal ? t('settings.reader.bookDock.autoFetchEnabled') : t('settings.reader.bookDock.autoFetchDisabled'))
|
||||
} else {
|
||||
toast.error(t('settings.reader.bookDock.updateSettingFailed'))
|
||||
if (!res.ok) {
|
||||
toast.error(t('settings.reader.bookDock.saveSettingFailed'))
|
||||
return false
|
||||
}
|
||||
applySettings(await res.json())
|
||||
return true
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleAutoFinalize() {
|
||||
if (saving.value) return
|
||||
const newVal = !autoFinalizeEnabled.value
|
||||
saving.value = true
|
||||
try {
|
||||
const res = await api('/api/v1/app-settings/book_dock_auto_finalize_enabled', {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ value: String(newVal) }),
|
||||
})
|
||||
if (res.ok) {
|
||||
autoFinalizeEnabled.value = newVal
|
||||
toast.success(newVal ? t('settings.reader.bookDock.autoFinalizeEnabled') : t('settings.reader.bookDock.autoFinalizeDisabled'))
|
||||
} else {
|
||||
toast.error(t('settings.reader.bookDock.updateSettingFailed'))
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
async function handleAutoFetchChange(enabled: boolean) {
|
||||
if (await saveSettings({ autoFetchMetadata: enabled })) {
|
||||
toast.success(enabled ? t('settings.reader.bookDock.autoFetchEnabled') : t('settings.reader.bookDock.autoFetchDisabled'))
|
||||
}
|
||||
}
|
||||
|
||||
async function handleAutoFinalizeChange(enabled: boolean) {
|
||||
if (await saveSettings({ autoFinalizeEnabled: enabled })) {
|
||||
toast.success(enabled ? t('settings.reader.bookDock.autoFinalizeEnabled') : t('settings.reader.bookDock.autoFinalizeDisabled'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onLibraryChange(event: Event) {
|
||||
const id = Number((event.target as HTMLSelectElement).value)
|
||||
autoFinalizeLibraryId.value = id
|
||||
const lib = libraries.value.find((l) => l.id === id)
|
||||
autoFinalizeFolderId.value = lib?.folders?.[0]?.id ?? null
|
||||
await Promise.all([
|
||||
saveSetting('book_dock_auto_finalize_library_id', String(id)),
|
||||
saveSetting('book_dock_auto_finalize_folder_id', String(autoFinalizeFolderId.value ?? '')),
|
||||
])
|
||||
toast.success(t('settings.reader.bookDock.destinationLibraryUpdated'))
|
||||
const libraryId = Number((event.target as HTMLSelectElement).value)
|
||||
const library = libraries.value.find((candidate) => candidate.id === libraryId)
|
||||
const folderId = library?.folders?.[0]?.id ?? null
|
||||
if (await saveSettings({ autoFinalizeLibraryId: libraryId, autoFinalizeFolderId: folderId })) {
|
||||
toast.success(t('settings.reader.bookDock.destinationLibraryUpdated'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onFolderChange(event: Event) {
|
||||
autoFinalizeFolderId.value = Number((event.target as HTMLSelectElement).value)
|
||||
await saveSetting('book_dock_auto_finalize_folder_id', String(autoFinalizeFolderId.value))
|
||||
toast.success(t('settings.reader.bookDock.destinationFolderUpdated'))
|
||||
const folderId = Number((event.target as HTMLSelectElement).value)
|
||||
if (await saveSettings({ autoFinalizeFolderId: folderId })) {
|
||||
toast.success(t('settings.reader.bookDock.destinationFolderUpdated'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onThresholdChange() {
|
||||
if (!isThresholdApplicable.value) return
|
||||
await saveSetting('book_dock_auto_finalize_threshold', String(autoFinalizeThreshold.value))
|
||||
toast.success(t('settings.reader.bookDock.confidenceThresholdUpdated'))
|
||||
if (await saveSettings({ autoFinalizeThreshold: autoFinalizeThreshold.value })) {
|
||||
toast.success(t('settings.reader.bookDock.confidenceThresholdUpdated'))
|
||||
}
|
||||
}
|
||||
|
||||
async function onMetadataModeChange(event: Event) {
|
||||
const value = (event.target as HTMLSelectElement).value as BookDockAutoFinalizeMetadataMode
|
||||
autoFinalizeMetadataMode.value = value
|
||||
await saveSetting('book_dock_auto_finalize_metadata_mode', value)
|
||||
toast.success(t('settings.reader.bookDock.metadataModeUpdated'))
|
||||
const metadataMode = (event.target as HTMLSelectElement).value as BookDockAutoFinalizeMetadataMode
|
||||
if (await saveSettings({ autoFinalizeMetadataMode: metadataMode })) {
|
||||
toast.success(t('settings.reader.bookDock.metadataModeUpdated'))
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -202,7 +191,12 @@ async function onMetadataModeChange(event: Event) {
|
||||
{{ t('settings.reader.bookDock.autoFetchHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<ToggleSwitch :model-value="autoFetch" :disabled="saving" class="self-start md:self-auto md:ml-4" @update:model-value="() => toggle()" />
|
||||
<ToggleSwitch
|
||||
:model-value="autoFetch"
|
||||
:disabled="saving"
|
||||
class="self-start md:self-auto md:ml-4"
|
||||
@update:model-value="handleAutoFetchChange"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -224,7 +218,7 @@ async function onMetadataModeChange(event: Event) {
|
||||
:model-value="autoFinalizeEnabled"
|
||||
:disabled="saving"
|
||||
class="self-start md:self-auto md:ml-4"
|
||||
@update:model-value="() => toggleAutoFinalize()"
|
||||
@update:model-value="handleAutoFinalizeChange"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -85,7 +85,7 @@ const sections = computed<Section[]>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (su || perms.includes('manage_app_settings') || perms.includes('book_dock_access')) {
|
||||
if (su || perms.includes(Permission.ManageAppSettings) || perms.includes(Permission.ManageBookDock)) {
|
||||
result.push({
|
||||
id: 'settings-system',
|
||||
label: t('settings.common.nav.system'),
|
||||
|
||||
@@ -43,7 +43,7 @@ const tabWidths: Record<Tab, string> = {
|
||||
|
||||
<SettingsTabs :class="tabWidths[activeTab]" :tabs="availableTabs" :active-tab="activeTab" @select="selectTab" />
|
||||
|
||||
<div :class="tabWidths[activeTab]">
|
||||
<div v-if="availableTabIds.includes(activeTab)" :class="tabWidths[activeTab]">
|
||||
<FileNamingSettings v-if="activeTab === 'file-naming'" embedded />
|
||||
<BookDockSettings v-else-if="activeTab === 'book-dock'" embedded />
|
||||
<MaintenanceSettings v-else-if="activeTab === 'maintenance'" embedded />
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { BookDockSettings as BookDockSettingsResponse } from '@bookorbit/types'
|
||||
|
||||
const { apiMock, fetchLibrariesMock, toastErrorMock } = vi.hoisted(() => ({
|
||||
apiMock: vi.fn<(url: string, init?: RequestInit) => Promise<Response>>(),
|
||||
fetchLibrariesMock: vi.fn<() => Promise<void>>().mockResolvedValue(undefined),
|
||||
toastErrorMock: vi.fn<(message: string) => void>(),
|
||||
}))
|
||||
|
||||
vi.mock('@/lib/api', () => ({ api: apiMock }))
|
||||
vi.mock('@/features/library/composables/useLibraries', () => ({
|
||||
useLibraries: () => ({
|
||||
libraries: ref([
|
||||
{
|
||||
id: 4,
|
||||
name: 'Library',
|
||||
folders: [{ id: 8, path: '/books' }],
|
||||
},
|
||||
]),
|
||||
fetchLibraries: fetchLibrariesMock,
|
||||
}),
|
||||
}))
|
||||
vi.mock('vue-sonner', () => ({
|
||||
toast: {
|
||||
success: vi.fn<(message: string) => void>(),
|
||||
error: toastErrorMock,
|
||||
},
|
||||
}))
|
||||
|
||||
import BookDockSettings from '../BookDockSettings.vue'
|
||||
|
||||
const settings: BookDockSettingsResponse = {
|
||||
bookDockPath: '/data/private-book-dock',
|
||||
autoFetchMetadata: true,
|
||||
autoFinalizeEnabled: true,
|
||||
autoFinalizeThreshold: 85,
|
||||
autoFinalizeLibraryId: 4,
|
||||
autoFinalizeFolderId: 8,
|
||||
autoFinalizeMetadataMode: 'safe_merge',
|
||||
}
|
||||
|
||||
function response(body: unknown, ok = true): Response {
|
||||
return {
|
||||
ok,
|
||||
status: ok ? 200 : 403,
|
||||
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
|
||||
} as unknown as Response
|
||||
}
|
||||
|
||||
async function mountSettings() {
|
||||
const wrapper = mount(BookDockSettings, { props: { embedded: true } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('BookDockSettings', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
fetchLibrariesMock.mockResolvedValue(undefined)
|
||||
apiMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/v1/book-dock/settings' && init?.method === 'PUT') {
|
||||
return response({ ...settings, ...JSON.parse(String(init.body)) })
|
||||
}
|
||||
return response(settings)
|
||||
})
|
||||
})
|
||||
|
||||
it('loads the protected settings contract and renders its scoped path', async () => {
|
||||
const wrapper = await mountSettings()
|
||||
|
||||
expect(apiMock).toHaveBeenCalledWith('/api/v1/book-dock/settings')
|
||||
expect(fetchLibrariesMock).toHaveBeenCalledOnce()
|
||||
expect(wrapper.get('[data-testid="book-dock-path"]').text()).toBe('/data/private-book-dock')
|
||||
expect(apiMock.mock.calls.some(([url]) => url === '/api/v1/app-info' || url === '/api/v1/app-settings')).toBe(false)
|
||||
})
|
||||
|
||||
it('updates the complete typed settings document through the protected endpoint', async () => {
|
||||
const wrapper = await mountSettings()
|
||||
|
||||
await wrapper.findAll('[role="switch"]')[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(apiMock).toHaveBeenLastCalledWith('/api/v1/book-dock/settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
autoFetchMetadata: false,
|
||||
autoFinalizeEnabled: true,
|
||||
autoFinalizeThreshold: 85,
|
||||
autoFinalizeLibraryId: 4,
|
||||
autoFinalizeFolderId: 8,
|
||||
autoFinalizeMetadataMode: 'safe_merge',
|
||||
}),
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a failed update without applying the optimistic value', async () => {
|
||||
apiMock.mockImplementation(async (url: string, init?: RequestInit) => {
|
||||
if (url === '/api/v1/book-dock/settings' && init?.method === 'PUT') return response({}, false)
|
||||
return response(settings)
|
||||
})
|
||||
const wrapper = await mountSettings()
|
||||
|
||||
await wrapper.findAll('[role="switch"]')[0]!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(toastErrorMock).toHaveBeenCalledOnce()
|
||||
expect(wrapper.findAll('[role="switch"]')[0]!.attributes('aria-checked')).toBe('true')
|
||||
})
|
||||
})
|
||||
@@ -190,12 +190,17 @@ describe('SettingsHeader', () => {
|
||||
expect(labels).toContain('System')
|
||||
})
|
||||
|
||||
it('shows System tab for user with book_dock_access', () => {
|
||||
it('hides System tab for user with book_dock_access only', () => {
|
||||
const labels = getTabLabels(mountHeader({ perms: ['book_dock_access'] }))
|
||||
expect(labels).not.toContain('System')
|
||||
})
|
||||
|
||||
it('shows System tab for user with manage_book_dock', () => {
|
||||
const labels = getTabLabels(mountHeader({ perms: ['manage_book_dock'] }))
|
||||
expect(labels).toContain('System')
|
||||
})
|
||||
|
||||
it('hides System tab when user lacks manage_app_settings and book_dock_access', () => {
|
||||
it('hides System tab when user lacks settings management permissions', () => {
|
||||
const labels = getTabLabels(mountHeader({ perms: ['kobo_sync'] }))
|
||||
expect(labels).not.toContain('System')
|
||||
})
|
||||
|
||||
@@ -94,17 +94,30 @@ describe('SystemAllSettings', () => {
|
||||
})
|
||||
|
||||
describe('user with book_dock_access only', () => {
|
||||
it('sees book-dock tab only', () => {
|
||||
it('cannot see the Book Dock settings tab', () => {
|
||||
const wrapper = mountComponent(undefined, { perms: ['book_dock_access'] })
|
||||
const labels = wrapper.findAll('button').map((b) => b.text())
|
||||
expect(labels).toContain('Book Dock')
|
||||
expect(labels).not.toContain('Book Dock')
|
||||
expect(labels).not.toContain('File Naming')
|
||||
expect(labels).not.toContain('Maintenance')
|
||||
expect(labels).not.toContain('Audit Log')
|
||||
})
|
||||
|
||||
it('defaults to book-dock', () => {
|
||||
const wrapper = mountComponent(undefined, { perms: ['book_dock_access'] })
|
||||
it('does not render protected settings when the Book Dock tab is requested directly', () => {
|
||||
const wrapper = mountComponent('book-dock', { perms: ['book_dock_access'] })
|
||||
expect(wrapper.find('[data-testid="book-dock"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="file-naming"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-testid="maintenance"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('user with manage_book_dock', () => {
|
||||
it('sees and defaults to Book Dock settings only', () => {
|
||||
const wrapper = mountComponent(undefined, { perms: ['manage_book_dock'] })
|
||||
const labels = wrapper.findAll('button').map((button) => button.text())
|
||||
expect(labels).toContain('Book Dock')
|
||||
expect(labels).not.toContain('File Naming')
|
||||
expect(labels).not.toContain('Maintenance')
|
||||
expect(wrapper.find('[data-testid="book-dock"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Permission } from '@bookorbit/types'
|
||||
import { SYSTEM_TABS, SYSTEM_TAB_INFO, normalizeSystemTab } from '../lib/system-tabs'
|
||||
|
||||
describe('system-tabs', () => {
|
||||
@@ -27,15 +28,15 @@ describe('system-tabs', () => {
|
||||
})
|
||||
|
||||
it('file-naming has manage_app_settings permission', () => {
|
||||
expect(SYSTEM_TAB_INFO['file-naming'].permission).toBe('manage_app_settings')
|
||||
expect(SYSTEM_TAB_INFO['file-naming'].permission).toBe(Permission.ManageAppSettings)
|
||||
})
|
||||
|
||||
it('book-dock has book_dock_access permission', () => {
|
||||
expect(SYSTEM_TAB_INFO['book-dock'].permission).toBe('book_dock_access')
|
||||
it('book-dock requires management permission', () => {
|
||||
expect(SYSTEM_TAB_INFO['book-dock'].permission).toBe(Permission.ManageBookDock)
|
||||
})
|
||||
|
||||
it('maintenance has manage_app_settings permission', () => {
|
||||
expect(SYSTEM_TAB_INFO.maintenance.permission).toBe('manage_app_settings')
|
||||
expect(SYSTEM_TAB_INFO.maintenance.permission).toBe(Permission.ManageAppSettings)
|
||||
})
|
||||
|
||||
it('audit-log has null permission (superuser only)', () => {
|
||||
|
||||
@@ -25,21 +25,19 @@ function makeErrorResponse(status: number): Response {
|
||||
describe('useAppInfo', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
|
||||
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
|
||||
version.value = ''
|
||||
updateAvailable.value = null
|
||||
latestVersion.value = null
|
||||
bookDockPath.value = ''
|
||||
isLoading.value = false
|
||||
error.value = null
|
||||
})
|
||||
|
||||
it('starts in idle state before loadAppInfo is called', () => {
|
||||
const { version, updateAvailable, latestVersion, bookDockPath, isLoading, error } = useAppInfo()
|
||||
const { version, updateAvailable, latestVersion, isLoading, error } = useAppInfo()
|
||||
expect(version.value).toBe('')
|
||||
expect(updateAvailable.value).toBeNull()
|
||||
expect(latestVersion.value).toBeNull()
|
||||
expect(bookDockPath.value).toBe('')
|
||||
expect(isLoading.value).toBe(false)
|
||||
expect(error.value).toBeNull()
|
||||
})
|
||||
@@ -149,45 +147,4 @@ describe('useAppInfo', () => {
|
||||
|
||||
expect(mockApi).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('populates bookDockPath from API response', async () => {
|
||||
mockApi.mockResolvedValue(
|
||||
makeOkResponse({
|
||||
version: 'v1.2.3',
|
||||
updateAvailable: false,
|
||||
latestVersion: 'v1.2.3',
|
||||
bookDockPath: '/data/book-dock',
|
||||
}),
|
||||
)
|
||||
|
||||
const { bookDockPath, loadAppInfo } = useAppInfo()
|
||||
await loadAppInfo()
|
||||
|
||||
expect(bookDockPath.value).toBe('/data/book-dock')
|
||||
})
|
||||
|
||||
it('defaults bookDockPath to empty string when field is absent from response', async () => {
|
||||
mockApi.mockResolvedValue(
|
||||
makeOkResponse({
|
||||
version: 'v1.2.3',
|
||||
updateAvailable: false,
|
||||
latestVersion: 'v1.2.3',
|
||||
}),
|
||||
)
|
||||
|
||||
const { bookDockPath, loadAppInfo } = useAppInfo()
|
||||
await loadAppInfo()
|
||||
|
||||
expect(bookDockPath.value).toBe('')
|
||||
})
|
||||
|
||||
it('does not change bookDockPath on API error', async () => {
|
||||
mockApi.mockRejectedValue(new Error('fail'))
|
||||
|
||||
const { bookDockPath, loadAppInfo } = useAppInfo()
|
||||
bookDockPath.value = '/data/book-dock'
|
||||
await loadAppInfo()
|
||||
|
||||
expect(bookDockPath.value).toBe('/data/book-dock')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,6 @@ import { api } from '@/lib/api'
|
||||
const version = ref('')
|
||||
const updateAvailable = ref<boolean | null>(null)
|
||||
const latestVersion = ref<string | null>(null)
|
||||
const bookDockPath = ref('')
|
||||
const maxUploadSizeMb = ref(500)
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -24,7 +23,6 @@ export function useAppInfo() {
|
||||
version.value = data.version
|
||||
updateAvailable.value = data.updateAvailable
|
||||
latestVersion.value = data.latestVersion
|
||||
bookDockPath.value = data.bookDockPath ?? ''
|
||||
maxUploadSizeMb.value = data.maxUploadSizeMb ?? 500
|
||||
} catch (e) {
|
||||
error.value = e instanceof Error ? e.message : 'Failed to load app info'
|
||||
@@ -33,5 +31,5 @@ export function useAppInfo() {
|
||||
}
|
||||
}
|
||||
|
||||
return { version, updateAvailable, latestVersion, bookDockPath, maxUploadSizeMb, isLoading, error, loadAppInfo }
|
||||
return { version, updateAvailable, latestVersion, maxUploadSizeMb, isLoading, error, loadAppInfo }
|
||||
}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
import { Permission } from '@bookorbit/types'
|
||||
|
||||
export const SYSTEM_TABS = ['file-naming', 'book-dock', 'maintenance', 'audit-log'] as const
|
||||
|
||||
export type SystemTab = (typeof SYSTEM_TABS)[number]
|
||||
|
||||
type SystemTabInfo = {
|
||||
permission: string | null
|
||||
permission: Permission | null
|
||||
}
|
||||
|
||||
export const SYSTEM_TAB_INFO: Record<SystemTab, SystemTabInfo> = {
|
||||
'file-naming': {
|
||||
permission: 'manage_app_settings',
|
||||
permission: Permission.ManageAppSettings,
|
||||
},
|
||||
'book-dock': {
|
||||
permission: 'book_dock_access',
|
||||
permission: Permission.ManageBookDock,
|
||||
},
|
||||
maintenance: {
|
||||
permission: 'manage_app_settings',
|
||||
permission: Permission.ManageAppSettings,
|
||||
},
|
||||
'audit-log': {
|
||||
permission: null,
|
||||
|
||||
Reference in New Issue
Block a user