diff --git a/client/src/components/AppSidebar.vue b/client/src/components/AppSidebar.vue index aebf51767..0be5f8376 100644 --- a/client/src/components/AppSidebar.vue +++ b/client/src/components/AppSidebar.vue @@ -19,6 +19,7 @@ import { useLibraries } from '@/features/library/composables/useLibraries' import { useLibraryScanRefresh } from '@/features/library/composables/useLibraryScanRefresh' import { useSmartScopes } from '@/features/smart-scope/composables/useSmartScopes' import { useCollections } from '@/features/collection/composables/useCollections' +import { ownedCollectionOrder } from '@/features/collection/lib/collection-access' import { usePermissions } from '@/features/auth/composables/usePermissions' import { useScanProgress, getSocket } from '@/features/scanner/composables/useScanProgress' import { useLibraryUploadEvents } from '@/features/library/composables/useLibraryUploadEvents' @@ -59,6 +60,12 @@ async function persistSmartScopeOrder(order: { id: number; displayOrder: number await reorderSmartScopes(ownedOrder) } +async function persistCollectionOrder(order: { id: number; displayOrder: number }[]) { + const ownedOrder = ownedCollectionOrder(collections.value, order) + if (ownedOrder.length === 0) return + await reorderCollections(ownedOrder) +} + const createSmartScopeOpen = ref(false) const createCollectionOpen = ref(false) const createLibraryOpen = ref(false) @@ -360,7 +367,7 @@ onUnmounted(() => stopLibraryUploadListener()) can-add :add-label="t('components.sidebar.newCollection')" can-reorder - :persist-order="reorderCollections" + :persist-order="persistCollectionOrder" @add="openCreateCollection" @navigate="handleNavigate" /> diff --git a/client/src/components/ui/ToggleSwitch.spec.ts b/client/src/components/ui/ToggleSwitch.spec.ts new file mode 100644 index 000000000..f60718708 --- /dev/null +++ b/client/src/components/ui/ToggleSwitch.spec.ts @@ -0,0 +1,29 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import ToggleSwitch from './ToggleSwitch.vue' + +describe('ToggleSwitch', () => { + it('keeps the thumb contained at logical track positions', async () => { + const wrapper = mount(ToggleSwitch, { props: { modelValue: false } }) + const track = wrapper.get('[role="switch"]') + const thumb = track.get('span') + + expect(track.classes()).toContain('overflow-hidden') + expect(thumb.classes()).toEqual(expect.arrayContaining(['absolute', 'start-0'])) + expect(track.attributes('aria-checked')).toBe('false') + + await wrapper.setProps({ modelValue: true }) + + expect(thumb.classes()).toContain('start-4') + expect(thumb.classes()).not.toContain('start-0') + expect(track.attributes('aria-checked')).toBe('true') + }) + + it('emits the next checked state when clicked', async () => { + const wrapper = mount(ToggleSwitch, { props: { modelValue: false } }) + + await wrapper.get('[role="switch"]').trigger('click') + + expect(wrapper.emitted('update:modelValue')).toEqual([[true]]) + }) +}) diff --git a/client/src/components/ui/ToggleSwitch.vue b/client/src/components/ui/ToggleSwitch.vue index 9c6c5c7de..345982b7d 100644 --- a/client/src/components/ui/ToggleSwitch.vue +++ b/client/src/components/ui/ToggleSwitch.vue @@ -12,14 +12,14 @@ function handleToggle() { type="button" role="switch" :aria-checked="modelValue" - class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 disabled:opacity-50 disabled:cursor-not-allowed" + class="relative inline-flex h-5 w-9 shrink-0 cursor-pointer overflow-hidden rounded-full border-2 border-transparent p-0 transition-colors focus:outline-none focus:ring-2 focus:ring-primary/50 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50" :class="modelValue ? 'bg-primary' : 'bg-muted'" :disabled="disabled" @click="handleToggle" > diff --git a/client/src/features/collection/components/CreateCollectionDialog.vue b/client/src/features/collection/components/CreateCollectionDialog.vue index 9d8d51b99..74d6c8e13 100644 --- a/client/src/features/collection/components/CreateCollectionDialog.vue +++ b/client/src/features/collection/components/CreateCollectionDialog.vue @@ -5,6 +5,7 @@ import { useRouter } from 'vue-router' import { X } from '@lucide/vue' import { useCollections } from '../composables/useCollections' import IconPicker from '@/components/IconPicker.vue' +import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' defineProps<{ open: boolean }>() const emit = defineEmits<{ close: [] }>() @@ -15,6 +16,7 @@ const { t } = useI18n() const name = ref('') const icon = ref('') +const isPublic = ref(false) const saving = ref(false) const error = ref(null) const trimmedName = computed(() => name.value.trim()) @@ -32,9 +34,10 @@ async function submit() { saving.value = true error.value = null try { - const collection = await createCollection(trimmedName.value, trimmedIcon.value) + const collection = await createCollection(trimmedName.value, trimmedIcon.value, undefined, isPublic.value) name.value = '' icon.value = '' + isPublic.value = false emit('close') router.push({ name: 'collection', params: { id: collection.id } }) } catch { @@ -74,6 +77,14 @@ async function submit() { +
+
+

{{ t('collection.visibleToAll') }}

+

{{ t('collection.visibleToAllHint') }}

+
+ +
+

{{ error }}

diff --git a/client/src/features/collection/components/EditCollectionDialog.vue b/client/src/features/collection/components/EditCollectionDialog.vue index 2e3e850f8..3fd2caaa7 100644 --- a/client/src/features/collection/components/EditCollectionDialog.vue +++ b/client/src/features/collection/components/EditCollectionDialog.vue @@ -6,6 +6,7 @@ import { X } from '@lucide/vue' import { toast } from 'vue-sonner' import { useCollections } from '../composables/useCollections' import IconPicker from '@/components/IconPicker.vue' +import ToggleSwitch from '@/components/ui/ToggleSwitch.vue' import type { Collection } from '@bookorbit/types' const props = defineProps<{ open: boolean; collection: Collection }>() @@ -18,6 +19,7 @@ const { t } = useI18n() const name = ref('') const icon = ref('') const syncToKobo = ref(false) +const isPublic = ref(false) const saving = ref(false) const deleting = ref(false) const confirmingDelete = ref(false) @@ -32,6 +34,7 @@ watch( name.value = props.collection.name icon.value = props.collection.icon ?? '' syncToKobo.value = props.collection.syncToKobo + isPublic.value = props.collection.isPublic error.value = null confirmingDelete.value = false } @@ -50,7 +53,7 @@ async function submit() { saving.value = true error.value = null try { - await updateCollection(props.collection.id, trimmedName.value, trimmedIcon.value, syncToKobo.value) + await updateCollection(props.collection.id, trimmedName.value, trimmedIcon.value, syncToKobo.value, isPublic.value) emit('close') } catch { error.value = t('collection.editDialog.updateFailed') @@ -126,22 +129,20 @@ async function confirmDelete() {
+
+
+

{{ t('collection.visibleToAll') }}

+

{{ t('collection.visibleToAllHint') }}

+
+ +
+

{{ t('collection.editDialog.syncToKobo') }}

{{ t('collection.editDialog.syncToKoboHint') }}

- +

{{ error }}

diff --git a/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts b/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts index 357b39d49..5260553a9 100644 --- a/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts +++ b/client/src/features/collection/components/__tests__/AddToCollectionSheet.spec.ts @@ -44,9 +44,12 @@ vi.mock('@/composables/useVirtualKeyboard', () => ({ function makeCollection(overrides: Partial = {}): Collection { return { id: 10, + userId: 1, name: 'Favorites', icon: 'FolderOpen', description: null, + isPublic: false, + isOwner: true, syncToKobo: false, displayOrder: 0, bookCount: 3, diff --git a/client/src/features/collection/composables/__tests__/useCollections.spec.ts b/client/src/features/collection/composables/__tests__/useCollections.spec.ts index 18c08db4a..8beafd859 100644 --- a/client/src/features/collection/composables/__tests__/useCollections.spec.ts +++ b/client/src/features/collection/composables/__tests__/useCollections.spec.ts @@ -10,9 +10,12 @@ vi.mock('@/lib/api', () => ({ function makeCollection(overrides: Partial = {}): Collection { return { id: 7, + userId: 1, name: 'Favorites', icon: 'FolderOpen', description: null, + isPublic: false, + isOwner: true, syncToKobo: false, displayOrder: 0, bookCount: 0, @@ -52,6 +55,23 @@ describe('useCollections', () => { expect(collections.value).toEqual([created]) }) + it('sends explicit visibility when creating a public collection', async () => { + const created = makeCollection({ isPublic: true }) + apiMock.mockResolvedValueOnce(makeResponse(created)) + + const { useCollections } = await import('../useCollections') + const { createCollection } = useCollections() + + await createCollection('Shared', 'Globe', undefined, true) + + const [, request] = apiMock.mock.calls[0] as [string, RequestInit] + expect(JSON.parse(String(request.body))).toEqual({ + name: 'Shared', + icon: 'Globe', + isPublic: true, + }) + }) + it('removes deleted collections from local state', async () => { const created = makeCollection() apiMock.mockResolvedValueOnce(makeResponse(created)).mockResolvedValueOnce(makeResponse()) diff --git a/client/src/features/collection/composables/useCollections.ts b/client/src/features/collection/composables/useCollections.ts index 6b952571b..e73535e19 100644 --- a/client/src/features/collection/composables/useCollections.ts +++ b/client/src/features/collection/composables/useCollections.ts @@ -1,6 +1,6 @@ import { ref } from 'vue' import { api } from '@/lib/api' -import type { BookSelectionPayload, Collection } from '@bookorbit/types' +import type { BookSelectionPayload, Collection, CreateCollectionPayload } from '@bookorbit/types' const collections = ref([]) const loaded = ref(false) @@ -58,11 +58,12 @@ export function useCollections() { return res.json() } - async function createCollection(name: string, icon: string, description?: string): Promise { + async function createCollection(name: string, icon: string, description?: string, isPublic?: boolean): Promise { + const payload: CreateCollectionPayload = { name, icon, description, ...(isPublic !== undefined && { isPublic }) } const res = await api('/api/v1/collections', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, icon, description }), + body: JSON.stringify(payload), }) if (!res.ok) throw new Error('Failed to create collection') const created: Collection = await res.json() @@ -70,11 +71,16 @@ export function useCollections() { return created } - async function updateCollection(id: number, name: string, icon: string, syncToKobo?: boolean): Promise { + async function updateCollection(id: number, name: string, icon: string, syncToKobo?: boolean, isPublic?: boolean): Promise { const res = await api(`/api/v1/collections/${id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ name, icon, ...(syncToKobo !== undefined && { syncToKobo }) }), + body: JSON.stringify({ + name, + icon, + ...(syncToKobo !== undefined && { syncToKobo }), + ...(isPublic !== undefined && { isPublic }), + }), }) if (!res.ok) throw new Error('Failed to update collection') const updated: Collection = await res.json() diff --git a/client/src/features/collection/lib/collection-access.spec.ts b/client/src/features/collection/lib/collection-access.spec.ts new file mode 100644 index 000000000..915546058 --- /dev/null +++ b/client/src/features/collection/lib/collection-access.spec.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { Collection } from '@bookorbit/types' +import { canMutateCollection, ownedCollectionOrder } from './collection-access' + +function makeCollection(id: number, isOwner: boolean): Collection { + return { + id, + userId: isOwner ? 7 : 99, + name: `Collection ${id}`, + icon: 'FolderOpen', + description: null, + isPublic: !isOwner, + isOwner, + syncToKobo: false, + displayOrder: id, + bookCount: 0, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + } +} + +describe('collection access policy', () => { + it('never treats public visibility as mutation authority', () => { + expect(canMutateCollection(makeCollection(1, true))).toBe(true) + expect(canMutateCollection(makeCollection(2, false))).toBe(false) + expect(canMutateCollection(undefined)).toBe(false) + }) + + it('removes shared collection IDs before persisting sidebar order', () => { + const collections = [makeCollection(1, true), makeCollection(2, false), makeCollection(3, true)] + + expect( + ownedCollectionOrder(collections, [ + { id: 2, displayOrder: 0 }, + { id: 3, displayOrder: 1 }, + { id: 1, displayOrder: 2 }, + ]), + ).toEqual([ + { id: 3, displayOrder: 1 }, + { id: 1, displayOrder: 2 }, + ]) + }) +}) diff --git a/client/src/features/collection/lib/collection-access.ts b/client/src/features/collection/lib/collection-access.ts new file mode 100644 index 000000000..2db8311eb --- /dev/null +++ b/client/src/features/collection/lib/collection-access.ts @@ -0,0 +1,13 @@ +import type { Collection } from '@bookorbit/types' + +export function canMutateCollection(collection: Collection | null | undefined): boolean { + return collection?.isOwner === true +} + +export function ownedCollectionOrder( + collections: readonly Collection[], + order: readonly { id: number; displayOrder: number }[], +): { id: number; displayOrder: number }[] { + const ownedIds = new Set(collections.filter(canMutateCollection).map((collection) => collection.id)) + return order.filter((entry) => ownedIds.has(entry.id)) +} diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 4371653eb..0f2fa752c 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -5684,6 +5684,8 @@ } }, "collection": { + "visibleToAll": "Visible to all users", + "visibleToAllHint": "Every user can see this collection, but only you can change its books", "dialog": { "name": "Name", "icon": "Icon", diff --git a/client/src/views/CollectionView.vue b/client/src/views/CollectionView.vue index e89f163da..39ecc1784 100644 --- a/client/src/views/CollectionView.vue +++ b/client/src/views/CollectionView.vue @@ -39,6 +39,7 @@ import DeleteBookDialog from '@/features/book/components/DeleteBookDialog.vue' import JumpRail from '@/features/book/components/JumpRail.vue' import { toast } from 'vue-sonner' import { useCollections } from '@/features/collection/composables/useCollections' +import { canMutateCollection } from '@/features/collection/lib/collection-access' import { useBookViewWindow } from '@/features/book/composables/useBookViewWindow' import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference' import { useEffectiveSeriesCollapse } from '@/features/book/composables/useEffectiveSeriesCollapse' @@ -76,6 +77,7 @@ const { coverSize, gridGap } = useViewDisplaySettings('collection', collectionId const { collections, loaded: collectionsLoaded, error: collectionsError, fetchCollections, removeBooksFromCollection } = useCollections() const collectionNotFound = ref(false) const collection = computed(() => collections.value.find((c) => c.id === collectionId.value)) +const isCollectionOwner = computed(() => canMutateCollection(collection.value)) const pageTitle = computed(() => { if (collection.value?.name) return t('views.collection.pageTitle', { name: collection.value.name }) return Number.isFinite(collectionId.value) ? t('views.collection.pageTitleWithId', { id: collectionId.value }) : t('views.collection.title') @@ -279,7 +281,7 @@ const mobileControlsExpanded = ref(false) let removingInProgress = false async function handleRemoveFromCollection() { - if (removingInProgress || !collectionId.value || selectedIds.value.size === 0) return + if (!isCollectionOwner.value || removingInProgress || !collectionId.value || selectedIds.value.size === 0) return removingInProgress = true try { const ids = [...selectedIds.value] @@ -424,7 +426,7 @@ defineOptions({ name: 'CollectionView' })