Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion src/router/index.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { createBrowserRouter, RouterProvider, Navigate } from 'react-router-dom'
import { lazy, Suspense } from 'react'
import { lazy, Suspense, useEffect } from 'react'
import type { ReactNode } from 'react'
import { Layout } from '../components/layout/Layout'
import { Spinner } from '../components/ui/Spinner'
import { useRoleStore } from '../stores/role.store'
import type { UserRole } from '../stores/role.store'
import { useWallet } from '../hooks/useWallet'
import { useAppStore } from '../stores/app.store'
import { setSessionExpiredHandler } from '../services/api'

// Route pages are code-split so heavy per-page dependencies (e.g. the Stellar
// SDK pulled in by the dashboard/vouch flows) load on demand instead of
Expand Down Expand Up @@ -55,6 +57,7 @@ function RoleGuard({
function page(node: ReactNode) {
return (
<Layout>
<RedirectOnSessionExpiry />
<Suspense
fallback={
<div className="flex items-center justify-center py-32">
Expand All @@ -68,6 +71,26 @@ function page(node: ReactNode) {
)
}

/**
* Sends the user to /dashboard with a client-side redirect once the API layer
* reports that the session is unrecoverable.
*
* `/dashboard` is the role dispatcher: it reads the role store and forwards to
* the correct surface (or to `/` when no role is selected), which is the right
* entry point for a signed-out session.
*
* This exists so `src/services/api.ts` never has to assign `window.location`:
* a full document reload discards all React and store state. The service calls
* a handler that this component registers, and React Router performs the
* navigation.
*/
function RedirectOnSessionExpiry() {
const sessionExpired = useAppStore((s) => s.sessionExpired)

if (!sessionExpired) return null
return <Navigate to="/dashboard" replace />
}

const router = createBrowserRouter([
{
path: '/',
Expand Down Expand Up @@ -136,5 +159,13 @@ const router = createBrowserRouter([
])

export function Router() {
useEffect(() => {
// Register the redirect once, at the top of the app: the API layer has no
// router context of its own, so this is how it hands the "session is over"
// signal back to React Router.
setSessionExpiredHandler(() => useAppStore.getState().setSessionExpired(true))
return () => setSessionExpiredHandler(null)
}, [])

return <RouterProvider router={router} />
}
153 changes: 152 additions & 1 deletion src/services/__tests__/api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,64 @@ import type {
AxiosResponse,
InternalAxiosRequestConfig,
} from 'axios'
import { api } from '../api'
import { api, setSessionExpiredHandler } from '../api'
import { useUserStore } from '../../stores/user.store'

const originalAdapter = api.defaults.adapter

/**
* Installs a working in-memory `localStorage` for the duration of a test.
*
* jsdom provides no `localStorage` here, and Node 24+ shadows it with a
* getter-only global accessor that resolves to `undefined` unless the process
* was started with `--localstorage-file`. Two consequences:
* - `vi.stubGlobal` cannot replace it (assignment to an accessor with no
* setter is silently ignored), and
* - zustand reads `window.localStorage` specifically (see `persistImpl` in
* zustand/middleware), not `globalThis.localStorage`, so `window` must be
* patched too.
*
* Returns a restore function for `afterEach`.
*/
function installMemoryStorage(): () => void {
const makeStorage = () => {
const store = new Map<string, string>()
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, String(value)),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size
},
}
}

const targets: object[] = []
const win = (globalThis as { window?: object }).window
if (win) targets.push(win)
targets.push(globalThis)

const restores = targets.map((target) => {
const original = Object.getOwnPropertyDescriptor(target, 'localStorage')
Object.defineProperty(target, 'localStorage', {
configurable: true,
writable: true,
value: makeStorage(),
})
return () => {
if (original) {
Object.defineProperty(target, 'localStorage', original)
} else {
Reflect.deleteProperty(target, 'localStorage')
}
}
})

return () => restores.forEach((restore) => restore())
}

function resetTokens() {
useUserStore.setState({ accessToken: '', refreshToken: '', isAuthenticated: false })
}
Expand Down Expand Up @@ -110,3 +163,101 @@ describe('API response interceptor — refresh keeps the store in sync', () => {
expect(useUserStore.getState().refreshToken).toBe('refresh-3')
})
})

describe('API response interceptor — unrecoverable session', () => {
let restoreStorage: () => void

beforeEach(() => {
// This suite reaches `clearTokens()`, which writes to localStorage. The
// environment does not provide a usable one (see installMemoryStorage).
restoreStorage = installMemoryStorage()
localStorage.clear()
resetTokens()
})

afterEach(() => {
setSessionExpiredHandler(null)
restoreStorage()
vi.restoreAllMocks()
})

it('clears the store and asks the router to redirect when there is no session to refresh', async () => {
// No refresh token and no access token: nothing can be recovered.
const onSessionExpired = vi.fn()
setSessionExpiredHandler(onSessionExpired)
useUserStore.setState({ accessToken: '', refreshToken: '', isAuthenticated: false })

const rejected = api.interceptors.response.handlers?.[0]?.rejected
const error = {
config: { headers: {} },
response: { status: 401 },
} as unknown as AxiosError

await expect(rejected!(error)).rejects.toBe(error)

expect(onSessionExpired).toHaveBeenCalledTimes(1)
expect(useUserStore.getState().isAuthenticated).toBe(false)
// The in-memory store must be cleared, not just flagged unauthenticated.
expect(useUserStore.getState().accessToken).toBe('')
expect(localStorage.getItem('accessToken')).toBeNull()
expect(localStorage.getItem('refreshToken')).toBeNull()
})

it('clears the store and redirects when a refresh attempt fails', async () => {
const onSessionExpired = vi.fn()
setSessionExpiredHandler(onSessionExpired)
useUserStore.getState().setTokens('stale-access', 'stale-refresh')
// Refresh fails, so recovery is impossible from here.
vi.spyOn(axios, 'post').mockRejectedValue(new Error('refresh rejected'))

const rejected = api.interceptors.response.handlers?.[0]?.rejected
const error = {
config: { headers: {} },
response: { status: 401 },
} as unknown as AxiosError

await expect(rejected!(error)).rejects.toBeTruthy()

expect(onSessionExpired).toHaveBeenCalledTimes(1)
expect(useUserStore.getState().accessToken).toBe('')
expect(useUserStore.getState().refreshToken).toBe('')
expect(localStorage.getItem('accessToken')).toBeNull()
})

it('clears the store but does not redirect an existing session that cannot be refreshed', async () => {
// An expired session with an access token but no refresh token: the caller
// should surface the 401 rather than having the page torn down underneath
// the user. This preserves the behaviour the original guard documented.
const onSessionExpired = vi.fn()
setSessionExpiredHandler(onSessionExpired)
useUserStore.setState({
accessToken: 'expired-access',
refreshToken: '',
isAuthenticated: true,
})

const rejected = api.interceptors.response.handlers?.[0]?.rejected
const error = {
config: { headers: {} },
response: { status: 401 },
} as unknown as AxiosError

await rejected!(error).catch(() => undefined)

expect(onSessionExpired).not.toHaveBeenCalled()
expect(useUserStore.getState().accessToken).toBe('')
})

it('does nothing when no handler has been registered', async () => {
// Guards against the service hard-depending on a mounted router.
setSessionExpiredHandler(null)

const rejected = api.interceptors.response.handlers?.[0]?.rejected
const error = {
config: { headers: {} },
response: { status: 401 },
} as unknown as AxiosError

await expect(rejected!(error)).rejects.toBe(error)
})
})
43 changes: 35 additions & 8 deletions src/services/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,33 @@ interface FailedRequest {
let isRefreshing = false
let failedQueue: FailedRequest[] = []

/**
* Called when the session can no longer be recovered and the user has to be
* sent back through the login flow.
*
* This is injected rather than performed here because `api` is a plain module:
* it cannot call `useNavigate`, and assigning `window.location` would trigger a
* full document reload that throws away all React and store state. The router
* registers the real handler at startup (see `setSessionExpiredHandler` in
* `src/router/index.tsx`), which navigates declaratively instead.
*/
let sessionExpiredHandler: (() => void) | null = null

export function setSessionExpiredHandler(handler: (() => void) | null): void {
sessionExpiredHandler = handler
}

function notifySessionExpired(): void {
sessionExpiredHandler?.()
}

function endSession(): void {
// Clear the store first so in-memory auth state and its persisted copy are
// gone before anything navigates away from the current route.
useUserStore.getState().clearTokens()
notifySessionExpired()
}

function processQueue(error: unknown, token: string | null = null) {
failedQueue.forEach((prom) => {
if (error) {
Expand Down Expand Up @@ -65,13 +92,14 @@ api.interceptors.response.use(
const { accessToken: existingAccessToken, refreshToken } = useUserStore.getState()
if (!refreshToken) {
isRefreshing = false
useUserStore.getState().clearTokens()
// Only force-redirect users who were never authenticated.
// If a token existed (e.g. expired session with no refresh
// token), let the caller surface the error instead of
// tearing the page down.
// Only sign users out when there was no authenticated session to
// preserve. If a token existed (e.g. an expired session with no refresh
// token), let the caller surface the error instead of tearing the page
// down.
if (!existingAccessToken) {
window.location.href = '/'
endSession()
} else {
useUserStore.getState().clearTokens()
}
return Promise.reject(error)
}
Expand All @@ -91,8 +119,7 @@ api.interceptors.response.use(
return api(originalRequest)
} catch (refreshError) {
processQueue(refreshError, null)
useUserStore.getState().clearTokens()
window.location.href = '/'
endSession()
return Promise.reject(refreshError)
} finally {
isRefreshing = false
Expand Down
88 changes: 88 additions & 0 deletions src/stores/__tests__/app.store.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
import { useAppStore } from '../app.store'

/**
* Installs a working in-memory `localStorage` for the duration of a test.
*
* jsdom provides no `localStorage` here, and Node 24+ shadows it with a
* getter-only global accessor that resolves to `undefined` unless the process
* was started with `--localstorage-file`. Two consequences:
* - `vi.stubGlobal` cannot replace it (assignment to an accessor with no
* setter is silently ignored), and
* - zustand reads `window.localStorage` specifically (see `persistImpl` in
* zustand/middleware), not `globalThis.localStorage`, so `window` must be
* patched too.
*/
function installMemoryStorage(): () => void {
const makeStorage = () => {
const store = new Map<string, string>()
return {
getItem: (key: string) => store.get(key) ?? null,
setItem: (key: string, value: string) => void store.set(key, String(value)),
removeItem: (key: string) => void store.delete(key),
clear: () => store.clear(),
key: (index: number) => Array.from(store.keys())[index] ?? null,
get length() {
return store.size
},
}
}

const targets: object[] = []
const win = (globalThis as { window?: object }).window
if (win) targets.push(win)
targets.push(globalThis)

const restores = targets.map((target) => {
const original = Object.getOwnPropertyDescriptor(target, 'localStorage')
Object.defineProperty(target, 'localStorage', {
configurable: true,
writable: true,
value: makeStorage(),
})
return () => {
if (original) {
Object.defineProperty(target, 'localStorage', original)
} else {
Reflect.deleteProperty(target, 'localStorage')
}
}
})

return () => restores.forEach((restore) => restore())
}

describe('useAppStore session expiry signal', () => {
let restoreStorage: () => void

beforeEach(() => {
restoreStorage = installMemoryStorage()
useAppStore.getState().setSessionExpired(false)
})

afterEach(() => {
restoreStorage()
})

it('starts without a pending session-expiry redirect', () => {
expect(useAppStore.getState().sessionExpired).toBe(false)
})

it('setSessionExpired raises and clears the redirect signal', () => {
useAppStore.getState().setSessionExpired(true)
expect(useAppStore.getState().sessionExpired).toBe(true)

useAppStore.getState().setSessionExpired(false)
expect(useAppStore.getState().sessionExpired).toBe(false)
})

it('does not persist the transient redirect signal across reloads', () => {
useAppStore.getState().setSessionExpired(true)

// A pending redirect must not survive a reload, otherwise a later visit
// would bounce the user straight back to the dashboard.
const raw = localStorage.getItem('stepfi-app')
const persisted = raw ? JSON.parse(raw) : { state: {} }
expect(persisted.state).not.toHaveProperty('sessionExpired')
})
})
Loading