-
Notifications
You must be signed in to change notification settings - Fork 329
Expand file tree
/
Copy pathsessionHandler.ts
More file actions
269 lines (241 loc) · 8.33 KB
/
Copy pathsessionHandler.ts
File metadata and controls
269 lines (241 loc) · 8.33 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
/**
* Frontend session expiry handler
* Detects session expiry from API responses and manages user flow
*
* @example Usage in API client wrapper
* ```typescript
* import { sessionHandler } from '@/lib/client/sessionHandler';
*
* async function apiRequest(url: string, options?: RequestInit) {
* const response = await fetch(url, options);
*
* if (sessionHandler.isSessionExpired(response)) {
* sessionHandler.handleSessionExpiry(window.location.pathname);
* return null;
* }
*
* return response;
* }
* ```
*/
/**
* Path to the sign-in page. Requests that fail with a session-expired 401 will
* redirect here with the current route passed as a `?next=` query parameter.
*/
export const SIGN_IN_PATH = '/' as const;
/**
* Builds the sign-in URL preserving the current route so the user can be
* redirected back after re-authentication.
*
* @param intendedPath - The page the user was on when the session expired.
* @returns A sign-in URL with `?next=` set, or just the sign-in path when
* `intendedPath` is absent or unsafe.
*/
export function getSignInUrl(intendedPath?: string): string {
if (!intendedPath || intendedPath === '/') return SIGN_IN_PATH;
const encoded = encodeURIComponent(intendedPath);
return `${SIGN_IN_PATH}?next=${encoded}`;
}
export interface SessionHandler {
/**
* Check if response indicates session expiry
* @param response - The fetch Response object to check
* @returns true if response is 401 with "Session expired" message
*/
isSessionExpired(response: Response): Promise<boolean>;
/**
* Handle session expiry flow
* Clears local auth state, shows message, and redirects to wallet connection
* preserving the current route in `?next=` for post-auth redirect.
* @param intendedPath - Optional path to redirect to after re-authentication
*/
handleSessionExpiry(intendedPath?: string): void;
/**
* Dispatch session-expiring warning event
* Call this when the backend indicates the session is about to expire
* @param countdown - Seconds remaining before expiry (default 120)
* @param message - Optional custom message
*/
dispatchSessionExpiring(countdown?: number, message?: string): void;
/**
* Attempt to refresh the session
* @returns true if session was refreshed, false otherwise
*/
refreshSession(): Promise<boolean>;
/**
* Clear local authentication state
* Removes stored wallet address and connection status
*/
clearAuthState(): void;
}
// Store the active refresh promise to deduplicate concurrent requests
let refreshPromise: Promise<boolean> | null = null;
/**
* Check if a response indicates session expiry
* @param response - The fetch Response object to check
* @returns true if response is 401 with "Session expired" message
*/
async function isSessionExpired(response: Response): Promise<boolean> {
if (response.status !== 401) {
return false;
}
try {
// Clone the response so the original can still be consumed
const cloned = response.clone();
const data = await cloned.json();
return data.message === 'Session expired';
} catch {
// If we can't parse JSON, it's not a session expiry response
return false;
}
}
/**
* Attempt to refresh the current session by calling the refresh endpoint
* Deduplicates concurrent calls to ensure only one refresh request is made at a time.
* @returns true if session was refreshed, false otherwise
*/
async function refreshSession(): Promise<boolean> {
if (refreshPromise) {
return refreshPromise;
}
refreshPromise = (async () => {
try {
const response = await fetch('/api/auth/refresh', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
});
return response.ok;
} catch {
return false;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
}
/**
* Dispatch session-expiring warning event
* Call this when the backend indicates the session is about to expire
* @param countdown - Seconds remaining before expiry (default 120)
* @param message - Optional custom message
*/
function dispatchSessionExpiring(countdown: number = 120, message?: string): void {
if (typeof window === 'undefined') return;
const event = new CustomEvent('session-expiring', {
detail: {
message: message || `Your session will expire in ${countdown} seconds. For your security, you'll be signed out automatically.`,
countdown,
},
});
window.dispatchEvent(event);
}
/**
* Known auth-related localStorage keys that must be cleared on logout
* or session expiry.
*
* Declared as a const array so it serves as a single source of truth for
* both the clear function and tests.
*/
export const AUTH_STORAGE_KEYS = [
'wallet_address',
'wallet_connected',
'auth_state',
'remitwise_session_expiry',
'redirect_after_auth',
] as const;
/**
* Prefix pattern for form-state entries in localStorage.
* Any key starting with this prefix is treated as user-entered form data
* that must be wiped on logout to prevent accidental exposure of sensitive
* or stale information after a session change.
*/
export const FORM_STATE_PREFIX = 'remitwise_form_' as const;
/**
* Additional sessionStorage keys that may hold form draft data.
* These are cleared alongside localStorage entries.
*/
export const SESSION_STORAGE_FORM_KEYS = ['form_draft', 'transfer_draft', 'bill_draft'] as const;
/**
* Wipe all client-side state from both localStorage and sessionStorage.
*
* This is a defensive sweep:
* 1. Removes every known auth key (wallet address, session data, redirect).
* 2. Removes every localStorage key starting with `FORM_STATE_PREFIX` — this
* catches dynamically-named form draft keys added by any component.
* 3. Removes known sessionStorage draft keys.
*
* The sweep is best-effort — if `localStorage` or `sessionStorage` throws
* (e.g. private browsing on some older browsers) the error is swallowed.
*/
export function wipeClientState(): void {
if (typeof window === 'undefined') return;
try {
// 1. Clear known auth keys
for (const key of AUTH_STORAGE_KEYS) {
localStorage.removeItem(key);
}
// 2. Clear any key with the form-state prefix (catches dynamic keys)
for (let i = localStorage.length - 1; i >= 0; i--) {
const key = localStorage.key(i);
if (key && key.startsWith(FORM_STATE_PREFIX)) {
localStorage.removeItem(key);
}
}
// 3. Clear sessionStorage draft keys
for (const key of SESSION_STORAGE_FORM_KEYS) {
sessionStorage.removeItem(key);
}
} catch {
// Storage unavailable — swallow
}
}
/**
* Clear local authentication state
* Removes stored wallet address, connection status, and any form state
* from localStorage and sessionStorage.
*/
function clearAuthState(): void {
wipeClientState();
}
/**
* Handle session expiry flow
* Clears local state, displays message, and redirects to wallet connection
* preserving the current route in the `?next=` query parameter.
* @param intendedPath - Optional path to redirect to after re-authentication
*/
function handleSessionExpiry(intendedPath?: string): void {
if (typeof window === 'undefined') return;
// Clear local authentication state
clearAuthState();
// Store intended destination for post-auth redirect
// Preserve the user's intended destination so they can be redirected back after re-authentication
if (intendedPath && intendedPath !== '/') {
localStorage.setItem('redirect_after_auth', intendedPath);
}
// Trigger a custom event that can be listened to by UI components
// This allows for flexible notification handling (toast, modal, etc.)
const event = new CustomEvent('session-expired', {
detail: { message: 'Your session has expired. Please reconnect your wallet.' }
});
window.dispatchEvent(event);
// Redirect to wallet connection page (home page) with the current route
// preserved in the `?next=` parameter so the user is sent back after
// re-authentication.
const target = getSignInUrl(intendedPath);
setTimeout(() => {
window.location.href = target;
}, 15000);
}
/**
* Session handler instance
* Use this singleton to handle session expiry across your application
*/
export const sessionHandler: SessionHandler = {
isSessionExpired,
refreshSession,
handleSessionExpiry,
dispatchSessionExpiring,
clearAuthState,
};