mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
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:
@@ -15,11 +15,12 @@ const cancelPendingThemeSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
|
||||
const cancelPendingDisplaySettingsSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
|
||||
const disconnectAuthorEnrichmentSocketMock = 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', () => ({
|
||||
default: {
|
||||
push: routerPushMock,
|
||||
currentRoute: { value: { query: {} } },
|
||||
currentRoute: currentRouteMock,
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -88,6 +89,7 @@ describe('useAuth', () => {
|
||||
cancelPendingDisplaySettingsSyncMock.mockReset()
|
||||
disconnectAuthorEnrichmentSocketMock.mockReset()
|
||||
disconnectBookMetadataFetchSocketMock.mockReset()
|
||||
currentRouteMock.value = { query: {}, meta: {} }
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn<typeof fetch>().mockResolvedValue({
|
||||
@@ -143,4 +145,25 @@ describe('useAuth', () => {
|
||||
expect(readBody).not.toHaveBeenCalled()
|
||||
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(() => {
|
||||
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()
|
||||
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
|
||||
}
|
||||
|
||||
// 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) {
|
||||
setMode(defaultCompactMode.value)
|
||||
}
|
||||
@@ -150,8 +154,6 @@ watch(
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
if (allVoices.value.length === 0) void loadVoices()
|
||||
|
||||
updateIsMobileViewport()
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
|
||||
Reference in New Issue
Block a user