feat(settings): add theme persistence across devices

Introduce a user-preferences module and database schema to sync theme
settings (theme, accent, radius, background, brightness) across devices.
Users can now enable theme sync to persist their customization choices.

Closes #146
This commit is contained in:
neon
2026-05-30 17:27:08 +00:00
parent 100334773c
commit a1fe471e83
29 changed files with 11509 additions and 70 deletions
@@ -0,0 +1,253 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { reactive, ref } from 'vue'
import type { ThemePreferences } from '@bookorbit/types'
const user = ref<{ settings?: { syncThemePreferences?: boolean } } | null>(null)
const toastError = vi.fn<(message: string) => void>()
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json: () => Promise<unknown> }>>()
function createThemeStore() {
return reactive({
theme: 'dark' as ThemePreferences['theme'],
accent: 'blue' as ThemePreferences['accent'],
radius: 'rounded' as ThemePreferences['radius'],
background: 'vinyl' as ThemePreferences['background'],
brightness: 35,
setTheme(nextTheme: ThemePreferences['theme']) {
this.theme = nextTheme
},
setAccent(nextAccent: ThemePreferences['accent']) {
this.accent = nextAccent
},
setRadius(nextRadius: ThemePreferences['radius']) {
this.radius = nextRadius
},
setBackground(nextBackground: ThemePreferences['background']) {
this.background = nextBackground
},
setBrightness(nextBrightness: number) {
this.brightness = nextBrightness
},
})
}
let themeStore = createThemeStore()
function mockJsonResponse(body: unknown, ok = true) {
return {
ok,
json: vi.fn<() => Promise<unknown>>().mockResolvedValue(body),
}
}
vi.mock('@/stores/theme', () => ({
useThemeStore: () => themeStore,
}))
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: () => ({ user }),
}))
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
getAccessToken: () => 'test-access-token',
}))
vi.mock('vue-sonner', () => ({
toast: { error: toastError },
}))
describe('useThemeSync', () => {
beforeEach(() => {
vi.resetModules()
vi.clearAllMocks()
vi.useFakeTimers()
user.value = { settings: { syncThemePreferences: false } }
themeStore = createThemeStore()
apiMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('loadFromServer applies valid fields from the server payload', async () => {
apiMock.mockResolvedValueOnce(
mockJsonResponse({
settings: {
theme: 'light',
accent: 'rose',
radius: 'pill',
background: 'mesh',
brightness: 72,
},
}),
)
const { loadFromServer } = await import('../useThemeSync')
await loadFromServer()
expect(themeStore.theme).toBe('light')
expect(themeStore.accent).toBe('rose')
expect(themeStore.radius).toBe('pill')
expect(themeStore.background).toBe('mesh')
expect(themeStore.brightness).toBe(72)
})
it('loadFromServer no-ops when server settings are null', async () => {
apiMock.mockResolvedValueOnce(mockJsonResponse({ settings: null }))
const { loadFromServer } = await import('../useThemeSync')
await loadFromServer()
expect(themeStore.theme).toBe('dark')
expect(themeStore.accent).toBe('blue')
expect(themeStore.radius).toBe('rounded')
expect(themeStore.background).toBe('vinyl')
expect(themeStore.brightness).toBe(35)
})
it('loadFromServer swallows api errors', async () => {
apiMock.mockRejectedValueOnce(new Error('boom'))
const { loadFromServer } = await import('../useThemeSync')
await expect(loadFromServer()).resolves.toBeUndefined()
})
it('loadFromServer sanitizes invalid values and keeps existing local ones', async () => {
apiMock.mockResolvedValueOnce(
mockJsonResponse({
settings: {
theme: 'light',
accent: 'invalid',
radius: 'pill',
background: 'not-real',
brightness: 101,
},
}),
)
const { loadFromServer } = await import('../useThemeSync')
await loadFromServer()
expect(themeStore.theme).toBe('light')
expect(themeStore.accent).toBe('blue')
expect(themeStore.radius).toBe('pill')
expect(themeStore.background).toBe('vinyl')
expect(themeStore.brightness).toBe(35)
})
it('loadFromServer does not trigger a save request while applying server data', async () => {
user.value = { settings: { syncThemePreferences: true } }
apiMock.mockResolvedValueOnce(
mockJsonResponse({ settings: { theme: 'light', accent: 'rose', radius: 'pill', background: 'mesh', brightness: 70 } }),
)
const { initThemeSync, loadFromServer } = await import('../useThemeSync')
initThemeSync()
await loadFromServer()
await vi.advanceTimersByTimeAsync(1600)
expect(apiMock).toHaveBeenCalledTimes(1)
expect(apiMock).toHaveBeenCalledWith('/api/v1/user-preferences/theme')
})
it('saveToServer calls PUT with the expected payload', async () => {
user.value = { settings: { syncThemePreferences: true } }
apiMock.mockResolvedValueOnce(mockJsonResponse({}, true))
const prefs: ThemePreferences = {
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
}
const { saveToServer } = await import('../useThemeSync')
await saveToServer(prefs)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/theme',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({ settings: prefs }),
}),
)
})
it('saveToServer shows a toast when the server rejects the request', async () => {
user.value = { settings: { syncThemePreferences: true } }
apiMock.mockResolvedValueOnce(mockJsonResponse({}, false))
const { saveToServer } = await import('../useThemeSync')
await saveToServer({
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
})
expect(toastError).toHaveBeenCalledWith('Failed to save theme preferences')
})
it('initThemeSync is idempotent', async () => {
user.value = { settings: { syncThemePreferences: true } }
apiMock.mockResolvedValue(mockJsonResponse({}, true))
const { initThemeSync } = await import('../useThemeSync')
initThemeSync()
initThemeSync()
themeStore.setAccent('rose')
await vi.advanceTimersByTimeAsync(1600)
expect(apiMock).toHaveBeenCalledTimes(1)
})
it('watcher only saves when sync is enabled', async () => {
apiMock.mockResolvedValue(mockJsonResponse({}, true))
const { initThemeSync } = await import('../useThemeSync')
initThemeSync()
themeStore.setAccent('rose')
await vi.advanceTimersByTimeAsync(1600)
expect(apiMock).not.toHaveBeenCalled()
user.value = { settings: { syncThemePreferences: true } }
themeStore.setAccent('green')
await vi.advanceTimersByTimeAsync(1600)
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/user-preferences/theme',
expect.objectContaining({
method: 'PUT',
body: JSON.stringify({
settings: {
theme: 'dark',
accent: 'green',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
},
}),
}),
)
})
it('cancelPendingThemeSync prevents a scheduled save from firing', async () => {
user.value = { settings: { syncThemePreferences: true } }
apiMock.mockResolvedValue(mockJsonResponse({}, true))
const { initThemeSync, cancelPendingThemeSync } = await import('../useThemeSync')
initThemeSync()
themeStore.setAccent('rose')
cancelPendingThemeSync()
await vi.advanceTimersByTimeAsync(1600)
expect(apiMock).not.toHaveBeenCalled()
})
})
+155
View File
@@ -0,0 +1,155 @@
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, THEME_IDS, type ThemePreferences } from '@bookorbit/types'
import { watch } from 'vue'
import { toast } from 'vue-sonner'
import { useAuth } from '@/features/auth/composables/useAuth'
import { api, getAccessToken } from '@/lib/api'
import { useThemeStore } from '@/stores/theme'
let initialized = false
let isApplyingServerPrefs = false
let pendingSave: ReturnType<typeof setTimeout> | null = null
let pagehideRegistered = false
function getCurrentPrefs(): ThemePreferences {
const store = useThemeStore()
return {
theme: store.theme,
accent: store.accent,
radius: store.radius,
background: store.background,
brightness: store.brightness,
}
}
function isSyncEnabled(): boolean {
const { user } = useAuth()
return user.value?.settings?.syncThemePreferences === true
}
function sanitizeServerPrefs(raw: unknown): Partial<ThemePreferences> {
if (typeof raw !== 'object' || raw === null) return {}
const obj = raw as Record<string, unknown>
const out: Partial<ThemePreferences> = {}
if (THEME_IDS.includes(obj.theme as ThemePreferences['theme'])) out.theme = obj.theme as ThemePreferences['theme']
if (ACCENT_IDS.includes(obj.accent as ThemePreferences['accent'])) out.accent = obj.accent as ThemePreferences['accent']
if (RADIUS_IDS.includes(obj.radius as ThemePreferences['radius'])) out.radius = obj.radius as ThemePreferences['radius']
if (BACKGROUND_IDS.includes(obj.background as ThemePreferences['background'])) out.background = obj.background as ThemePreferences['background']
if (typeof obj.brightness === 'number' && Number.isInteger(obj.brightness) && obj.brightness >= 0 && obj.brightness <= 100) {
out.brightness = obj.brightness
}
return out
}
function flushPendingSave(): void {
if (pendingSave === null || !isSyncEnabled()) return
clearTimeout(pendingSave)
pendingSave = null
const accessToken = getAccessToken()
if (!accessToken) return
void fetch('/api/v1/user-preferences/theme', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${accessToken}`,
},
credentials: 'include',
keepalive: true,
body: JSON.stringify({ settings: getCurrentPrefs() }),
})
}
export async function loadFromServer(): Promise<void> {
try {
const res = await api('/api/v1/user-preferences/theme')
if (!res.ok) return
const body = (await res.json()) as { settings: unknown }
if (body.settings === null || body.settings === undefined) return
const prefs = sanitizeServerPrefs(body.settings)
const store = useThemeStore()
isApplyingServerPrefs = true
try {
if (prefs.theme !== undefined) store.setTheme(prefs.theme)
if (prefs.accent !== undefined) store.setAccent(prefs.accent)
if (prefs.radius !== undefined) store.setRadius(prefs.radius)
if (prefs.background !== undefined) store.setBackground(prefs.background)
if (prefs.brightness !== undefined) store.setBrightness(prefs.brightness)
} finally {
isApplyingServerPrefs = false
}
} catch {
// Silent on startup.
}
}
export async function seedToServer(prefs: ThemePreferences): Promise<void> {
try {
await api('/api/v1/user-preferences/theme', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: prefs }),
})
} catch {
// Silent seed failure.
}
}
export async function saveToServer(prefs: ThemePreferences): Promise<void> {
if (!isSyncEnabled()) return
try {
const res = await api('/api/v1/user-preferences/theme', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ settings: prefs }),
})
if (!res.ok) {
toast.error('Failed to save theme preferences')
}
} catch {
toast.error('Failed to save theme preferences')
}
}
export function cancelPendingThemeSync(): void {
if (pendingSave !== null) {
clearTimeout(pendingSave)
pendingSave = null
}
}
export function initThemeSync(): void {
if (initialized) return
initialized = true
const store = useThemeStore()
watch(
() => [store.theme, store.accent, store.radius, store.background, store.brightness] as const,
() => {
if (isApplyingServerPrefs || !isSyncEnabled()) return
if (pendingSave !== null) clearTimeout(pendingSave)
pendingSave = setTimeout(() => {
pendingSave = null
void saveToServer(getCurrentPrefs())
}, 1500)
},
{ flush: 'sync' },
)
if (!pagehideRegistered && typeof window !== 'undefined') {
pagehideRegistered = true
window.addEventListener('pagehide', flushPendingSave)
}
}
@@ -2,6 +2,7 @@ import { ref } from 'vue'
import type { AuthUser, AuthResponse } from '@bookorbit/types'
import { api, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
import router from '@/router'
import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composables/useThemeSync'
import { useSetupStatus } from './useSetupStatus'
import { disconnectAuthorEnrichmentSocket } from '@/features/settings/composables/useAuthorEnrichmentStatus'
import { disconnectBookMetadataFetchSocket } from '@/features/book-metadata-fetch/composables/useBookMetadataFetchStatus'
@@ -43,6 +44,7 @@ if (typeof document !== 'undefined') {
}
function clearAuth() {
cancelPendingThemeSync()
stopSessionRefresh()
user.value = null
setAccessToken(null)
@@ -62,16 +64,30 @@ async function me(): Promise<void> {
user.value = await res.json()
}
async function hydrateThemeSync(options: { refreshUser?: boolean } = {}): Promise<void> {
if (options.refreshUser) {
await me()
}
if (user.value?.settings?.syncThemePreferences) {
await loadFromServer()
}
initThemeSync()
}
export function useAuth() {
async function init(): Promise<void> {
isLoading.value = true
try {
await refreshAccessToken()
await me()
await hydrateThemeSync()
startSessionRefresh()
} catch {
// no valid session
} finally {
initThemeSync()
isLoading.value = false
}
}
@@ -92,9 +108,10 @@ export function useAuth() {
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
await hydrateThemeSync({ refreshUser: true })
startSessionRefresh()
if (data.user.isDefaultPassword) {
if (user.value?.isDefaultPassword) {
router.push('/')
} else {
const redirect = router.currentRoute.value.query.redirect as string | undefined
@@ -128,6 +145,7 @@ export function useAuth() {
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
await hydrateThemeSync()
startSessionRefresh()
useSetupStatus().markSetupComplete()
@@ -167,6 +185,7 @@ export function useAuth() {
const data: AuthResponse = await res.json()
setAccessToken(data.accessToken)
user.value = data.user
await hydrateThemeSync()
startSessionRefresh()
router.push('/')
}
@@ -1,7 +1,12 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toast } from 'vue-sonner'
import { ACCENT_VIVID, ACCENT_PASTEL, BACKGROUND_OPTIONS, RADIUS_OPTIONS, useThemeStore } from '@/stores/theme'
import { Circle, Moon, Square, Sun } from 'lucide-vue-next'
import { Circle, Cloud, Monitor, Moon, Square, Sun } from 'lucide-vue-next'
import { loadFromServer, seedToServer } from '@/composables/useThemeSync'
import { useAuth } from '@/features/auth/composables/useAuth'
import { usePermissions } from '@/features/auth/composables/usePermissions'
import { api } from '@/lib/api'
import {
useDisplaySettings,
type BookShadowStrength,
@@ -15,6 +20,8 @@ import SettingsPageHeader from './SettingsPageHeader.vue'
import { useSeriesCollapsePreference } from '@/features/book/composables/useSeriesCollapsePreference'
const themeStore = useThemeStore()
const { user } = useAuth()
const { isDemoRestrictedAccount } = usePermissions()
const {
portraitCoverSize,
squareCoverSize,
@@ -32,11 +39,61 @@ const {
const { prefs, setPreference } = useSeriesCollapsePreference()
const globalCollapseEnabled = computed(() => prefs.value?.global ?? false)
const syncEnabled = computed(() => !isDemoRestrictedAccount.value && (user.value?.settings?.syncThemePreferences ?? false))
async function handleGlobalCollapseToggle(value: boolean) {
await setPreference('global', value)
}
async function handleSetStorageMode(sync: boolean) {
if (!user.value || syncEnabled.value === sync) return
if (isDemoRestrictedAccount.value) {
toast.error('Demo-restricted account cannot change theme storage mode')
return
}
try {
const res = await api('/api/v1/users/me/theme-storage-mode', {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sync }),
})
if (!res.ok) {
toast.error('Failed to update storage mode')
return
}
user.value = {
...user.value,
settings: { ...user.value.settings, syncThemePreferences: sync },
}
if (sync) {
const prefsRes = await api('/api/v1/user-preferences/theme')
if (prefsRes.ok) {
const body = (await prefsRes.json()) as { settings: unknown }
if (body.settings === null) {
await seedToServer({
theme: themeStore.theme,
accent: themeStore.accent,
radius: themeStore.radius,
background: themeStore.background,
brightness: themeStore.brightness,
})
} else {
await loadFromServer()
}
}
}
toast.success(sync ? 'Preferences will now be synced' : 'Preferences will stay on this device')
} catch {
toast.error('An error occurred while updating storage mode')
}
}
const OVERLAY_OPTIONS: { key: CardOverlayKey; label: string; hint: string }[] = [
{ key: 'progress-bar', label: 'Progress bar', hint: 'Thin colored line along the bottom edge' },
{ key: 'format', label: 'File format', hint: 'Color-coded EPUB, PDF, CBZ badge at bottom-right' },
@@ -98,6 +155,69 @@ const syncModeEnabled = computed(() => coverSizeScope.value === 'synced')
</p>
</div>
<!-- Preference storage -->
<div class="mt-5 mb-6">
<p class="settings-group-label">Where to save appearance preferences</p>
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 cursor-pointer transition-colors"
:class="!syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'"
@click="handleSetStorageMode(false)"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="!syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Monitor :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">This device only</span>
<span v-if="!syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences stay in your browser. Best if you want a different look on different devices.
</span>
</div>
</div>
<div
class="flex items-start gap-4 px-4 py-3.5 md:px-5 md:py-4 rounded-lg border-2 transition-colors"
:class="[
isDemoRestrictedAccount ? 'border-border bg-card opacity-50 cursor-not-allowed' : 'cursor-pointer',
!isDemoRestrictedAccount && (syncEnabled ? 'border-primary bg-primary/5' : 'border-border bg-card hover:border-muted-foreground/30'),
]"
@click="handleSetStorageMode(true)"
>
<div
class="mt-0.5 flex items-center justify-center w-8 h-8 rounded-lg shrink-0 transition-colors"
:class="syncEnabled ? 'bg-primary/15 text-primary' : 'bg-muted text-muted-foreground'"
>
<Cloud :size="16" />
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1">
<span class="settings-label">My account</span>
<span v-if="syncEnabled" class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-primary/15 text-primary">
Active
</span>
<span
v-if="isDemoRestrictedAccount"
class="text-xs font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-muted text-muted-foreground"
>
Not available
</span>
</div>
<span class="block text-xs text-muted-foreground leading-relaxed">
Preferences are saved to your account. Best if you want the same appearance on every device.
</span>
</div>
</div>
</div>
</div>
<!-- Theme & colors -->
<div class="mb-6">
<p class="settings-group-label">Theme</p>
@@ -0,0 +1,212 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { mount, flushPromises } from '@vue/test-utils'
import { computed, ref } from 'vue'
import AppearanceSettings from '../AppearanceSettings.vue'
const userState = {
settings: { syncThemePreferences: false } as Record<string, unknown>,
}
const permissionState = { isDemo: false }
const apiMock = vi.fn<(...args: unknown[]) => Promise<{ ok: boolean; json?: () => Promise<unknown> }>>()
const loadFromServerMock = vi.fn<() => Promise<void>>()
const seedToServerMock = vi.fn<(...args: unknown[]) => Promise<void>>()
const themeStore = {
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
toggleTheme: vi.fn<() => void>(),
setTheme: vi.fn<(theme: string) => void>(),
setAccent: vi.fn<(accent: string) => void>(),
setRadius: vi.fn<(radius: string) => void>(),
setBackground: vi.fn<(background: string) => void>(),
setBrightness: vi.fn<(brightness: number) => void>(),
}
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: () => {
const user = ref({ ...userState, settings: { ...userState.settings } })
return { user }
},
}))
vi.mock('@/features/auth/composables/usePermissions', () => ({
usePermissions: () => ({
isDemoRestrictedAccount: computed(() => permissionState.isDemo),
}),
}))
vi.mock('@/lib/api', () => ({
api: (...args: unknown[]) => apiMock(...args),
}))
vi.mock('@/composables/useThemeSync', () => ({
loadFromServer: () => loadFromServerMock(),
seedToServer: (...args: unknown[]) => seedToServerMock(...args),
}))
vi.mock('@/stores/theme', () => ({
ACCENT_VIVID: [{ id: 'blue', label: 'Blue', color: '#0000ff' }],
ACCENT_PASTEL: [{ id: 'grey', label: 'Grey', color: '#999999' }],
RADIUS_OPTIONS: [{ id: 'rounded', label: 'Rounded', className: 'rounded-lg' }],
BACKGROUND_OPTIONS: [{ id: 'vinyl', label: 'Vinyl', previewClass: 'bg-muted' }],
useThemeStore: () => themeStore,
}))
vi.mock('@/composables/useDisplaySettings', () => ({
useDisplaySettings: () => ({
portraitCoverSize: ref(180),
squareCoverSize: ref(180),
coverSizeScope: ref('separate'),
portraitGridGap: ref(12),
squareGridGap: ref(12),
cardOverlays: ref([]),
smartScopeFilterExpanded: ref(false),
authorCoverSize: ref(120),
authorCoverShape: ref('portrait'),
tableZebraStriping: ref(true),
bookSpineOverlay: ref('off'),
bookShadowStrength: ref('default'),
}),
}))
vi.mock('@/features/book/composables/useSeriesCollapsePreference', () => ({
useSeriesCollapsePreference: () => ({
prefs: ref({ global: false }),
setPreference: vi.fn<(...args: unknown[]) => Promise<void>>().mockResolvedValue(undefined),
}),
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: { template: '<div><slot /></div>' },
TooltipContent: { template: '<div><slot /></div>' },
TooltipTrigger: { template: '<div><slot /></div>', props: ['asChild'] },
}))
vi.mock('@/components/ui/ToggleSwitch.vue', () => ({
default: { template: '<div />', props: ['modelValue', 'label', 'description'] },
}))
vi.mock('../SettingsPageHeader.vue', () => ({
default: { template: '<div />' },
}))
vi.mock('vue-sonner', () => ({
toast: { success: vi.fn<() => void>(), error: vi.fn<() => void>() },
}))
import { toast } from 'vue-sonner'
function mountComponent() {
return mount(AppearanceSettings)
}
describe('AppearanceSettings', () => {
beforeEach(() => {
vi.clearAllMocks()
permissionState.isDemo = false
userState.settings = { syncThemePreferences: false }
apiMock.mockResolvedValue({ ok: true, json: async () => ({ settings: null }) })
})
it('shows This device only as active when theme sync is disabled', () => {
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
const deviceCard = cards[0]!
expect(deviceCard.classes()).toContain('border-primary')
expect(deviceCard.text()).toContain('Active')
})
it('shows My account as active when theme sync is enabled', () => {
userState.settings = { syncThemePreferences: true }
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
const accountCard = cards[1]!
expect(accountCard.classes()).toContain('border-primary')
expect(accountCard.text()).toContain('Active')
})
it('enabling account sync seeds current local preferences when no server row exists', async () => {
apiMock.mockResolvedValueOnce({ ok: true, json: async () => ({}) }).mockResolvedValueOnce({ ok: true, json: async () => ({ settings: null }) })
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
await cards[1]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenNthCalledWith(
1,
'/api/v1/users/me/theme-storage-mode',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ sync: true }) }),
)
expect(apiMock).toHaveBeenNthCalledWith(2, '/api/v1/user-preferences/theme')
expect(seedToServerMock).toHaveBeenCalledWith({
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
})
expect(loadFromServerMock).not.toHaveBeenCalled()
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Preferences will now be synced')
})
it('enabling account sync loads existing server preferences when a row already exists', async () => {
apiMock
.mockResolvedValueOnce({ ok: true, json: async () => ({}) })
.mockResolvedValueOnce({ ok: true, json: async () => ({ settings: { theme: 'light' } }) })
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
await cards[1]!.trigger('click')
await flushPromises()
expect(loadFromServerMock).toHaveBeenCalled()
expect(seedToServerMock).not.toHaveBeenCalled()
})
it('switching back to This device only calls the storage mode endpoint with sync false', async () => {
userState.settings = { syncThemePreferences: true }
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
await cards[0]!.trigger('click')
await flushPromises()
expect(apiMock).toHaveBeenCalledWith(
'/api/v1/users/me/theme-storage-mode',
expect.objectContaining({ method: 'PATCH', body: JSON.stringify({ sync: false }) }),
)
expect(vi.mocked(toast.success)).toHaveBeenCalledWith('Preferences will stay on this device')
})
it('shows an error toast when the storage mode update fails', async () => {
apiMock.mockResolvedValueOnce({ ok: false, json: async () => ({}) })
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
await cards[1]!.trigger('click')
await flushPromises()
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Failed to update storage mode')
})
it('demo-restricted users cannot enable account sync', async () => {
permissionState.isDemo = true
const wrapper = mountComponent()
const cards = wrapper.findAll('.rounded-lg.border-2')
await cards[1]!.trigger('click')
await flushPromises()
expect(wrapper.text()).toContain('Not available')
expect(apiMock).not.toHaveBeenCalled()
expect(vi.mocked(toast.error)).toHaveBeenCalledWith('Demo-restricted account cannot change theme storage mode')
})
})
+9 -66
View File
@@ -1,67 +1,8 @@
import { defineStore } from 'pinia'
import { ref, watch } from 'vue'
import { BACKGROUND_IDS, type Background, ACCENT_IDS, type Accent, RADIUS_IDS, type Radius, THEME_IDS, type Theme } from '@bookorbit/types'
import { storage } from '@/services/storage'
type Theme = 'light' | 'dark'
export type Accent =
| 'grey'
// Vivid — rainbow order
| 'rose'
| 'orange'
| 'amber'
| 'yellow'
| 'lime'
| 'green'
| 'emerald'
| 'teal'
| 'cyan'
| 'sky'
| 'blue'
| 'indigo'
| 'violet'
| 'fuchsia'
| 'pink'
// Pastel — rainbow order
| 'white'
| 'coral'
| 'peach'
| 'butter'
| 'lemon'
| 'celadon'
| 'sage'
| 'mint'
| 'seafoam'
| 'powder'
| 'mist'
| 'periwinkle'
| 'wisteria'
| 'lavender'
| 'orchid'
| 'blush'
export type Radius = 'sharp' | 'default' | 'rounded' | 'pill'
export type Background =
| 'none'
| 'dots'
| 'cross'
| 'terminal'
| 'millimeter'
| 'blueprint'
| 'brushed'
| 'scanlines'
| 'carbon'
| 'vinyl'
| 'perforated'
| 'aurora'
| 'horizon'
| 'glow'
| 'mesh'
| 'elevation'
| 'prism'
| 'spectrum'
| 'spectrum-x'
| 'spectrum-plus'
| 'eclipse'
export const ACCENT_VIVID: { id: Accent; label: string; color: string }[] = [
{ id: 'white', label: 'White', color: '#fafafa' },
{ id: 'rose', label: 'Rose', color: '#e11d48' },
@@ -140,13 +81,11 @@ export const BACKGROUND_OPTIONS: { id: Background; label: string; cssClass: stri
{ id: 'eclipse', label: 'Eclipse', cssClass: 'pattern-eclipse' },
]
const ACCENT_IDS = ACCENT_OPTIONS.map((a) => a.id)
const RADIUS_IDS = RADIUS_OPTIONS.map((r) => r.id)
const BACKGROUND_IDS = BACKGROUND_OPTIONS.map((b) => b.id)
const DEFAULT_SURFACE_BRIGHTNESS = 35
export const useThemeStore = defineStore('theme', () => {
const theme = ref<Theme>(storage.get<Theme>('theme', 'dark'))
const storedTheme = storage.get<Theme>('theme', 'dark')
const theme = ref<Theme>(THEME_IDS.includes(storedTheme) ? storedTheme : 'dark')
const storedAccent = storage.get<Accent>('accent', 'blue')
const accent = ref<Accent>(ACCENT_IDS.includes(storedAccent) ? storedAccent : 'blue')
@@ -178,8 +117,12 @@ export const useThemeStore = defineStore('theme', () => {
document.documentElement.style.setProperty('--bg-lift', lift.toFixed(4))
}
function setTheme(nextTheme: Theme) {
theme.value = nextTheme
}
function toggleTheme() {
theme.value = theme.value === 'dark' ? 'light' : 'dark'
setTheme(theme.value === 'dark' ? 'light' : 'dark')
}
function setAccent(a: Accent) {
@@ -248,5 +191,5 @@ export const useThemeStore = defineStore('theme', () => {
{ immediate: true },
)
return { theme, accent, radius, background, brightness, toggleTheme, setAccent, setRadius, setBackground, setBrightness }
return { theme, accent, radius, background, brightness, setTheme, toggleTheme, setAccent, setRadius, setBackground, setBrightness }
})
+1
View File
@@ -17,6 +17,7 @@ export type ProvisioningMethod = (typeof ProvisioningMethod)[keyof typeof Provis
export interface UserSettings {
syncReaderPreferences?: boolean;
syncThemePreferences?: boolean;
statisticsConfig?: import("./statistics").StatisticsSettings;
onboarding?: {
tourCompleted?: boolean;
+1 -1
View File
@@ -73,7 +73,7 @@ export interface DuplicateScanResponse {
pageSize: number;
}
export type DuplicateScanState = 'idle' | 'computing' | 'done' | 'error';
export type DuplicateScanState = "idle" | "computing" | "done" | "error";
export interface DuplicateScanStatus {
entityType: EntityType;
+1
View File
@@ -1,5 +1,6 @@
export * from "./audit";
export * from "./auth";
export * from "./theme";
export * from "./permissions";
export * from "./content-filter";
export * from "./metadata-preferences";
+4 -1
View File
@@ -134,7 +134,10 @@ function normalizeResolverOptions(options?: PathResolverOptions): Required<PathR
}
function sanitizePathSegmentValue(value: string, replacementCharacter: "_" | "-"): string {
let sanitized = value.replace(INVALID_SEGMENT_CHARS_REGEX, replacementCharacter).trim().replace(/[. ]+$/g, "");
let sanitized = value
.replace(INVALID_SEGMENT_CHARS_REGEX, replacementCharacter)
.trim()
.replace(/[. ]+$/g, "");
if (!sanitized || sanitized === "." || sanitized === "..") {
sanitized = replacementCharacter;
}
+74
View File
@@ -0,0 +1,74 @@
export const THEME_IDS = ["light", "dark"] as const;
export type Theme = (typeof THEME_IDS)[number];
export const ACCENT_IDS = [
"grey",
"rose",
"orange",
"amber",
"yellow",
"lime",
"green",
"emerald",
"teal",
"cyan",
"sky",
"blue",
"indigo",
"violet",
"fuchsia",
"pink",
"white",
"coral",
"peach",
"butter",
"lemon",
"celadon",
"sage",
"mint",
"seafoam",
"powder",
"mist",
"periwinkle",
"wisteria",
"lavender",
"orchid",
"blush",
] as const;
export type Accent = (typeof ACCENT_IDS)[number];
export const RADIUS_IDS = ["sharp", "default", "rounded", "pill"] as const;
export type Radius = (typeof RADIUS_IDS)[number];
export const BACKGROUND_IDS = [
"none",
"dots",
"cross",
"terminal",
"millimeter",
"blueprint",
"brushed",
"scanlines",
"carbon",
"vinyl",
"perforated",
"aurora",
"horizon",
"glow",
"mesh",
"elevation",
"prism",
"spectrum",
"spectrum-x",
"spectrum-plus",
"eclipse",
] as const;
export type Background = (typeof BACKGROUND_IDS)[number];
export interface ThemePreferences {
theme: Theme;
accent: Accent;
radius: Radius;
background: Background;
brightness: number;
}
+2
View File
@@ -36,6 +36,7 @@ import { MetadataModule } from './modules/metadata/metadata.module';
import { NarratorModule } from './modules/narrator/narrator.module';
import { ReaderPreferencesModule } from './modules/reader-preferences/reader-preferences.module';
import { RecommendationModule } from './modules/recommendation/recommendation.module';
import { UserPreferencesModule } from './modules/user-preferences/user-preferences.module';
import { ScannerModule } from './modules/scanner/scanner.module';
import { SeedModule } from './modules/seed/seed.module';
import { EmailModule } from './modules/email/email.module';
@@ -110,6 +111,7 @@ import { HardcoverModule } from './modules/hardcover/hardcover.module';
HealthModule,
CbzModule,
ReaderPreferencesModule,
UserPreferencesModule,
EpubModule,
BookDockModule,
EmailModule,
@@ -0,0 +1,10 @@
CREATE TABLE "user_preferences" (
"id" serial PRIMARY KEY NOT NULL,
"user_id" integer NOT NULL,
"category" text NOT NULL,
"data" jsonb NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "user_preferences" ADD CONSTRAINT "user_preferences_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "up_user_category_idx" ON "user_preferences" USING btree ("user_id","category");
File diff suppressed because it is too large Load Diff
@@ -64,6 +64,13 @@
"when": 1779941161092,
"tag": "0008_auth_refresh_rotation_metadata",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1780159365997,
"tag": "0009_user-preferences-theme-sync",
"breakpoints": true
}
]
}
+1
View File
@@ -26,3 +26,4 @@ export * from './fonts';
export * from './koreader';
export * from './achievements';
export * from './hardcover';
export * from './user-preferences';
+23
View File
@@ -0,0 +1,23 @@
import { integer, jsonb, pgTable, serial, text, timestamp, uniqueIndex } from 'drizzle-orm/pg-core';
import { users } from './auth';
export const userPreferences = pgTable(
'user_preferences',
{
id: serial('id').primaryKey(),
userId: integer('user_id')
.notNull()
.references(() => users.id, { onDelete: 'cascade' }),
category: text('category').notNull(),
data: jsonb('data').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdateFn(() => new Date()),
},
(t) => [uniqueIndex('up_user_category_idx').on(t.userId, t.category)],
);
export type UserPreference = typeof userPreferences.$inferSelect;
export type NewUserPreference = typeof userPreferences.$inferInsert;
@@ -0,0 +1,7 @@
import { IsNotEmptyObject, IsObject } from 'class-validator';
export class UpsertUserPreferenceDto {
@IsObject()
@IsNotEmptyObject()
settings!: Record<string, unknown>;
}
@@ -0,0 +1,76 @@
import type { Mocked } from 'vitest';
import { EMPTY_CONTENT_FILTER_RULES, type ThemePreferences } from '@bookorbit/types';
import type { RequestUser } from '../../common/types/request-user';
import { UserPreferencesController } from './user-preferences.controller';
import { UserPreferencesService } from './user-preferences.service';
function makeUser(overrides?: Partial<RequestUser>): RequestUser {
return {
id: 7,
username: 'reader',
name: 'Reader',
email: null,
active: true,
isSuperuser: false,
isDefaultPassword: false,
tokenVersion: 1,
settings: {},
avatarUrl: null,
provisioningMethod: 'local',
permissions: [],
...overrides,
contentFilters: EMPTY_CONTENT_FILTER_RULES,
};
}
const validThemePreferences: ThemePreferences = {
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
};
describe('UserPreferencesController', () => {
let service: Mocked<UserPreferencesService>;
let controller: UserPreferencesController;
beforeEach(() => {
service = {
getThemePreferences: vi.fn(),
upsertThemePreferences: vi.fn(),
} as unknown as Mocked<UserPreferencesService>;
controller = new UserPreferencesController(service);
});
it('GET /theme returns null settings when no saved preferences exist', async () => {
service.getThemePreferences.mockResolvedValueOnce(null);
await expect(controller.getThemePreferences(makeUser({ id: 11 }))).resolves.toEqual({ settings: null });
expect(service.getThemePreferences).toHaveBeenCalledWith(11);
});
it('GET /theme returns saved settings when present', async () => {
service.getThemePreferences.mockResolvedValueOnce(validThemePreferences);
await expect(controller.getThemePreferences(makeUser())).resolves.toEqual({ settings: validThemePreferences });
});
it('PUT /theme delegates valid payloads to the service and returns 204', async () => {
const user = makeUser({ id: 42 });
const dto = { settings: validThemePreferences };
await expect(controller.upsertThemePreferences(dto, user)).resolves.toBeUndefined();
expect(service.upsertThemePreferences).toHaveBeenCalledWith(42, validThemePreferences);
});
it('PUT /theme forwards the current user id to the service', async () => {
const user = makeUser({ id: 99 });
await controller.upsertThemePreferences({ settings: validThemePreferences }, user);
expect(service.upsertThemePreferences).toHaveBeenCalledWith(99, validThemePreferences);
});
});
@@ -0,0 +1,23 @@
import { Body, Controller, Get, HttpCode, Put } from '@nestjs/common';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import type { RequestUser } from '../../common/types/request-user';
import { UpsertUserPreferenceDto } from './dto/upsert-user-preference.dto';
import { UserPreferencesService } from './user-preferences.service';
@Controller('user-preferences')
export class UserPreferencesController {
constructor(private readonly userPreferencesService: UserPreferencesService) {}
@Get('theme')
async getThemePreferences(@CurrentUser() user: RequestUser) {
const settings = await this.userPreferencesService.getThemePreferences(user.id);
return { settings };
}
@Put('theme')
@HttpCode(204)
async upsertThemePreferences(@Body() dto: UpsertUserPreferenceDto, @CurrentUser() user: RequestUser) {
await this.userPreferencesService.upsertThemePreferences(user.id, dto.settings);
}
}
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { DbModule } from '../../db/db.module';
import { UserPreferencesController } from './user-preferences.controller';
import { UserPreferencesRepository } from './user-preferences.repository';
import { UserPreferencesService } from './user-preferences.service';
@Module({
imports: [DbModule],
controllers: [UserPreferencesController],
providers: [UserPreferencesService, UserPreferencesRepository],
})
export class UserPreferencesModule {}
@@ -0,0 +1,34 @@
import { Inject, Injectable } from '@nestjs/common';
import { and, eq } from 'drizzle-orm';
import { NodePgDatabase } from 'drizzle-orm/node-postgres';
import { DB } from '../../db';
import * as schema from '../../db/schema';
type Db = NodePgDatabase<typeof schema>;
@Injectable()
export class UserPreferencesRepository {
constructor(@Inject(DB) private readonly db: Db) {}
findByCategory(userId: number, category: string) {
return this.db.query.userPreferences.findFirst({
where: and(eq(schema.userPreferences.userId, userId), eq(schema.userPreferences.category, category)),
});
}
async upsert(userId: number, category: string, data: Record<string, unknown>) {
const now = new Date();
await this.db
.insert(schema.userPreferences)
.values({ userId, category, data, updatedAt: now })
.onConflictDoUpdate({
target: [schema.userPreferences.userId, schema.userPreferences.category],
set: { data, updatedAt: now },
});
}
async delete(userId: number, category: string) {
await this.db.delete(schema.userPreferences).where(and(eq(schema.userPreferences.userId, userId), eq(schema.userPreferences.category, category)));
}
}
@@ -0,0 +1,100 @@
import { BadRequestException } from '@nestjs/common';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { ThemePreferences } from '@bookorbit/types';
import { UserPreferencesRepository } from './user-preferences.repository';
import { UserPreferencesService } from './user-preferences.service';
const validThemePreferences: ThemePreferences = {
theme: 'dark',
accent: 'blue',
radius: 'rounded',
background: 'vinyl',
brightness: 35,
};
const repo = {
findByCategory: vi.fn<(...args: [number, string]) => Promise<{ data: ThemePreferences } | undefined>>(),
upsert: vi.fn<(...args: [number, string, Record<string, unknown>]) => Promise<void>>(),
delete: vi.fn<(...args: [number, string]) => Promise<void>>(),
};
describe('UserPreferencesService', () => {
let service: UserPreferencesService;
beforeEach(() => {
vi.clearAllMocks();
repo.findByCategory.mockResolvedValue(undefined);
repo.upsert.mockResolvedValue(undefined);
service = new UserPreferencesService(repo as unknown as UserPreferencesRepository);
});
it('getThemePreferences returns null when repository has no row', async () => {
await expect(service.getThemePreferences(7)).resolves.toBeNull();
expect(repo.findByCategory).toHaveBeenCalledWith(7, 'theme');
});
it('getThemePreferences returns saved theme settings when row exists', async () => {
repo.findByCategory.mockResolvedValueOnce({ data: validThemePreferences });
await expect(service.getThemePreferences(7)).resolves.toEqual(validThemePreferences);
});
it('upsertThemePreferences persists validated settings', async () => {
await expect(service.upsertThemePreferences(11, validThemePreferences)).resolves.toBeUndefined();
expect(repo.upsert).toHaveBeenCalledWith(11, 'theme', validThemePreferences);
});
it('upsertThemePreferences rejects invalid theme ids', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, theme: 'sepia' } as never)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects invalid accent ids', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, accent: 'magenta' } as never)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects invalid radius ids', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, radius: 'soft' } as never)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects invalid background ids', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, background: 'stars' } as never)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects brightness below zero', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: -1 })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects brightness above one hundred', async () => {
await expect(service.upsertThemePreferences(11, { ...validThemePreferences, brightness: 101 })).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects extra unknown fields', async () => {
await expect(
service.upsertThemePreferences(11, { ...validThemePreferences, unexpected: true } as Record<string, unknown>),
).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
it('upsertThemePreferences rejects payloads missing required fields', async () => {
const { background, ...incomplete } = validThemePreferences;
void background;
await expect(service.upsertThemePreferences(11, incomplete)).rejects.toBeInstanceOf(BadRequestException);
expect(repo.upsert).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,55 @@
import { ACCENT_IDS, BACKGROUND_IDS, RADIUS_IDS, THEME_IDS, type ThemePreferences } from '@bookorbit/types';
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
import { z } from 'zod';
import { sanitizeLogValue } from '../../common/utils/log-sanitize.utils';
import { UserPreferencesRepository } from './user-preferences.repository';
const THEME_PREFERENCES_SCHEMA = z
.object({
theme: z.enum(THEME_IDS),
accent: z.enum(ACCENT_IDS),
radius: z.enum(RADIUS_IDS),
background: z.enum(BACKGROUND_IDS),
brightness: z.number().int().min(0).max(100),
})
.strict();
@Injectable()
export class UserPreferencesService {
private readonly logger = new Logger(UserPreferencesService.name);
constructor(private readonly repo: UserPreferencesRepository) {}
async getThemePreferences(userId: number): Promise<ThemePreferences | null> {
const row = await this.repo.findByCategory(userId, 'theme');
return row ? (row.data as ThemePreferences) : null;
}
async upsertThemePreferences(userId: number, data: Record<string, unknown>): Promise<void> {
const start = Date.now();
this.logger.log(`[user_preferences.upsert_theme] [start] userId=${userId} - upsert theme preferences started`);
const result = THEME_PREFERENCES_SCHEMA.safeParse(data);
if (!result.success) {
const firstIssue = result.error.issues[0];
const issuePath = firstIssue?.path.length ? firstIssue.path.join('.') : 'settings';
const issueMessage = firstIssue?.message ?? 'Invalid settings payload';
throw new BadRequestException(`Invalid theme preferences at "${issuePath}": ${issueMessage}`);
}
try {
await this.repo.upsert(userId, 'theme', result.data);
const durationMs = Date.now() - start;
this.logger.log(`[user_preferences.upsert_theme] [end] userId=${userId} durationMs=${durationMs} - upsert theme preferences completed`);
} catch (err) {
const durationMs = Date.now() - start;
const errorClass = err instanceof Error ? err.constructor.name : 'UnknownError';
const error = sanitizeLogValue(err instanceof Error ? err.message : String(err));
this.logger.error(
`[user_preferences.upsert_theme] [fail] userId=${userId} durationMs=${durationMs} errorClass=${errorClass} error="${error}" - upsert theme preferences failed`,
);
throw err;
}
}
}
@@ -0,0 +1,6 @@
import { IsBoolean } from 'class-validator';
export class UpdateThemeStorageModeDto {
@IsBoolean()
sync!: boolean;
}
@@ -26,6 +26,7 @@ describe('UserController', () => {
updateMe: vi.fn(),
updateMySettings: vi.fn(),
updateReaderStorageMode: vi.fn(),
updateThemeStorageMode: vi.fn(),
findById: vi.fn(),
createUser: vi.fn(),
updateUser: vi.fn(),
@@ -293,4 +294,23 @@ describe('UserController', () => {
message: 'Demo-restricted account cannot change reader storage mode',
});
});
it('delegates theme storage mode update to updateThemeStorageMode service method', async () => {
userService.updateThemeStorageMode.mockResolvedValue({ id: 7, settings: { syncThemePreferences: true } });
await controller.updateThemeStorageMode({ id: 7 } as any, { sync: true } as any);
expect(userService.updateThemeStorageMode).toHaveBeenCalledWith(7, true);
});
it('updateThemeStorageMode has ForbidPermission DemoRestricted metadata', () => {
const meta = Reflect.getMetadata(FORBIDDEN_PERMISSION_KEY, UserController.prototype.updateThemeStorageMode) as {
permission: Permission;
message?: string;
};
expect(meta).toEqual({
permission: Permission.DemoRestricted,
message: 'Demo-restricted account cannot change theme storage mode',
});
});
});
@@ -37,6 +37,7 @@ import { SetContentFiltersDto } from './dto/set-content-filters.dto';
import { UpdateMeDto } from './dto/update-me.dto';
import { UpdateMeSettingsDto } from './dto/update-me-settings.dto';
import { UpdateReaderStorageModeDto } from './dto/update-reader-storage-mode.dto';
import { UpdateThemeStorageModeDto } from './dto/update-theme-storage-mode.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { UpdateSeriesCollapsePreferencesDto } from './dto/update-series-collapse-preferences.dto';
import { MAX_USER_AVATAR_BYTES } from './user-avatar.service';
@@ -91,6 +92,13 @@ export class UserController {
return this.userService.updateReaderStorageMode(user.id, dto.sync);
}
@Patch('me/theme-storage-mode')
@HttpCode(HttpStatus.NO_CONTENT)
@ForbidPermission(Permission.DemoRestricted, 'Demo-restricted account cannot change theme storage mode')
updateThemeStorageMode(@CurrentUser() user: RequestUser, @Body() dto: UpdateThemeStorageModeDto) {
return this.userService.updateThemeStorageMode(user.id, dto.sync);
}
@Patch('me/series-collapse-preferences')
@HttpCode(HttpStatus.NO_CONTENT)
updateSeriesCollapsePreferences(@CurrentUser() user: RequestUser, @Body() dto: UpdateSeriesCollapsePreferencesDto) {
@@ -273,6 +273,23 @@ describe('UserService', () => {
await expect(service.updateReaderStorageMode(99, true)).rejects.toBeInstanceOf(NotFoundException);
});
it('updateThemeStorageMode updates syncThemePreferences when enabling sync', async () => {
userRepo.update.mockResolvedValue({ id: 3, settings: { syncThemePreferences: true } });
const result = await service.updateThemeStorageMode(3, true);
expect(userRepo.update).toHaveBeenCalledWith(3, { settings: { syncThemePreferences: true } });
expect(result).toMatchObject({ id: 3 });
});
it('updateThemeStorageMode updates syncThemePreferences when disabling sync', async () => {
userRepo.update.mockResolvedValue({ id: 3, settings: { syncThemePreferences: false } });
await service.updateThemeStorageMode(3, false);
expect(userRepo.update).toHaveBeenCalledWith(3, { settings: { syncThemePreferences: false } });
});
it('deleteUser blocks deleting your own account', async () => {
await expect(service.deleteUser(1, reqUser({ id: 1, isSuperuser: true }))).rejects.toBeInstanceOf(ConflictException);
});
+6
View File
@@ -198,6 +198,12 @@ export class UserService {
return user;
}
async updateThemeStorageMode(userId: number, sync: boolean) {
const user = await this.userRepo.update(userId, { settings: { syncThemePreferences: sync } });
if (!user) throw new NotFoundException('User not found');
return user;
}
async deleteUser(id: number, requestingUser: RequestUser) {
if (id === requestingUser.id) {
throw new ConflictException('You cannot delete your own account');