feat(ui): handle session_expired_{idle,absolute} in axios interceptor
Seventh commit in the session-expiration-policy series. Wires the
backend taxonomy from commit 2 through to the frontend so users see
the right page (calm banner vs plain logout) when the refresh path
fails for different reasons.
- types/auth.ts: Token gains idle_expires_at + absolute_expires_at
(Optional ISO 8601 strings). The next commit adds the
useAuthSessionExpiry hook that reads these.
- api/auth.ts: OAuthCallbackResponse mirrors the same two fields.
- api/client.ts: refresh-failure handler now branches on the response
detail. session_expired_idle and session_expired_absolute both
redirect to /login?reason=session_expired (commit 8 adds the
banner that reads the query param); any other detail (most
commonly invalid_refresh_token) goes to plain /login. The bare
redirect is guarded against re-firing when the user is already on
/login. The refresh-success path now forwards the two new fields
into setTokens so the store stays current as the session ages.
- pages/OAuthCallbackPage.tsx: setTokens({...}) spreads
idle_expires_at + absolute_expires_at from the OAuth response.
No new tests — authStore.test still 2/2, tsc clean. The
useAuthSessionExpiry hook and the SessionExpiryToast that consume
the new fields land in commit 8 alongside the AccountSecurity page.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,8 @@ export interface OAuthCallbackResponse {
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
is_new_user: boolean
|
||||
idle_expires_at?: string | null
|
||||
absolute_expires_at?: string | null
|
||||
}
|
||||
|
||||
export const authApi = {
|
||||
|
||||
@@ -136,15 +136,18 @@ apiClient.interceptors.response.use(
|
||||
},
|
||||
})
|
||||
|
||||
const { access_token, refresh_token } = response.data
|
||||
const { access_token, refresh_token, idle_expires_at, absolute_expires_at } = response.data
|
||||
localStorage.setItem('access_token', access_token)
|
||||
localStorage.setItem('refresh_token', refresh_token)
|
||||
|
||||
// Sync Zustand auth store
|
||||
// Sync Zustand auth store — include the new expiry fields so
|
||||
// useAuthSessionExpiry stays accurate after each refresh.
|
||||
useAuthStore.getState().setTokens({
|
||||
access_token,
|
||||
refresh_token,
|
||||
token_type: 'bearer',
|
||||
idle_expires_at,
|
||||
absolute_expires_at,
|
||||
})
|
||||
|
||||
isRefreshing = false
|
||||
@@ -159,11 +162,28 @@ apiClient.interceptors.response.use(
|
||||
isRefreshing = false
|
||||
onRefreshFailed(refreshError)
|
||||
|
||||
// Refresh failed — clear tokens and redirect to login
|
||||
// Refresh failed — clear tokens and redirect to login. The redirect
|
||||
// target depends on WHY the refresh failed (plan §4.10):
|
||||
// - session_expired_idle / session_expired_absolute: the user hit a
|
||||
// policy boundary. Show the calm "session ended for security"
|
||||
// banner via ?reason=session_expired.
|
||||
// - invalid_refresh_token (or anything else): plain logout, no
|
||||
// banner — the user wasn't kicked by policy, the token just
|
||||
// wasn't recognized.
|
||||
const refreshAxiosErr = refreshError as AxiosError
|
||||
const refreshDetail = (refreshAxiosErr.response?.data as { detail?: string })?.detail
|
||||
const isPolicyExpiry =
|
||||
refreshDetail === 'session_expired_idle' ||
|
||||
refreshDetail === 'session_expired_absolute'
|
||||
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
useAuthStore.getState().logout()
|
||||
window.location.href = '/login'
|
||||
if (!window.location.pathname.startsWith('/login')) {
|
||||
window.location.href = isPolicyExpiry
|
||||
? '/login?reason=session_expired'
|
||||
: '/login'
|
||||
}
|
||||
return Promise.reject(refreshError)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -103,6 +103,8 @@ export function OAuthCallbackPage() {
|
||||
access_token: result.access_token,
|
||||
refresh_token: result.refresh_token,
|
||||
token_type: result.token_type || 'bearer',
|
||||
idle_expires_at: result.idle_expires_at,
|
||||
absolute_expires_at: result.absolute_expires_at,
|
||||
})
|
||||
// Hydrate user / account / subscription.
|
||||
await fetchUser()
|
||||
|
||||
@@ -3,6 +3,12 @@ export interface Token {
|
||||
refresh_token: string
|
||||
token_type: string
|
||||
must_change_password?: boolean
|
||||
// ISO 8601 UTC strings derived from the refresh JWT's idle and absolute
|
||||
// session windows. Used by useAuthSessionExpiry to drive the
|
||||
// "your session ends soon" toast and the forced-logout fallback when
|
||||
// /auth/refresh rejects with session_expired_{idle,absolute}.
|
||||
idle_expires_at?: string | null
|
||||
absolute_expires_at?: string | null
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
Reference in New Issue
Block a user