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 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') {
|
||||||
|
|||||||
Reference in New Issue
Block a user