fix(auth): keep signed-out reset links on their page

Load read-aloud voices only once playback starts, so the app-wide player
no longer fires a 401 on public pages. Let the session-failure handler
clear auth without leaving a public route, which also keeps magic links
and the sign-in redirect intact.

Fixes #1503
This commit is contained in:
neon
2026-09-23 13:02:00 +00:00
parent 3c032e0e3c
commit 1dd29c742f
4 changed files with 112 additions and 3 deletions
@@ -15,11 +15,12 @@ const cancelPendingThemeSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
const cancelPendingDisplaySettingsSyncMock = vi.hoisted(() => vi.fn<VoidFn>()) const cancelPendingDisplaySettingsSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
const disconnectAuthorEnrichmentSocketMock = vi.hoisted(() => vi.fn<VoidFn>()) const disconnectAuthorEnrichmentSocketMock = vi.hoisted(() => vi.fn<VoidFn>())
const disconnectBookMetadataFetchSocketMock = vi.hoisted(() => vi.fn<VoidFn>()) const disconnectBookMetadataFetchSocketMock = vi.hoisted(() => vi.fn<VoidFn>())
const currentRouteMock = vi.hoisted(() => ({ value: { query: {}, meta: {} as { public?: boolean } } }))
vi.mock('@/router', () => ({ vi.mock('@/router', () => ({
default: { default: {
push: routerPushMock, push: routerPushMock,
currentRoute: { value: { query: {} } }, currentRoute: currentRouteMock,
}, },
})) }))
@@ -88,6 +89,7 @@ describe('useAuth', () => {
cancelPendingDisplaySettingsSyncMock.mockReset() cancelPendingDisplaySettingsSyncMock.mockReset()
disconnectAuthorEnrichmentSocketMock.mockReset() disconnectAuthorEnrichmentSocketMock.mockReset()
disconnectBookMetadataFetchSocketMock.mockReset() disconnectBookMetadataFetchSocketMock.mockReset()
currentRouteMock.value = { query: {}, meta: {} }
vi.stubGlobal( vi.stubGlobal(
'fetch', 'fetch',
vi.fn<typeof fetch>().mockResolvedValue({ vi.fn<typeof fetch>().mockResolvedValue({
@@ -143,4 +145,25 @@ describe('useAuth', () => {
expect(readBody).not.toHaveBeenCalled() expect(readBody).not.toHaveBeenCalled()
expect(routerPushMock).toHaveBeenCalledWith('/login') expect(routerPushMock).toHaveBeenCalledWith('/login')
}) })
it('sends a protected page to sign-in when the session is rejected', async () => {
await import('../useAuth')
const onAuthFailure = setOnAuthFailureMock.mock.calls[0]![0]
onAuthFailure()
expect(setAccessTokenMock).toHaveBeenCalledWith(null)
expect(routerPushMock).toHaveBeenCalledWith('/login')
})
it('stays on a public page when the session is rejected', async () => {
currentRouteMock.value = { query: {}, meta: { public: true } }
await import('../useAuth')
const onAuthFailure = setOnAuthFailureMock.mock.calls[0]![0]
onAuthFailure()
expect(setAccessTokenMock).toHaveBeenCalledWith(null)
expect(routerPushMock).not.toHaveBeenCalled()
})
}) })
@@ -70,6 +70,9 @@ function clearAuth() {
setOnAuthFailure(() => { setOnAuthFailure(() => {
clearAuth() clearAuth()
// Public pages are reached without a session, so a rejected request there is not a reason to
// leave. Moving on would strand a reset or magic link, or drop the sign-in redirect.
if (router.currentRoute.value.meta.public) return
const { needsSetup } = useSetupStatus() const { needsSetup } = useSetupStatus()
router.push(needsSetup.value ? '/setup' : '/login') router.push(needsSetup.value ? '/setup' : '/login')
}) })
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { enableAutoUnmount, flushPromises, mount } from '@vue/test-utils'
import type { TtsVoice } from '@bookorbit/types'
import TtsMiniPlayer from './TtsMiniPlayer.vue'
import * as ttsApi from '../api/tts.api'
import { useTtsPlayer } from '../composables/useTtsPlayer'
import { useTtsVoices } from '../composables/useTtsVoices'
vi.mock('../composables/useTtsPlayer', async () => {
const { ref } = await import('vue')
const state = {
playbackState: ref('idle'),
currentBook: ref(null),
currentBlockIndex: ref(0),
currentChapterIndex: ref(0),
speed: ref(1),
currentProviderId: ref(null),
currentVoiceId: ref(null),
togglePlayPause: () => {},
}
return { useTtsPlayer: () => state }
})
const HEART: TtsVoice = {
id: 'af_heart',
name: 'Heart',
shortName: 'af_heart',
language: 'English',
locale: 'en-US',
gender: 'Female',
providerId: '1',
providerName: 'Kokoro',
}
function mountPlayer() {
return mount(TtsMiniPlayer, { global: { stubs: { TtsMiniPlayerExpanded: true } } })
}
enableAutoUnmount(afterEach)
describe('TtsMiniPlayer', () => {
beforeEach(() => {
vi.restoreAllMocks()
useTtsPlayer().playbackState.value = 'idle'
useTtsVoices().allVoices.value = []
})
// The player is mounted app-wide, so a voices request while idle fires on the signed-out reset,
// magic-link and sign-in pages, where its 401 used to send the page to /login (issue 1503).
it('does not request voices while nothing is playing', async () => {
const getVoices = vi.spyOn(ttsApi, 'getVoices').mockResolvedValue([])
mountPlayer()
await flushPromises()
expect(getVoices).not.toHaveBeenCalled()
})
it('loads voices once playback starts', async () => {
const getVoices = vi.spyOn(ttsApi, 'getVoices').mockResolvedValue([HEART])
mountPlayer()
await flushPromises()
useTtsPlayer().playbackState.value = 'playing'
await flushPromises()
expect(getVoices).toHaveBeenCalledTimes(1)
expect(useTtsVoices().allVoices.value).toEqual([HEART])
})
it('keeps the voices it already has when playback starts again', async () => {
useTtsVoices().allVoices.value = [HEART]
const getVoices = vi.spyOn(ttsApi, 'getVoices').mockResolvedValue([])
mountPlayer()
useTtsPlayer().playbackState.value = 'playing'
await flushPromises()
expect(getVoices).not.toHaveBeenCalled()
})
})
@@ -133,6 +133,10 @@ watch(
return return
} }
// Not on mount: this player is mounted on signed-out pages too, where a 401 would send a reset or
// magic link to sign-in. Playback implies a session.
if (allVoices.value.length === 0) void loadVoices()
if (mode.value !== 'expanded' && !compactModeCustomized.value) { if (mode.value !== 'expanded' && !compactModeCustomized.value) {
setMode(defaultCompactMode.value) setMode(defaultCompactMode.value)
} }
@@ -150,8 +154,6 @@ watch(
) )
onMounted(() => { onMounted(() => {
if (allVoices.value.length === 0) void loadVoices()
updateIsMobileViewport() updateIsMobileViewport()
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {