(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' })