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
|
refresh_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
is_new_user: boolean
|
is_new_user: boolean
|
||||||
|
idle_expires_at?: string | null
|
||||||
|
absolute_expires_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export const authApi = {
|
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('access_token', access_token)
|
||||||
localStorage.setItem('refresh_token', refresh_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({
|
useAuthStore.getState().setTokens({
|
||||||
access_token,
|
access_token,
|
||||||
refresh_token,
|
refresh_token,
|
||||||
token_type: 'bearer',
|
token_type: 'bearer',
|
||||||
|
idle_expires_at,
|
||||||
|
absolute_expires_at,
|
||||||
})
|
})
|
||||||
|
|
||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
@@ -159,11 +162,28 @@ apiClient.interceptors.response.use(
|
|||||||
isRefreshing = false
|
isRefreshing = false
|
||||||
onRefreshFailed(refreshError)
|
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('access_token')
|
||||||
localStorage.removeItem('refresh_token')
|
localStorage.removeItem('refresh_token')
|
||||||
useAuthStore.getState().logout()
|
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)
|
return Promise.reject(refreshError)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,8 @@ export function OAuthCallbackPage() {
|
|||||||
access_token: result.access_token,
|
access_token: result.access_token,
|
||||||
refresh_token: result.refresh_token,
|
refresh_token: result.refresh_token,
|
||||||
token_type: result.token_type || 'bearer',
|
token_type: result.token_type || 'bearer',
|
||||||
|
idle_expires_at: result.idle_expires_at,
|
||||||
|
absolute_expires_at: result.absolute_expires_at,
|
||||||
})
|
})
|
||||||
// Hydrate user / account / subscription.
|
// Hydrate user / account / subscription.
|
||||||
await fetchUser()
|
await fetchUser()
|
||||||
|
|||||||
@@ -3,6 +3,12 @@ export interface Token {
|
|||||||
refresh_token: string
|
refresh_token: string
|
||||||
token_type: string
|
token_type: string
|
||||||
must_change_password?: boolean
|
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 {
|
export interface AuthState {
|
||||||
|
|||||||
Reference in New Issue
Block a user