mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(auth): wait for an unreachable server instead of signing out
A page load during a server restart read the failed session refresh as signed out and stranded the reader on /login. Boot now holds the first navigation behind a reconnect screen and retries until the server answers, and api() keeps the session when a refresh gets a 5xx or no connection.
This commit is contained in:
@@ -10,6 +10,8 @@ import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
|
|||||||
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
|
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
|
||||||
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
|
||||||
import { useAuth } from '@/features/auth/composables/useAuth'
|
import { useAuth } from '@/features/auth/composables/useAuth'
|
||||||
|
import { useSessionBootstrap } from '@/features/auth/composables/useSessionBootstrap'
|
||||||
|
import ServerReconnectScreen from '@/features/auth/components/ServerReconnectScreen.vue'
|
||||||
import TtsMiniPlayer from '@/features/tts/components/TtsMiniPlayer.vue'
|
import TtsMiniPlayer from '@/features/tts/components/TtsMiniPlayer.vue'
|
||||||
import MediaOverlayMiniPlayer from '@/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue'
|
import MediaOverlayMiniPlayer from '@/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue'
|
||||||
import PodcastMiniPlayer from '@/features/podcast/components/PodcastMiniPlayer.vue'
|
import PodcastMiniPlayer from '@/features/podcast/components/PodcastMiniPlayer.vue'
|
||||||
@@ -44,6 +46,7 @@ const chartTokens = useChartTokens()
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { user } = useAuth()
|
const { user } = useAuth()
|
||||||
|
const { unreachable: serverUnreachable } = useSessionBootstrap()
|
||||||
const { popupOpen, evaluate, syncPopup } = useWhatsNew()
|
const { popupOpen, evaluate, syncPopup } = useWhatsNew()
|
||||||
const podcastPlayer = APP_FEATURES.podcasts ? usePodcastPlayer() : null
|
const podcastPlayer = APP_FEATURES.podcasts ? usePodcastPlayer() : null
|
||||||
const podcastDownloads = APP_FEATURES.podcasts ? usePodcastDownloadBatches() : null
|
const podcastDownloads = APP_FEATURES.podcasts ? usePodcastDownloadBatches() : null
|
||||||
@@ -88,6 +91,7 @@ provide(
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<TooltipProvider :delay-duration="0">
|
<TooltipProvider :delay-duration="0">
|
||||||
|
<ServerReconnectScreen v-if="serverUnreachable" />
|
||||||
<router-view v-slot="{ Component, route: activeRoute }">
|
<router-view v-slot="{ Component, route: activeRoute }">
|
||||||
<Transition name="page" mode="out-in">
|
<Transition name="page" mode="out-in">
|
||||||
<component :is="Component" :key="resolveRouteViewKey(activeRoute)" />
|
<component :is="Component" :key="resolveRouteViewKey(activeRoute)" />
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ServerOff } from '@lucide/vue'
|
||||||
|
import { useSessionBootstrap } from '../composables/useSessionBootstrap'
|
||||||
|
import AuthAlert from './AuthAlert.vue'
|
||||||
|
import AuthPlate from './AuthPlate.vue'
|
||||||
|
import AuthSubmit from './AuthSubmit.vue'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { retryNow } = useSessionBootstrap()
|
||||||
|
|
||||||
|
const retrying = ref(false)
|
||||||
|
|
||||||
|
async function handleRetry() {
|
||||||
|
retrying.value = true
|
||||||
|
try {
|
||||||
|
await retryNow()
|
||||||
|
} finally {
|
||||||
|
retrying.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<AuthPlate :heading="t('auth.reconnect.heading')">
|
||||||
|
<form class="flex flex-col gap-4" @submit.prevent="handleRetry">
|
||||||
|
<AuthAlert tone="notice" :icon="ServerOff">{{ t('auth.reconnect.description') }}</AuthAlert>
|
||||||
|
<AuthSubmit :label="t('auth.reconnect.retry')" :loading-label="t('auth.reconnect.retrying')" :loading="retrying" />
|
||||||
|
</form>
|
||||||
|
</AuthPlate>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import ServerReconnectScreen from '../ServerReconnectScreen.vue'
|
||||||
|
|
||||||
|
const retryNowMock = vi.hoisted(() => vi.fn<() => Promise<void>>())
|
||||||
|
|
||||||
|
vi.mock('../../composables/useSessionBootstrap', () => ({
|
||||||
|
useSessionBootstrap: () => ({ retryNow: retryNowMock }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../AuthAppearanceToolbar.vue', () => ({
|
||||||
|
default: { name: 'AuthAppearanceToolbar', template: '<div />' },
|
||||||
|
}))
|
||||||
|
|
||||||
|
describe('ServerReconnectScreen', () => {
|
||||||
|
it('names the wait and announces why', () => {
|
||||||
|
const wrapper = mount(ServerReconnectScreen)
|
||||||
|
|
||||||
|
expect(wrapper.get('h1').text()).toBe('Waiting for the server')
|
||||||
|
expect(wrapper.get('[role="status"]').text()).toContain("can't reach its server")
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks the server again on demand and shows the attempt in progress', async () => {
|
||||||
|
let finish!: () => void
|
||||||
|
retryNowMock.mockReturnValueOnce(
|
||||||
|
new Promise((resolve) => {
|
||||||
|
finish = resolve
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
const wrapper = mount(ServerReconnectScreen)
|
||||||
|
|
||||||
|
await wrapper.get('form').trigger('submit')
|
||||||
|
|
||||||
|
expect(retryNowMock).toHaveBeenCalledTimes(1)
|
||||||
|
expect(wrapper.get('button').attributes('aria-busy')).toBe('true')
|
||||||
|
finish()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.get('button').attributes('aria-busy')).toBeUndefined()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -16,6 +16,9 @@ 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 } } }))
|
const currentRouteMock = vi.hoisted(() => ({ value: { query: {}, meta: {} as { public?: boolean } } }))
|
||||||
|
const initThemeSyncMock = vi.hoisted(() => vi.fn<VoidFn>())
|
||||||
|
// Stands in for the real class, which pulls in i18n; identity survives vi.resetModules().
|
||||||
|
const NetworkErrorMock = vi.hoisted(() => class NetworkError extends Error {})
|
||||||
|
|
||||||
vi.mock('@/router', () => ({
|
vi.mock('@/router', () => ({
|
||||||
default: {
|
default: {
|
||||||
@@ -27,6 +30,8 @@ vi.mock('@/router', () => ({
|
|||||||
vi.mock('@/lib/api', () => ({
|
vi.mock('@/lib/api', () => ({
|
||||||
api: apiMock,
|
api: apiMock,
|
||||||
fetchWithAuthProxyRecovery: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, { ...init, redirect: 'manual' }),
|
fetchWithAuthProxyRecovery: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, { ...init, redirect: 'manual' }),
|
||||||
|
isServerUnavailableStatus: (status: number) => status >= 500 || status === 408 || status === 429,
|
||||||
|
NetworkError: NetworkErrorMock,
|
||||||
refreshAccessToken: refreshAccessTokenMock,
|
refreshAccessToken: refreshAccessTokenMock,
|
||||||
setAccessToken: setAccessTokenMock,
|
setAccessToken: setAccessTokenMock,
|
||||||
setOnAuthFailure: setOnAuthFailureMock,
|
setOnAuthFailure: setOnAuthFailureMock,
|
||||||
@@ -34,10 +39,16 @@ vi.mock('@/lib/api', () => ({
|
|||||||
|
|
||||||
vi.mock('@/composables/useThemeSync', () => ({
|
vi.mock('@/composables/useThemeSync', () => ({
|
||||||
cancelPendingThemeSync: cancelPendingThemeSyncMock,
|
cancelPendingThemeSync: cancelPendingThemeSyncMock,
|
||||||
initThemeSync: vi.fn<VoidFn>(),
|
initThemeSync: initThemeSyncMock,
|
||||||
loadFromServer: vi.fn<() => Promise<void>>(),
|
loadFromServer: vi.fn<() => Promise<void>>(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/composables/useLocaleSync', () => ({
|
||||||
|
cancelPendingLocaleSync: vi.fn<VoidFn>(),
|
||||||
|
hydrateLocalePreference: vi.fn<() => Promise<void>>(async () => {}),
|
||||||
|
initLocaleSync: vi.fn<VoidFn>(),
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/composables/useDisplaySettingsSync', () => ({
|
vi.mock('@/composables/useDisplaySettingsSync', () => ({
|
||||||
cancelPendingDisplaySettingsSync: cancelPendingDisplaySettingsSyncMock,
|
cancelPendingDisplaySettingsSync: cancelPendingDisplaySettingsSyncMock,
|
||||||
initDisplaySettingsSync: vi.fn<VoidFn>(),
|
initDisplaySettingsSync: vi.fn<VoidFn>(),
|
||||||
@@ -90,6 +101,7 @@ describe('useAuth', () => {
|
|||||||
cancelPendingDisplaySettingsSyncMock.mockReset()
|
cancelPendingDisplaySettingsSyncMock.mockReset()
|
||||||
disconnectAuthorEnrichmentSocketMock.mockReset()
|
disconnectAuthorEnrichmentSocketMock.mockReset()
|
||||||
disconnectBookMetadataFetchSocketMock.mockReset()
|
disconnectBookMetadataFetchSocketMock.mockReset()
|
||||||
|
initThemeSyncMock.mockReset()
|
||||||
currentRouteMock.value = { query: {}, meta: {} }
|
currentRouteMock.value = { query: {}, meta: {} }
|
||||||
vi.stubGlobal(
|
vi.stubGlobal(
|
||||||
'fetch',
|
'fetch',
|
||||||
@@ -157,6 +169,43 @@ describe('useAuth', () => {
|
|||||||
expect(routerPushMock).toHaveBeenCalledWith('/login')
|
expect(routerPushMock).toHaveBeenCalledWith('/login')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('init', () => {
|
||||||
|
it('reports an unreachable server instead of an anonymous visitor', async () => {
|
||||||
|
refreshAccessTokenMock.mockRejectedValue(new NetworkErrorMock('refresh answered 500'))
|
||||||
|
const { useAuth } = await import('../useAuth')
|
||||||
|
|
||||||
|
await expect(useAuth().init()).resolves.toBe('unreachable')
|
||||||
|
expect(useAuth().user.value).toBeNull()
|
||||||
|
// A later attempt starts the syncs, so they must not start twice.
|
||||||
|
expect(initThemeSyncMock).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an unreachable server when the profile request is answered by a proxy', async () => {
|
||||||
|
refreshAccessTokenMock.mockResolvedValue('token')
|
||||||
|
apiMock.mockResolvedValue(new Response('', { status: 502 }))
|
||||||
|
const { useAuth } = await import('../useAuth')
|
||||||
|
|
||||||
|
await expect(useAuth().init()).resolves.toBe('unreachable')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an anonymous visitor when the refresh is refused', async () => {
|
||||||
|
refreshAccessTokenMock.mockRejectedValue(new Error('refresh failed'))
|
||||||
|
const { useAuth } = await import('../useAuth')
|
||||||
|
|
||||||
|
await expect(useAuth().init()).resolves.toBe('anonymous')
|
||||||
|
expect(initThemeSyncMock).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an authenticated reader once the profile loads', async () => {
|
||||||
|
refreshAccessTokenMock.mockResolvedValue('token')
|
||||||
|
apiMock.mockResolvedValue(new Response(JSON.stringify({ id: 7, settings: {} }), { status: 200 }))
|
||||||
|
const { useAuth } = await import('../useAuth')
|
||||||
|
|
||||||
|
await expect(useAuth().init()).resolves.toBe('authenticated')
|
||||||
|
expect(useAuth().user.value).toMatchObject({ id: 7 })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('stays on a public page when the session is rejected', async () => {
|
it('stays on a public page when the session is rejected', async () => {
|
||||||
currentRouteMock.value = { query: {}, meta: { public: true } }
|
currentRouteMock.value = { query: {}, meta: { public: true } }
|
||||||
await import('../useAuth')
|
await import('../useAuth')
|
||||||
|
|||||||
@@ -0,0 +1,106 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
|
||||||
|
import type { SessionOutcome } from '../useAuth'
|
||||||
|
|
||||||
|
const { initMock, fetchSetupStatusMock, setupState } = vi.hoisted(() => ({
|
||||||
|
initMock: vi.fn<() => Promise<SessionOutcome>>(),
|
||||||
|
fetchSetupStatusMock: vi.fn<() => Promise<boolean>>(),
|
||||||
|
setupState: { needsSetup: { value: null as boolean | null } },
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../useAuth', () => ({ useAuth: () => ({ init: initMock }) }))
|
||||||
|
|
||||||
|
vi.mock('../useSetupStatus', () => ({
|
||||||
|
useSetupStatus: () => ({ needsSetup: setupState.needsSetup, fetchSetupStatus: fetchSetupStatusMock }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock('../useLoginOptions', () => ({
|
||||||
|
useLoginOptions: () => ({ fetchLoginOptions: vi.fn<() => Promise<object>>(async () => ({})) }),
|
||||||
|
}))
|
||||||
|
|
||||||
|
/** Module state is the point of this composable, so every test gets a fresh copy. */
|
||||||
|
async function loadBootstrap() {
|
||||||
|
return import('../useSessionBootstrap')
|
||||||
|
}
|
||||||
|
|
||||||
|
function track(promise: Promise<void>) {
|
||||||
|
const state = { settled: false }
|
||||||
|
void promise.then(() => {
|
||||||
|
state.settled = true
|
||||||
|
})
|
||||||
|
return state
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.resetModules()
|
||||||
|
// setImmediate stays real so flushPromises can drain the retry chain between timer steps.
|
||||||
|
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
|
||||||
|
initMock.mockReset()
|
||||||
|
fetchSetupStatusMock.mockReset().mockResolvedValue(false)
|
||||||
|
setupState.needsSetup.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('bootstrapSession', () => {
|
||||||
|
it('lets the router through at once when the server answers', async () => {
|
||||||
|
initMock.mockResolvedValue('anonymous')
|
||||||
|
const boot = await loadBootstrap()
|
||||||
|
|
||||||
|
await boot.bootstrapSession()
|
||||||
|
|
||||||
|
expect(boot.useSessionBootstrap().unreachable.value).toBe(false)
|
||||||
|
await expect(boot.whenSessionResolved()).resolves.toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('skips the session check while the server still needs setup', async () => {
|
||||||
|
setupState.needsSetup.value = true
|
||||||
|
const boot = await loadBootstrap()
|
||||||
|
|
||||||
|
await boot.bootstrapSession()
|
||||||
|
|
||||||
|
expect(initMock).not.toHaveBeenCalled()
|
||||||
|
expect(boot.useSessionBootstrap().unreachable.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('holds the router and keeps asking, backing off, while the server is unreachable', async () => {
|
||||||
|
initMock.mockResolvedValueOnce('unreachable').mockResolvedValueOnce('unreachable').mockResolvedValue('authenticated')
|
||||||
|
const boot = await loadBootstrap()
|
||||||
|
|
||||||
|
await boot.bootstrapSession()
|
||||||
|
const resolved = track(boot.whenSessionResolved())
|
||||||
|
expect(boot.useSessionBootstrap().unreachable.value).toBe(true)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1_000)
|
||||||
|
await flushPromises()
|
||||||
|
expect(initMock).toHaveBeenCalledTimes(2)
|
||||||
|
expect(resolved.settled).toBe(false)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1_999)
|
||||||
|
await flushPromises()
|
||||||
|
expect(initMock).toHaveBeenCalledTimes(2)
|
||||||
|
|
||||||
|
vi.advanceTimersByTime(1)
|
||||||
|
await flushPromises()
|
||||||
|
expect(initMock).toHaveBeenCalledTimes(3)
|
||||||
|
expect(resolved.settled).toBe(true)
|
||||||
|
expect(boot.useSessionBootstrap().unreachable.value).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('asks again at once on request and drops the scheduled attempt', async () => {
|
||||||
|
initMock.mockResolvedValueOnce('unreachable').mockResolvedValue('anonymous')
|
||||||
|
const boot = await loadBootstrap()
|
||||||
|
await boot.bootstrapSession()
|
||||||
|
|
||||||
|
await boot.useSessionBootstrap().retryNow()
|
||||||
|
|
||||||
|
expect(initMock).toHaveBeenCalledTimes(2)
|
||||||
|
await expect(boot.whenSessionResolved()).resolves.toBeUndefined()
|
||||||
|
vi.advanceTimersByTime(30_000)
|
||||||
|
await flushPromises()
|
||||||
|
expect(initMock).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,6 +1,14 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import type { AuthUser, AuthResponse } from '@bookorbit/types'
|
import type { AuthUser, AuthResponse } from '@bookorbit/types'
|
||||||
import { api, fetchWithAuthProxyRecovery, refreshAccessToken, setAccessToken, setOnAuthFailure } from '@/lib/api'
|
import {
|
||||||
|
api,
|
||||||
|
fetchWithAuthProxyRecovery,
|
||||||
|
isServerUnavailableStatus,
|
||||||
|
NetworkError,
|
||||||
|
refreshAccessToken,
|
||||||
|
setAccessToken,
|
||||||
|
setOnAuthFailure,
|
||||||
|
} from '@/lib/api'
|
||||||
import router from '@/router'
|
import router from '@/router'
|
||||||
import { cancelPendingDisplaySettingsSync, initDisplaySettingsSync, loadDisplaySettingsFromServer } from '@/composables/useDisplaySettingsSync'
|
import { cancelPendingDisplaySettingsSync, initDisplaySettingsSync, loadDisplaySettingsFromServer } from '@/composables/useDisplaySettingsSync'
|
||||||
import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composables/useThemeSync'
|
import { cancelPendingThemeSync, initThemeSync, loadFromServer } from '@/composables/useThemeSync'
|
||||||
@@ -18,6 +26,9 @@ import { clearReleaseSearchCache } from '@/features/book-requests/composables/us
|
|||||||
|
|
||||||
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
const SESSION_REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||||
|
|
||||||
|
/** `unreachable` is no answer about the session at all, which is not the same as having none. */
|
||||||
|
export type SessionOutcome = 'authenticated' | 'anonymous' | 'unreachable'
|
||||||
|
|
||||||
const user = ref<AuthUser | null>(null)
|
const user = ref<AuthUser | null>(null)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
let sessionRefreshTimer: number | null = null
|
let sessionRefreshTimer: number | null = null
|
||||||
@@ -81,7 +92,7 @@ setOnAuthFailure(() => {
|
|||||||
|
|
||||||
async function me(): Promise<void> {
|
async function me(): Promise<void> {
|
||||||
const res = await api('/api/v1/auth/me')
|
const res = await api('/api/v1/auth/me')
|
||||||
if (!res.ok) throw new Error('Failed to load user')
|
if (!res.ok) throw isServerUnavailableStatus(res.status) ? new NetworkError(`me answered ${res.status}`) : new Error('Failed to load user')
|
||||||
user.value = await res.json()
|
user.value = await res.json()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,21 +132,26 @@ export class LoginError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function useAuth() {
|
export function useAuth() {
|
||||||
async function init(): Promise<void> {
|
async function init(): Promise<SessionOutcome> {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
let outcome: SessionOutcome = 'authenticated'
|
||||||
try {
|
try {
|
||||||
await refreshAccessToken()
|
await refreshAccessToken()
|
||||||
await me()
|
await me()
|
||||||
await hydratePreferences()
|
await hydratePreferences()
|
||||||
startSessionRefresh()
|
startSessionRefresh()
|
||||||
} catch {
|
} catch (reason) {
|
||||||
// no valid session
|
if (!user.value) outcome = reason instanceof NetworkError ? 'unreachable' : 'anonymous'
|
||||||
} finally {
|
} finally {
|
||||||
initThemeSync()
|
// An unreachable server is asked again, so the syncs start once, with whichever answer comes back.
|
||||||
initDisplaySettingsSync()
|
if (outcome !== 'unreachable') {
|
||||||
initLocaleSync()
|
initThemeSync()
|
||||||
|
initDisplaySettingsSync()
|
||||||
|
initLocaleSync()
|
||||||
|
}
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
}
|
}
|
||||||
|
return outcome
|
||||||
}
|
}
|
||||||
|
|
||||||
async function login(username: string, password: string): Promise<void> {
|
async function login(username: string, password: string): Promise<void> {
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { readonly, ref } from 'vue'
|
||||||
|
import { useAuth } from './useAuth'
|
||||||
|
import { useLoginOptions } from './useLoginOptions'
|
||||||
|
import { useSetupStatus } from './useSetupStatus'
|
||||||
|
|
||||||
|
const RETRY_BASE_DELAY_MS = 1_000
|
||||||
|
const RETRY_MAX_DELAY_MS = 10_000
|
||||||
|
|
||||||
|
const unreachable = ref(false)
|
||||||
|
let checking = false
|
||||||
|
let resolved: Promise<void> = Promise.resolve()
|
||||||
|
let markResolved: (() => void) | null = null
|
||||||
|
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let failedRetries = 0
|
||||||
|
|
||||||
|
/** True once the server has answered, whatever the answer was. */
|
||||||
|
async function askServer(): Promise<boolean> {
|
||||||
|
const { fetchSetupStatus, needsSetup } = useSetupStatus()
|
||||||
|
try {
|
||||||
|
await Promise.all([fetchSetupStatus(), useLoginOptions().fetchLoginOptions()])
|
||||||
|
} catch {
|
||||||
|
// The session check below decides whether the server is there at all.
|
||||||
|
}
|
||||||
|
if (needsSetup.value === true) return true
|
||||||
|
return (await useAuth().init()) !== 'unreachable'
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleRetry(): void {
|
||||||
|
const delay = Math.min(RETRY_BASE_DELAY_MS * 2 ** failedRetries, RETRY_MAX_DELAY_MS)
|
||||||
|
failedRetries += 1
|
||||||
|
retryTimer = setTimeout(() => void retryNow(), delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retryNow(): Promise<void> {
|
||||||
|
if (!unreachable.value || checking) return
|
||||||
|
if (retryTimer !== null) {
|
||||||
|
clearTimeout(retryTimer)
|
||||||
|
retryTimer = null
|
||||||
|
}
|
||||||
|
checking = true
|
||||||
|
const answered = await askServer().catch(() => false)
|
||||||
|
checking = false
|
||||||
|
if (!answered) {
|
||||||
|
scheduleRetry()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unreachable.value = false
|
||||||
|
markResolved?.()
|
||||||
|
markResolved = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settles setup status and the session before the router's first decision. When the server cannot be
|
||||||
|
* reached (a restart, a dropped connection) the session is unknown rather than absent, so this keeps
|
||||||
|
* asking in the background and the auth guard waits, instead of sending a signed-in reader to sign in.
|
||||||
|
*/
|
||||||
|
export async function bootstrapSession(): Promise<void> {
|
||||||
|
if (await askServer()) return
|
||||||
|
unreachable.value = true
|
||||||
|
resolved = new Promise((resolve) => {
|
||||||
|
markResolved = resolve
|
||||||
|
})
|
||||||
|
scheduleRetry()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Settles once the server has said whether there is a session. */
|
||||||
|
export function whenSessionResolved(): Promise<void> {
|
||||||
|
return resolved
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSessionBootstrap() {
|
||||||
|
return { unreachable: readonly(unreachable), retryNow }
|
||||||
|
}
|
||||||
@@ -108,6 +108,53 @@ describe('api wrapper', () => {
|
|||||||
expect(onFail).toHaveBeenCalledTimes(1)
|
expect(onFail).toHaveBeenCalledTimes(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
describe('server unavailable during a refresh', () => {
|
||||||
|
it.each([500, 502, 503, 504, 429])('keeps the session when the refresh after a 401 is answered with %i', async (status) => {
|
||||||
|
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) =>
|
||||||
|
Promise.resolve(new Response('', { status: urlOf(input).endsWith('/api/v1/auth/refresh') ? status : 401 })),
|
||||||
|
) as never
|
||||||
|
const onFail = vi.fn<() => void>()
|
||||||
|
setOnAuthFailure(onFail)
|
||||||
|
|
||||||
|
await expect(api('/api/v1/books/1')).rejects.toBeInstanceOf(NetworkError)
|
||||||
|
expect(onFail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the session when the refresh after a 401 cannot connect at all', async () => {
|
||||||
|
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) =>
|
||||||
|
urlOf(input).endsWith('/api/v1/auth/refresh')
|
||||||
|
? Promise.reject(new TypeError('Failed to fetch'))
|
||||||
|
: Promise.resolve(new Response('', { status: 401 })),
|
||||||
|
) as never
|
||||||
|
const onFail = vi.fn<() => void>()
|
||||||
|
setOnAuthFailure(onFail)
|
||||||
|
|
||||||
|
await expect(api('/api/v1/books/1')).rejects.toBeInstanceOf(NetworkError)
|
||||||
|
expect(onFail).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('still ends the session when the refresh is refused', async () => {
|
||||||
|
globalThis.fetch = vi.fn<typeof fetch>((input: RequestInfo | URL) =>
|
||||||
|
Promise.resolve(new Response('', { status: urlOf(input).endsWith('/api/v1/auth/refresh') ? 403 : 401 })),
|
||||||
|
) as never
|
||||||
|
const onFail = vi.fn<() => void>()
|
||||||
|
setOnAuthFailure(onFail)
|
||||||
|
|
||||||
|
await expect(api('/api/v1/books/1')).rejects.toThrow('Session expired')
|
||||||
|
expect(onFail).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports an unavailable refresh as a NetworkError and a refused one as a plain failure', async () => {
|
||||||
|
globalThis.fetch = vi.fn<typeof fetch>(() => Promise.resolve(new Response('', { status: 502 }))) as never
|
||||||
|
await expect(refreshAccessToken()).rejects.toBeInstanceOf(NetworkError)
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn<typeof fetch>(() => Promise.resolve(new Response('', { status: 401 }))) as never
|
||||||
|
const refused = await refreshAccessToken().catch((reason: unknown) => reason)
|
||||||
|
expect(refused).toBeInstanceOf(Error)
|
||||||
|
expect(refused).not.toBeInstanceOf(NetworkError)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
describe('expiry awareness', () => {
|
describe('expiry awareness', () => {
|
||||||
it('renews an expired token before the request, so the server never sees it', async () => {
|
it('renews an expired token before the request, so the server never sees it', async () => {
|
||||||
const calls: { url: string; auth: string | null }[] = []
|
const calls: { url: string; auth: string | null }[] = []
|
||||||
|
|||||||
+14
-2
@@ -101,6 +101,7 @@ export function setOnAuthFailure(fn: () => void): void {
|
|||||||
* `fetch` rejects these with a `TypeError` whose message is the browser's own untranslated English,
|
* `fetch` rejects these with a `TypeError` whose message is the browser's own untranslated English,
|
||||||
* "Failed to fetch", and plenty of call sites render `reason.message` straight into a toast. The
|
* "Failed to fetch", and plenty of call sites render `reason.message` straight into a toast. The
|
||||||
* rejection is retagged here, at the one place every request passes through, so no caller can leak it.
|
* rejection is retagged here, at the one place every request passes through, so no caller can leak it.
|
||||||
|
* Session checks also raise it for a status that only means the server could not answer.
|
||||||
*/
|
*/
|
||||||
export class NetworkError extends Error {
|
export class NetworkError extends Error {
|
||||||
/** The browser's own wording. Useful in a log line, never in the interface. */
|
/** The browser's own wording. Useful in a log line, never in the interface. */
|
||||||
@@ -113,6 +114,15 @@ export class NetworkError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A status that says nothing about the session: the server is restarting, or a proxy with nothing
|
||||||
|
* behind it answered instead (the Vite dev proxy reports a refused connection as a bare 500).
|
||||||
|
* Reading one of these as "signed out" strands a signed-in reader on the login page.
|
||||||
|
*/
|
||||||
|
export function isServerUnavailableStatus(status: number): boolean {
|
||||||
|
return status >= 500 || status === 408 || status === 429
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* True when a same-origin request came back redirected elsewhere. With `redirect: 'manual'` this is
|
* True when a same-origin request came back redirected elsewhere. With `redirect: 'manual'` this is
|
||||||
* how an edge auth proxy (Cloudflare Access, an OAuth2 gateway) shows up once its session cookie has
|
* how an edge auth proxy (Cloudflare Access, an OAuth2 gateway) shows up once its session cookie has
|
||||||
@@ -166,7 +176,7 @@ async function rawFetch(input: RequestInfo | URL, init?: RequestInit): Promise<R
|
|||||||
|
|
||||||
async function attemptRefresh(): Promise<string> {
|
async function attemptRefresh(): Promise<string> {
|
||||||
const res = await rawFetch('/api/v1/auth/refresh', { method: 'POST' })
|
const res = await rawFetch('/api/v1/auth/refresh', { method: 'POST' })
|
||||||
if (!res.ok) throw new Error('refresh failed')
|
if (!res.ok) throw isServerUnavailableStatus(res.status) ? new NetworkError(`refresh answered ${res.status}`) : new Error('refresh failed')
|
||||||
const data: RefreshResponse = await res.json()
|
const data: RefreshResponse = await res.json()
|
||||||
const recovered = _sawAuthFailure
|
const recovered = _sawAuthFailure
|
||||||
setAccessToken(data.accessToken)
|
setAccessToken(data.accessToken)
|
||||||
@@ -201,7 +211,9 @@ export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRe
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await refreshAccessToken()
|
await refreshAccessToken()
|
||||||
} catch {
|
} catch (reason) {
|
||||||
|
// The refresh never got an answer, so the session may well be intact. Keep it.
|
||||||
|
if (reason instanceof NetworkError) throw reason
|
||||||
_onAuthFailure?.()
|
_onAuthFailure?.()
|
||||||
throw new Error('Session expired')
|
throw new Error('Session expired')
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5714,6 +5714,12 @@
|
|||||||
"errors": {
|
"errors": {
|
||||||
"noToken": "No token provided"
|
"noToken": "No token provided"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"reconnect": {
|
||||||
|
"heading": "Waiting for the server",
|
||||||
|
"description": "BookOrbit can't reach its server right now. This page continues on its own as soon as the server answers.",
|
||||||
|
"retry": "Try again now",
|
||||||
|
"retrying": "Trying again..."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"author": {
|
"author": {
|
||||||
|
|||||||
+2
-14
@@ -8,9 +8,7 @@ import App from './App.vue'
|
|||||||
import router from './router'
|
import router from './router'
|
||||||
import { i18n } from './i18n'
|
import { i18n } from './i18n'
|
||||||
import { useLocaleStore } from './stores/locale'
|
import { useLocaleStore } from './stores/locale'
|
||||||
import { useAuth } from './features/auth/composables/useAuth'
|
import { bootstrapSession } from './features/auth/composables/useSessionBootstrap'
|
||||||
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
|
|
||||||
import { useLoginOptions } from './features/auth/composables/useLoginOptions'
|
|
||||||
|
|
||||||
// Chrome 124+ blocks aria-hidden from being applied to an element that contains
|
// Chrome 124+ blocks aria-hidden from being applied to an element that contains
|
||||||
// a focused descendant. Reka UI's dialog uses the aria-hidden package which sets
|
// a focused descendant. Reka UI's dialog uses the aria-hidden package which sets
|
||||||
@@ -48,17 +46,7 @@ await localeStore.setLocale(localeStore.locale)
|
|||||||
|
|
||||||
// Resolve setup status/auth before installing router.
|
// Resolve setup status/auth before installing router.
|
||||||
// app.use(router) triggers initial navigation and guard execution.
|
// app.use(router) triggers initial navigation and guard execution.
|
||||||
const { fetchSetupStatus, needsSetup } = useSetupStatus()
|
await bootstrapSession()
|
||||||
try {
|
|
||||||
await Promise.all([fetchSetupStatus(), useLoginOptions().fetchLoginOptions()])
|
|
||||||
} catch {
|
|
||||||
// If setup-status check fails, continue with normal auth bootstrap.
|
|
||||||
}
|
|
||||||
|
|
||||||
const { init } = useAuth()
|
|
||||||
if (needsSetup.value !== true) {
|
|
||||||
await init()
|
|
||||||
}
|
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.mount('#app')
|
app.mount('#app')
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ import { createMemoryHistory, createRouter } from 'vue-router'
|
|||||||
|
|
||||||
import { registerAuthGuard } from '../guards/auth.guard'
|
import { registerAuthGuard } from '../guards/auth.guard'
|
||||||
|
|
||||||
const { authState, statusState, openChangePasswordMock } = vi.hoisted(() => ({
|
const { authState, statusState, sessionGate, openChangePasswordMock } = vi.hoisted(() => ({
|
||||||
authState: { user: null as Record<string, unknown> | null },
|
authState: { user: null as Record<string, unknown> | null },
|
||||||
statusState: { needsSetup: false, allowRegistration: false, passwordLoginEnabled: true },
|
statusState: { needsSetup: false, allowRegistration: false, passwordLoginEnabled: true },
|
||||||
|
sessionGate: { resolved: Promise.resolve() },
|
||||||
openChangePasswordMock: vi.fn<(force: boolean) => void>(),
|
openChangePasswordMock: vi.fn<(force: boolean) => void>(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@/features/auth/composables/useSessionBootstrap', () => ({
|
||||||
|
whenSessionResolved: () => sessionGate.resolved,
|
||||||
|
}))
|
||||||
|
|
||||||
vi.mock('@/features/auth/composables/useAuth', () => ({
|
vi.mock('@/features/auth/composables/useAuth', () => ({
|
||||||
useAuth: () => ({ user: ref(authState.user) }),
|
useAuth: () => ({ user: ref(authState.user) }),
|
||||||
}))
|
}))
|
||||||
@@ -67,6 +72,27 @@ beforeEach(() => {
|
|||||||
statusState.needsSetup = false
|
statusState.needsSetup = false
|
||||||
statusState.allowRegistration = false
|
statusState.allowRegistration = false
|
||||||
statusState.passwordLoginEnabled = true
|
statusState.passwordLoginEnabled = true
|
||||||
|
sessionGate.resolved = Promise.resolve()
|
||||||
|
})
|
||||||
|
|
||||||
|
describe('auth guard: unreachable server at boot', () => {
|
||||||
|
it('waits for the session instead of reading "no answer yet" as signed out', async () => {
|
||||||
|
let release!: () => void
|
||||||
|
sessionGate.resolved = new Promise((resolve) => {
|
||||||
|
release = resolve
|
||||||
|
})
|
||||||
|
const router = makeRouter()
|
||||||
|
|
||||||
|
const navigation = router.push('/requests')
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||||
|
expect(router.currentRoute.value.name).toBeUndefined()
|
||||||
|
|
||||||
|
authState.user = { id: 1, isDefaultPassword: false, permissions: [], settings: {} }
|
||||||
|
release()
|
||||||
|
await navigation
|
||||||
|
|
||||||
|
expect(router.currentRoute.value.name).toBe('book-requests')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('auth guard: hidden book requests', () => {
|
describe('auth guard: hidden book requests', () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useAuth } from '@/features/auth/composables/useAuth'
|
|||||||
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
|
||||||
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
|
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
|
||||||
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
import { useLoginOptions } from '@/features/auth/composables/useLoginOptions'
|
||||||
|
import { whenSessionResolved } from '@/features/auth/composables/useSessionBootstrap'
|
||||||
|
|
||||||
const PASSWORD_ONLY_PUBLIC_PATHS = new Set(['/forgot-password', '/reset-password'])
|
const PASSWORD_ONLY_PUBLIC_PATHS = new Set(['/forgot-password', '/reset-password'])
|
||||||
|
|
||||||
@@ -12,6 +13,8 @@ function permissionFallback(name: string | undefined) {
|
|||||||
|
|
||||||
export function registerAuthGuard(router: Router): void {
|
export function registerAuthGuard(router: Router): void {
|
||||||
router.beforeEach(async (to) => {
|
router.beforeEach(async (to) => {
|
||||||
|
// No user while the server is unreachable means "not known yet", not "signed out".
|
||||||
|
await whenSessionResolved()
|
||||||
const { fetchSetupStatus } = useSetupStatus()
|
const { fetchSetupStatus } = useSetupStatus()
|
||||||
const { fetchLoginOptions, loginOptions } = useLoginOptions()
|
const { fetchLoginOptions, loginOptions } = useLoginOptions()
|
||||||
let requiresSetup = false
|
let requiresSetup = false
|
||||||
|
|||||||
Reference in New Issue
Block a user