@startsimpli/auth 0.4.8 → 0.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -9
- package/src/__tests__/auth-client-oauth-register.test.ts +264 -0
- package/src/__tests__/auth-functions.test.ts +190 -0
- package/src/__tests__/setup.ts +38 -0
- package/src/__tests__/useauth-shape-contract.test.ts +68 -0
- package/src/client/auth-client.ts +339 -8
- package/src/client/auth-context.tsx +106 -6
- package/src/client/functions.ts +120 -37
- package/src/client/use-auth.ts +19 -0
- package/src/server/middleware.ts +11 -7
- package/src/types/index.ts +6 -0
|
@@ -11,6 +11,35 @@ import type {
|
|
|
11
11
|
AuthUser,
|
|
12
12
|
} from '../types';
|
|
13
13
|
import { isTokenExpired, getTokenExpiresAt, shouldRefreshToken } from '../utils';
|
|
14
|
+
import { extractApiError, setAccessToken as setModuleAccessToken } from './functions';
|
|
15
|
+
import { deleteCookie } from '../utils/cookies';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* sessionStorage key set by logout to suppress any subsequent
|
|
19
|
+
* bootstrapFromCookies from resurrecting a session via a refresh_token
|
|
20
|
+
* cookie that wasn't successfully deleted on the server side. Cleared on
|
|
21
|
+
* successful login / register / OAuth completion.
|
|
22
|
+
*/
|
|
23
|
+
const LOGGED_OUT_FLAG = '__ss_logged_out';
|
|
24
|
+
|
|
25
|
+
function setLoggedOutFlag(): void {
|
|
26
|
+
try {
|
|
27
|
+
if (typeof window !== 'undefined') window.sessionStorage.setItem(LOGGED_OUT_FLAG, '1');
|
|
28
|
+
} catch {}
|
|
29
|
+
}
|
|
30
|
+
function clearLoggedOutFlag(): void {
|
|
31
|
+
try {
|
|
32
|
+
if (typeof window !== 'undefined') window.sessionStorage.removeItem(LOGGED_OUT_FLAG);
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
function hasLoggedOutFlag(): boolean {
|
|
36
|
+
try {
|
|
37
|
+
if (typeof window === 'undefined') return false;
|
|
38
|
+
return window.sessionStorage.getItem(LOGGED_OUT_FLAG) === '1';
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
14
43
|
|
|
15
44
|
export class AuthClient {
|
|
16
45
|
private config: Required<AuthConfig>;
|
|
@@ -40,8 +69,8 @@ export class AuthClient {
|
|
|
40
69
|
});
|
|
41
70
|
|
|
42
71
|
if (!response.ok) {
|
|
43
|
-
const
|
|
44
|
-
throw new Error(
|
|
72
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
73
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Login failed'));
|
|
45
74
|
}
|
|
46
75
|
|
|
47
76
|
const data: any = await response.json();
|
|
@@ -59,6 +88,11 @@ export class AuthClient {
|
|
|
59
88
|
};
|
|
60
89
|
|
|
61
90
|
this.session = tempSession;
|
|
91
|
+
// Mirror the access token into the module-level storage so any consumer
|
|
92
|
+
// reading via functions.ts `getAccessToken()` (e.g. @startsimpli/api's
|
|
93
|
+
// FetchWrapper) sees the same value as useAuth().session.
|
|
94
|
+
setModuleAccessToken(data.access);
|
|
95
|
+
clearLoggedOutFlag();
|
|
62
96
|
|
|
63
97
|
// Fetch user data if not included in login response
|
|
64
98
|
if (!data.user) {
|
|
@@ -75,6 +109,177 @@ export class AuthClient {
|
|
|
75
109
|
return this.session;
|
|
76
110
|
}
|
|
77
111
|
|
|
112
|
+
/**
|
|
113
|
+
* Register a new account and open a session.
|
|
114
|
+
*
|
|
115
|
+
* POSTs to /api/v1/auth/register/ with snake_case body fields. If the
|
|
116
|
+
* backend returns a user in the response, use it directly; otherwise fall
|
|
117
|
+
* back to /me/ (same pattern as login).
|
|
118
|
+
*/
|
|
119
|
+
async register(payload: {
|
|
120
|
+
email: string;
|
|
121
|
+
password: string;
|
|
122
|
+
passwordConfirm: string;
|
|
123
|
+
name?: string;
|
|
124
|
+
firstName?: string;
|
|
125
|
+
lastName?: string;
|
|
126
|
+
}): Promise<Session> {
|
|
127
|
+
// Derive first/last from `name` if the caller used it.
|
|
128
|
+
const rawName = payload.name?.trim() ?? '';
|
|
129
|
+
const [firstFromName, ...rest] = rawName ? rawName.split(/\s+/) : [];
|
|
130
|
+
const lastFromName = rest.length ? rest.join(' ') : undefined;
|
|
131
|
+
|
|
132
|
+
const response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/register/`, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: { 'Content-Type': 'application/json' },
|
|
135
|
+
credentials: 'include',
|
|
136
|
+
body: JSON.stringify({
|
|
137
|
+
email: payload.email,
|
|
138
|
+
password: payload.password,
|
|
139
|
+
password_confirm: payload.passwordConfirm,
|
|
140
|
+
name: payload.name,
|
|
141
|
+
first_name: payload.firstName ?? firstFromName ?? undefined,
|
|
142
|
+
last_name: payload.lastName ?? lastFromName ?? undefined,
|
|
143
|
+
}),
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
147
|
+
|
|
148
|
+
if (!response.ok) {
|
|
149
|
+
throw new Error(extractApiError(data as Record<string, unknown>, 'Registration failed'));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return this.sessionFromTokenResponse(data as Record<string, unknown>);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Kick off Google OAuth by asking the backend for an authorization URL.
|
|
157
|
+
*
|
|
158
|
+
* @param redirectTo optional in-app path the server-side callback should
|
|
159
|
+
* route to after exchanging the code. Defaults to the current origin
|
|
160
|
+
* + /auth/callback.
|
|
161
|
+
* @returns the Google authorization URL the caller should redirect to.
|
|
162
|
+
*/
|
|
163
|
+
async signInWithGoogle(redirectTo?: string): Promise<string> {
|
|
164
|
+
const defaultRedirect =
|
|
165
|
+
typeof window !== 'undefined' ? `${window.location.origin}/auth/callback` : '';
|
|
166
|
+
const redirectUri = redirectTo ?? defaultRedirect;
|
|
167
|
+
|
|
168
|
+
const response = await fetch(
|
|
169
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/google/initiate/`,
|
|
170
|
+
{
|
|
171
|
+
method: 'POST',
|
|
172
|
+
headers: { 'Content-Type': 'application/json' },
|
|
173
|
+
credentials: 'include',
|
|
174
|
+
body: JSON.stringify({ redirect_uri: redirectUri }),
|
|
175
|
+
}
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
179
|
+
|
|
180
|
+
if (!response.ok) {
|
|
181
|
+
throw new Error(
|
|
182
|
+
extractApiError(data as Record<string, unknown>, 'Failed to initiate Google OAuth')
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const url = (data as { auth_url?: string; authUrl?: string }).auth_url
|
|
187
|
+
?? (data as { authUrl?: string }).authUrl;
|
|
188
|
+
if (!url) {
|
|
189
|
+
throw new Error('OAuth initiation succeeded but no auth_url was returned');
|
|
190
|
+
}
|
|
191
|
+
return url;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Complete a Google OAuth callback: exchange the code + state for a session.
|
|
196
|
+
*/
|
|
197
|
+
async completeGoogleCallback(code: string, state: string): Promise<Session> {
|
|
198
|
+
const url = new URL(
|
|
199
|
+
`${this.config.apiBaseUrl}/api/v1/auth/oauth/google/callback/`
|
|
200
|
+
);
|
|
201
|
+
url.searchParams.set('code', code);
|
|
202
|
+
url.searchParams.set('state', state);
|
|
203
|
+
|
|
204
|
+
const response = await fetch(url.toString(), {
|
|
205
|
+
credentials: 'include',
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
const data = await response.json().catch(() => ({} as Record<string, unknown>));
|
|
209
|
+
|
|
210
|
+
if (!response.ok) {
|
|
211
|
+
throw new Error(
|
|
212
|
+
extractApiError(data as Record<string, unknown>, 'OAuth authentication failed')
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return this.sessionFromTokenResponse(data as Record<string, unknown>);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Shared session builder for flows that return `{ access, user? }`.
|
|
221
|
+
*
|
|
222
|
+
* - Validates the access token and derives expiresAt
|
|
223
|
+
* - Uses the response user if present, else fetches /me/
|
|
224
|
+
* - Stores the session and starts the refresh timer
|
|
225
|
+
*/
|
|
226
|
+
private async sessionFromTokenResponse(data: Record<string, unknown>): Promise<Session> {
|
|
227
|
+
const accessToken = (data.access ?? data.accessToken) as string | undefined;
|
|
228
|
+
if (!accessToken) {
|
|
229
|
+
throw new Error('Auth response missing access token');
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const expiresAt = getTokenExpiresAt(accessToken);
|
|
233
|
+
if (!expiresAt) {
|
|
234
|
+
throw new Error('Invalid token received');
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Seed session with whatever user payload the response gave us so
|
|
238
|
+
// getCurrentUser() (which reads this.session for its auth header) can
|
|
239
|
+
// run if we need it.
|
|
240
|
+
const rawUser = data.user as Record<string, unknown> | undefined;
|
|
241
|
+
let user: AuthUser = rawUser
|
|
242
|
+
? {
|
|
243
|
+
id: (rawUser.id ?? '') as string,
|
|
244
|
+
email: (rawUser.email ?? '') as string,
|
|
245
|
+
firstName: (rawUser.first_name ?? rawUser.firstName ?? '') as string,
|
|
246
|
+
lastName: (rawUser.last_name ?? rawUser.lastName ?? '') as string,
|
|
247
|
+
isEmailVerified:
|
|
248
|
+
(rawUser.is_email_verified ?? rawUser.isEmailVerified ?? false) as boolean,
|
|
249
|
+
createdAt: (rawUser.created_at ?? rawUser.createdAt ?? '') as string,
|
|
250
|
+
updatedAt: (rawUser.updated_at ?? rawUser.updatedAt ?? '') as string,
|
|
251
|
+
isStaff: (rawUser.is_staff ?? rawUser.isStaff) as boolean | undefined,
|
|
252
|
+
isActive: (rawUser.is_active ?? rawUser.isActive) as boolean | undefined,
|
|
253
|
+
name: (rawUser.full_name ?? rawUser.name ?? null) as string | null,
|
|
254
|
+
}
|
|
255
|
+
: {
|
|
256
|
+
id: '',
|
|
257
|
+
email: '',
|
|
258
|
+
firstName: '',
|
|
259
|
+
lastName: '',
|
|
260
|
+
isEmailVerified: false,
|
|
261
|
+
createdAt: '',
|
|
262
|
+
updatedAt: '',
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
this.session = { user, accessToken, expiresAt };
|
|
266
|
+
// Keep module-level access-token storage in lockstep (see comment on login).
|
|
267
|
+
setModuleAccessToken(accessToken);
|
|
268
|
+
clearLoggedOutFlag();
|
|
269
|
+
|
|
270
|
+
if (!rawUser) {
|
|
271
|
+
try {
|
|
272
|
+
user = await this.getCurrentUser();
|
|
273
|
+
this.session.user = user;
|
|
274
|
+
} catch (error) {
|
|
275
|
+
console.error('Failed to fetch user data after auth flow:', error);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
this.startRefreshTimer();
|
|
280
|
+
return this.session;
|
|
281
|
+
}
|
|
282
|
+
|
|
78
283
|
/**
|
|
79
284
|
* Logout and clear session
|
|
80
285
|
*/
|
|
@@ -88,7 +293,17 @@ export class AuthClient {
|
|
|
88
293
|
} catch (error) {
|
|
89
294
|
console.error('Logout error:', error);
|
|
90
295
|
} finally {
|
|
296
|
+
// Clear client-readable cookies so the Next middleware
|
|
297
|
+
// (see packages/auth/src/server/middleware.ts) doesn't see a stale
|
|
298
|
+
// auth_session and bounce the post-logout /auth/signin request back to
|
|
299
|
+
// /dashboard. HttpOnly refresh_token is cleared by the backend.
|
|
300
|
+
deleteCookie('auth_session');
|
|
301
|
+
deleteCookie('access_token');
|
|
302
|
+
deleteCookie('csrftoken');
|
|
91
303
|
this.clearSession();
|
|
304
|
+
// Prevent a stale refresh_token cookie from re-authenticating on the
|
|
305
|
+
// next page load. Cleared on next successful login/register/OAuth.
|
|
306
|
+
setLoggedOutFlag();
|
|
92
307
|
}
|
|
93
308
|
}
|
|
94
309
|
|
|
@@ -114,16 +329,39 @@ export class AuthClient {
|
|
|
114
329
|
}
|
|
115
330
|
|
|
116
331
|
private async performTokenRefresh(): Promise<string> {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
332
|
+
let response: Response;
|
|
333
|
+
try {
|
|
334
|
+
response = await fetch(`${this.config.apiBaseUrl}/api/v1/auth/token/refresh/`, {
|
|
335
|
+
method: 'POST',
|
|
336
|
+
headers: { 'Content-Type': 'application/json' },
|
|
337
|
+
credentials: 'include', // Send refresh token cookie
|
|
338
|
+
});
|
|
339
|
+
} catch (err) {
|
|
340
|
+
// Network error / unreachable backend — transient, do NOT clear session.
|
|
341
|
+
throw new Error(
|
|
342
|
+
`Token refresh failed transiently: ${err instanceof Error ? err.message : String(err)}`
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// 401/403 = session dead. 5xx = backend flake. Only the former should
|
|
347
|
+
// log the user out; the latter should let them retry.
|
|
348
|
+
if (response.status === 401 || response.status === 403) {
|
|
349
|
+
this.clearSession();
|
|
350
|
+
this.config.onSessionExpired();
|
|
351
|
+
throw new Error('Token refresh rejected — session expired');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
if (response.status >= 500) {
|
|
355
|
+
throw new Error(
|
|
356
|
+
`Token refresh failed transiently: backend returned ${response.status}`
|
|
357
|
+
);
|
|
358
|
+
}
|
|
122
359
|
|
|
123
360
|
if (!response.ok) {
|
|
361
|
+
// Other non-ok (400, 404) — treat conservatively as session-dead.
|
|
124
362
|
this.clearSession();
|
|
125
363
|
this.config.onSessionExpired();
|
|
126
|
-
throw new Error(
|
|
364
|
+
throw new Error(`Token refresh failed: ${response.status}`);
|
|
127
365
|
}
|
|
128
366
|
|
|
129
367
|
const data: RefreshResponse = await response.json();
|
|
@@ -137,6 +375,7 @@ export class AuthClient {
|
|
|
137
375
|
this.session.accessToken = data.access;
|
|
138
376
|
this.session.expiresAt = expiresAt;
|
|
139
377
|
}
|
|
378
|
+
setModuleAccessToken(data.access);
|
|
140
379
|
|
|
141
380
|
this.startRefreshTimer();
|
|
142
381
|
return data.access;
|
|
@@ -168,6 +407,9 @@ export class AuthClient {
|
|
|
168
407
|
isEmailVerified: raw.is_email_verified ?? raw.isEmailVerified ?? false,
|
|
169
408
|
createdAt: raw.created_at || raw.createdAt || '',
|
|
170
409
|
updatedAt: raw.updated_at || raw.updatedAt || '',
|
|
410
|
+
isStaff: raw.is_staff ?? raw.isStaff,
|
|
411
|
+
isActive: raw.is_active ?? raw.isActive,
|
|
412
|
+
name: raw.full_name ?? raw.name ?? null,
|
|
171
413
|
};
|
|
172
414
|
|
|
173
415
|
if (this.session) {
|
|
@@ -177,6 +419,93 @@ export class AuthClient {
|
|
|
177
419
|
return user;
|
|
178
420
|
}
|
|
179
421
|
|
|
422
|
+
/**
|
|
423
|
+
* Attempt to restore a session on page load / AuthProvider remount.
|
|
424
|
+
*
|
|
425
|
+
* New AuthClient instances start with no in-memory session, so a hard
|
|
426
|
+
* navigation (or a full page reload) loses auth state even when the
|
|
427
|
+
* refresh_token cookie is still valid. This method:
|
|
428
|
+
* 1. POSTs /auth/token/refresh/ using the refresh_token cookie the
|
|
429
|
+
* browser sends automatically (credentials: include).
|
|
430
|
+
* 2. If the backend returns a new access token, fetches /me/ to populate
|
|
431
|
+
* the user, stores the session, and starts the refresh timer.
|
|
432
|
+
* 3. Returns the Session, or null if no valid refresh cookie exists.
|
|
433
|
+
*
|
|
434
|
+
* Safe to call even when a session is already in memory — it becomes a
|
|
435
|
+
* no-op pass-through in that case.
|
|
436
|
+
*/
|
|
437
|
+
async bootstrapFromCookies(): Promise<Session | null> {
|
|
438
|
+
if (this.session && !isTokenExpired(this.session.accessToken)) {
|
|
439
|
+
return this.session;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// Respect an explicit logout. If the user just logged out in this tab,
|
|
443
|
+
// don't let a stale refresh_token cookie resurrect the session — even
|
|
444
|
+
// if Chromium didn't honor the Set-Cookie delete from /auth/logout/.
|
|
445
|
+
if (hasLoggedOutFlag()) {
|
|
446
|
+
return null;
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
try {
|
|
450
|
+
const response = await fetch(
|
|
451
|
+
`${this.config.apiBaseUrl}/api/v1/auth/token/refresh/`,
|
|
452
|
+
{
|
|
453
|
+
method: 'POST',
|
|
454
|
+
headers: { 'Content-Type': 'application/json' },
|
|
455
|
+
credentials: 'include',
|
|
456
|
+
}
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
// 401/403 = no valid refresh cookie / session dead → return null so the
|
|
460
|
+
// provider settles in the unauthenticated state.
|
|
461
|
+
// 5xx = backend flake → leave the session untouched; caller should retry.
|
|
462
|
+
// Keeping the user in isLoading=false / isAuthenticated=false during a
|
|
463
|
+
// backend outage is better than looping, so fall through to null for
|
|
464
|
+
// transient failures too — but we never clear any in-memory state the
|
|
465
|
+
// app already had. (The early-return at the top of this method already
|
|
466
|
+
// preserved a valid in-memory session.)
|
|
467
|
+
if (response.status >= 500) {
|
|
468
|
+
return null;
|
|
469
|
+
}
|
|
470
|
+
if (!response.ok) return null;
|
|
471
|
+
|
|
472
|
+
const data = (await response.json()) as RefreshResponse;
|
|
473
|
+
const accessToken = data.access;
|
|
474
|
+
const expiresAt = getTokenExpiresAt(accessToken);
|
|
475
|
+
if (!expiresAt) return null;
|
|
476
|
+
|
|
477
|
+
// Seed with a placeholder user so getCurrentUser's auth-header builder works.
|
|
478
|
+
this.session = {
|
|
479
|
+
user: {
|
|
480
|
+
id: '',
|
|
481
|
+
email: '',
|
|
482
|
+
firstName: '',
|
|
483
|
+
lastName: '',
|
|
484
|
+
isEmailVerified: false,
|
|
485
|
+
createdAt: '',
|
|
486
|
+
updatedAt: '',
|
|
487
|
+
},
|
|
488
|
+
accessToken,
|
|
489
|
+
expiresAt,
|
|
490
|
+
};
|
|
491
|
+
setModuleAccessToken(accessToken);
|
|
492
|
+
|
|
493
|
+
try {
|
|
494
|
+
const user = await this.getCurrentUser();
|
|
495
|
+
this.session.user = user;
|
|
496
|
+
} catch (error) {
|
|
497
|
+
console.error('bootstrapFromCookies: /me/ failed', error);
|
|
498
|
+
this.clearSession();
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
this.startRefreshTimer();
|
|
503
|
+
return this.session;
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
180
509
|
/**
|
|
181
510
|
* Get current session
|
|
182
511
|
*/
|
|
@@ -199,6 +528,7 @@ export class AuthClient {
|
|
|
199
528
|
*/
|
|
200
529
|
setSession(session: Session): void {
|
|
201
530
|
this.session = session;
|
|
531
|
+
setModuleAccessToken(session.accessToken);
|
|
202
532
|
this.startRefreshTimer();
|
|
203
533
|
}
|
|
204
534
|
|
|
@@ -260,6 +590,7 @@ export class AuthClient {
|
|
|
260
590
|
*/
|
|
261
591
|
private clearSession(): void {
|
|
262
592
|
this.session = null;
|
|
593
|
+
setModuleAccessToken(null);
|
|
263
594
|
if (this.refreshTimer) {
|
|
264
595
|
clearInterval(this.refreshTimer);
|
|
265
596
|
this.refreshTimer = null;
|
|
@@ -21,6 +21,23 @@ interface AuthContextValue extends AuthState {
|
|
|
21
21
|
logout: () => Promise<void>;
|
|
22
22
|
refreshUser: () => Promise<void>;
|
|
23
23
|
getAccessToken: () => Promise<string | null>;
|
|
24
|
+
register: (payload: {
|
|
25
|
+
email: string;
|
|
26
|
+
password: string;
|
|
27
|
+
passwordConfirm: string;
|
|
28
|
+
name?: string;
|
|
29
|
+
firstName?: string;
|
|
30
|
+
lastName?: string;
|
|
31
|
+
}) => Promise<void>;
|
|
32
|
+
signInWithGoogle: (redirectTo?: string) => Promise<string>;
|
|
33
|
+
completeGoogleCallback: (code: string, state: string) => Promise<void>;
|
|
34
|
+
/**
|
|
35
|
+
* Hydrate the provider with an externally-acquired session. Used by OAuth
|
|
36
|
+
* callback flows that run the token exchange via a component (OAuthCallback)
|
|
37
|
+
* and then need to tell AuthProvider about the result so React state and
|
|
38
|
+
* the refresh timer stay in sync.
|
|
39
|
+
*/
|
|
40
|
+
hydrateSession: (session: Session) => void;
|
|
24
41
|
}
|
|
25
42
|
|
|
26
43
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
|
@@ -43,8 +60,14 @@ export function AuthProvider({
|
|
|
43
60
|
isAuthenticated: !!initialSession,
|
|
44
61
|
}));
|
|
45
62
|
|
|
46
|
-
// Initialize session from SSR or
|
|
63
|
+
// Initialize session from SSR or restore from refresh_token cookie.
|
|
64
|
+
// The bootstrap path matters after a hard navigation (or full reload) —
|
|
65
|
+
// a brand-new AuthClient instance starts with no in-memory session, so
|
|
66
|
+
// without this the user bounces to /auth/signin even though their
|
|
67
|
+
// refresh_token cookie is still valid.
|
|
47
68
|
useEffect(() => {
|
|
69
|
+
let cancelled = false;
|
|
70
|
+
|
|
48
71
|
if (initialSession) {
|
|
49
72
|
authClient.setSession(initialSession);
|
|
50
73
|
setState({
|
|
@@ -52,15 +75,37 @@ export function AuthProvider({
|
|
|
52
75
|
isLoading: false,
|
|
53
76
|
isAuthenticated: true,
|
|
54
77
|
});
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const existing = authClient.getSession();
|
|
82
|
+
if (existing) {
|
|
58
83
|
setState({
|
|
59
|
-
session,
|
|
84
|
+
session: existing,
|
|
60
85
|
isLoading: false,
|
|
61
|
-
isAuthenticated:
|
|
86
|
+
isAuthenticated: true,
|
|
62
87
|
});
|
|
88
|
+
return;
|
|
63
89
|
}
|
|
90
|
+
|
|
91
|
+
authClient
|
|
92
|
+
.bootstrapFromCookies()
|
|
93
|
+
.then((session) => {
|
|
94
|
+
if (cancelled) return;
|
|
95
|
+
setState({
|
|
96
|
+
session,
|
|
97
|
+
isLoading: false,
|
|
98
|
+
isAuthenticated: !!session,
|
|
99
|
+
});
|
|
100
|
+
})
|
|
101
|
+
.catch(() => {
|
|
102
|
+
if (cancelled) return;
|
|
103
|
+
setState({ session: null, isLoading: false, isAuthenticated: false });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return () => {
|
|
107
|
+
cancelled = true;
|
|
108
|
+
};
|
|
64
109
|
}, [authClient, initialSession]);
|
|
65
110
|
|
|
66
111
|
// Session expiration handler — covers both AuthClient timer and authFetch 401
|
|
@@ -135,12 +180,67 @@ export function AuthProvider({
|
|
|
135
180
|
return authClient.getAccessToken();
|
|
136
181
|
}, [authClient]);
|
|
137
182
|
|
|
183
|
+
const register = useCallback(
|
|
184
|
+
async (payload: {
|
|
185
|
+
email: string;
|
|
186
|
+
password: string;
|
|
187
|
+
passwordConfirm: string;
|
|
188
|
+
name?: string;
|
|
189
|
+
firstName?: string;
|
|
190
|
+
lastName?: string;
|
|
191
|
+
}) => {
|
|
192
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
193
|
+
try {
|
|
194
|
+
const session = await authClient.register(payload);
|
|
195
|
+
setState({ session, isLoading: false, isAuthenticated: true });
|
|
196
|
+
} catch (error) {
|
|
197
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
[authClient]
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
const signInWithGoogle = useCallback(
|
|
205
|
+
async (redirectTo?: string) => {
|
|
206
|
+
// This just produces the URL; the caller is responsible for the redirect.
|
|
207
|
+
return authClient.signInWithGoogle(redirectTo);
|
|
208
|
+
},
|
|
209
|
+
[authClient]
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
const completeGoogleCallback = useCallback(
|
|
213
|
+
async (code: string, state: string) => {
|
|
214
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
215
|
+
try {
|
|
216
|
+
const session = await authClient.completeGoogleCallback(code, state);
|
|
217
|
+
setState({ session, isLoading: false, isAuthenticated: true });
|
|
218
|
+
} catch (error) {
|
|
219
|
+
setState((prev) => ({ ...prev, isLoading: false }));
|
|
220
|
+
throw error;
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
[authClient]
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
const hydrateSession = useCallback(
|
|
227
|
+
(session: Session) => {
|
|
228
|
+
authClient.setSession(session);
|
|
229
|
+
setState({ session, isLoading: false, isAuthenticated: true });
|
|
230
|
+
},
|
|
231
|
+
[authClient]
|
|
232
|
+
);
|
|
233
|
+
|
|
138
234
|
const value: AuthContextValue = {
|
|
139
235
|
...state,
|
|
140
236
|
login,
|
|
141
237
|
logout,
|
|
142
238
|
refreshUser,
|
|
143
239
|
getAccessToken,
|
|
240
|
+
register,
|
|
241
|
+
signInWithGoogle,
|
|
242
|
+
completeGoogleCallback,
|
|
243
|
+
hydrateSession,
|
|
144
244
|
};
|
|
145
245
|
|
|
146
246
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|