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:
neon
2026-10-18 18:07:08 +00:00
parent ce118e3890
commit 7202a59afc
13 changed files with 429 additions and 26 deletions
+4
View File
@@ -10,6 +10,8 @@ import ChangePasswordDialog from '@/features/auth/ChangePasswordDialog.vue'
import WhatsNewDialog from '@/features/whats-new/WhatsNewDialog.vue'
import { useWhatsNew } from '@/features/whats-new/composables/useWhatsNew'
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 MediaOverlayMiniPlayer from '@/features/reader/media-overlay/components/MediaOverlayMiniPlayer.vue'
import PodcastMiniPlayer from '@/features/podcast/components/PodcastMiniPlayer.vue'
@@ -44,6 +46,7 @@ const chartTokens = useChartTokens()
const route = useRoute()
const { user } = useAuth()
const { unreachable: serverUnreachable } = useSessionBootstrap()
const { popupOpen, evaluate, syncPopup } = useWhatsNew()
const podcastPlayer = APP_FEATURES.podcasts ? usePodcastPlayer() : null
const podcastDownloads = APP_FEATURES.podcasts ? usePodcastDownloadBatches() : null
@@ -88,6 +91,7 @@ provide(
<template>
<TooltipProvider :delay-duration="0">
<ServerReconnectScreen v-if="serverUnreachable" />
<router-view v-slot="{ Component, route: activeRoute }">
<Transition name="page" mode="out-in">
<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 disconnectBookMetadataFetchSocketMock = vi.hoisted(() => vi.fn<VoidFn>())
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', () => ({
default: {
@@ -27,6 +30,8 @@ vi.mock('@/router', () => ({
vi.mock('@/lib/api', () => ({
api: apiMock,
fetchWithAuthProxyRecovery: (input: RequestInfo | URL, init?: RequestInit) => fetch(input, { ...init, redirect: 'manual' }),
isServerUnavailableStatus: (status: number) => status >= 500 || status === 408 || status === 429,
NetworkError: NetworkErrorMock,
refreshAccessToken: refreshAccessTokenMock,
setAccessToken: setAccessTokenMock,
setOnAuthFailure: setOnAuthFailureMock,
@@ -34,10 +39,16 @@ vi.mock('@/lib/api', () => ({
vi.mock('@/composables/useThemeSync', () => ({
cancelPendingThemeSync: cancelPendingThemeSyncMock,
initThemeSync: vi.fn<VoidFn>(),
initThemeSync: initThemeSyncMock,
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', () => ({
cancelPendingDisplaySettingsSync: cancelPendingDisplaySettingsSyncMock,
initDisplaySettingsSync: vi.fn<VoidFn>(),
@@ -90,6 +101,7 @@ describe('useAuth', () => {
cancelPendingDisplaySettingsSyncMock.mockReset()
disconnectAuthorEnrichmentSocketMock.mockReset()
disconnectBookMetadataFetchSocketMock.mockReset()
initThemeSyncMock.mockReset()
currentRouteMock.value = { query: {}, meta: {} }
vi.stubGlobal(
'fetch',
@@ -157,6 +169,43 @@ describe('useAuth', () => {
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 () => {
currentRouteMock.value = { query: {}, meta: { public: true } }
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 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 { cancelPendingDisplaySettingsSync, initDisplaySettingsSync, loadDisplaySettingsFromServer } from '@/composables/useDisplaySettingsSync'
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
/** `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 isLoading = ref(false)
let sessionRefreshTimer: number | null = null
@@ -81,7 +92,7 @@ setOnAuthFailure(() => {
async function me(): Promise<void> {
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()
}
@@ -121,21 +132,26 @@ export class LoginError extends Error {
}
export function useAuth() {
async function init(): Promise<void> {
async function init(): Promise<SessionOutcome> {
isLoading.value = true
let outcome: SessionOutcome = 'authenticated'
try {
await refreshAccessToken()
await me()
await hydratePreferences()
startSessionRefresh()
} catch {
// no valid session
} catch (reason) {
if (!user.value) outcome = reason instanceof NetworkError ? 'unreachable' : 'anonymous'
} finally {
// An unreachable server is asked again, so the syncs start once, with whichever answer comes back.
if (outcome !== 'unreachable') {
initThemeSync()
initDisplaySettingsSync()
initLocaleSync()
}
isLoading.value = false
}
return outcome
}
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 }
}
+47
View File
@@ -108,6 +108,53 @@ describe('api wrapper', () => {
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', () => {
it('renews an expired token before the request, so the server never sees it', async () => {
const calls: { url: string; auth: string | null }[] = []
+14 -2
View File
@@ -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,
* "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.
* Session checks also raise it for a status that only means the server could not answer.
*/
export class NetworkError extends Error {
/** 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
* 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> {
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 recovered = _sawAuthFailure
setAccessToken(data.accessToken)
@@ -201,7 +211,9 @@ export async function api(input: RequestInfo | URL, init?: RequestInit & { _isRe
try {
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?.()
throw new Error('Session expired')
}
+6
View File
@@ -5714,6 +5714,12 @@
"errors": {
"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": {
+2 -14
View File
@@ -8,9 +8,7 @@ import App from './App.vue'
import router from './router'
import { i18n } from './i18n'
import { useLocaleStore } from './stores/locale'
import { useAuth } from './features/auth/composables/useAuth'
import { useSetupStatus } from './features/auth/composables/useSetupStatus'
import { useLoginOptions } from './features/auth/composables/useLoginOptions'
import { bootstrapSession } from './features/auth/composables/useSessionBootstrap'
// 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
@@ -48,17 +46,7 @@ await localeStore.setLocale(localeStore.locale)
// Resolve setup status/auth before installing router.
// app.use(router) triggers initial navigation and guard execution.
const { fetchSetupStatus, needsSetup } = useSetupStatus()
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()
}
await bootstrapSession()
app.use(router)
app.mount('#app')
+27 -1
View File
@@ -4,12 +4,17 @@ import { createMemoryHistory, createRouter } from 'vue-router'
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 },
statusState: { needsSetup: false, allowRegistration: false, passwordLoginEnabled: true },
sessionGate: { resolved: Promise.resolve() },
openChangePasswordMock: vi.fn<(force: boolean) => void>(),
}))
vi.mock('@/features/auth/composables/useSessionBootstrap', () => ({
whenSessionResolved: () => sessionGate.resolved,
}))
vi.mock('@/features/auth/composables/useAuth', () => ({
useAuth: () => ({ user: ref(authState.user) }),
}))
@@ -67,6 +72,27 @@ beforeEach(() => {
statusState.needsSetup = false
statusState.allowRegistration = false
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', () => {
+3
View File
@@ -3,6 +3,7 @@ import { useAuth } from '@/features/auth/composables/useAuth'
import { useChangePasswordDialog } from '@/composables/useChangePasswordDialog'
import { useSetupStatus } from '@/features/auth/composables/useSetupStatus'
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'])
@@ -12,6 +13,8 @@ function permissionFallback(name: string | undefined) {
export function registerAuthGuard(router: Router): void {
router.beforeEach(async (to) => {
// No user while the server is unreachable means "not known yet", not "signed out".
await whenSessionResolved()
const { fetchSetupStatus } = useSetupStatus()
const { fetchLoginOptions, loginOptions } = useLoginOptions()
let requiresSetup = false