@startsimpli/auth 0.4.8 → 0.4.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/auth",
3
- "version": "0.4.8",
3
+ "version": "0.4.11",
4
4
  "description": "Shared authentication package for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -29,17 +29,18 @@
29
29
  "clean": "rm -rf dist"
30
30
  },
31
31
  "peerDependencies": {
32
- "react": "^18.0.0 || ^19.0.0",
33
- "next": "^14.0.0 || ^15.0.0 || ^16.0.0"
32
+ "next": "^14.0.0 || ^15.0.0 || ^16.0.0",
33
+ "react": "^18.0.0 || ^19.0.0"
34
34
  },
35
35
  "devDependencies": {
36
- "@types/node": "^20.17.14",
37
- "@types/react": "^18.3.18",
38
- "@vitest/ui": "^3.0.0",
39
- "happy-dom": "^15.11.7",
36
+ "@types/node": "^20.19.39",
37
+ "@types/react": "^19.2.14",
38
+ "@vitest/ui": "^4.1.5",
39
+ "jsdom": "^29.0.2",
40
+ "@types/jsdom": "^21.1.7",
40
41
  "tsup": "^8.5.1",
41
- "typescript": "^5.7.3",
42
- "vitest": "^3.0.0"
42
+ "typescript": "^6.0.3",
43
+ "vitest": "^4.1.5"
43
44
  },
44
45
  "keywords": [
45
46
  "authentication",
@@ -0,0 +1,264 @@
1
+ /**
2
+ * Contract tests for AuthClient register + OAuth support (bead gkat).
3
+ *
4
+ * These tests define the target API that unblocks raise-simpli's provider
5
+ * swap (bead q6vf). They are written to fail until the implementation lands.
6
+ *
7
+ * Scope of this file:
8
+ * - AuthClient.register({ email, password, passwordConfirm, name? })
9
+ * - AuthClient.signInWithGoogle(redirectTo?)
10
+ * - AuthClient.completeGoogleCallback(code, state)
11
+ *
12
+ * A sibling test file (auth-context-oauth-register.test.tsx) covers the same
13
+ * features through the AuthProvider/useAuth surface.
14
+ */
15
+
16
+ import { describe, it, expect, vi, beforeEach } from 'vitest'
17
+ import { AuthClient } from '../client/auth-client'
18
+
19
+ function makeToken(exp: number): string {
20
+ const header = btoa(JSON.stringify({ alg: 'HS256', typ: 'JWT' }))
21
+ const body = btoa(JSON.stringify({ tokenType: 'access', exp, iat: exp - 3600, jti: 'test', userId: '123' }))
22
+ return `${header}.${body}.signature`
23
+ }
24
+
25
+ const VALID_TOKEN = makeToken(Math.floor(Date.now() / 1000) + 3600)
26
+
27
+ function makeConfig() {
28
+ return { apiBaseUrl: 'http://localhost:8001' }
29
+ }
30
+
31
+ function userPayload(overrides: Record<string, unknown> = {}) {
32
+ return {
33
+ id: 'user-1',
34
+ email: 'new@example.com',
35
+ first_name: 'New',
36
+ last_name: 'User',
37
+ is_email_verified: false,
38
+ created_at: '2026-04-20T00:00:00Z',
39
+ updated_at: '2026-04-20T00:00:00Z',
40
+ ...overrides,
41
+ }
42
+ }
43
+
44
+ describe('AuthClient.register (contract)', () => {
45
+ beforeEach(() => {
46
+ vi.restoreAllMocks()
47
+ })
48
+
49
+ it('POSTs { email, password, password_confirm, name } to /api/v1/auth/register/ and returns a Session', async () => {
50
+ const client = new AuthClient(makeConfig())
51
+ const mockFetch = vi.fn()
52
+ .mockResolvedValueOnce({
53
+ ok: true,
54
+ status: 200,
55
+ json: async () => ({
56
+ access: VALID_TOKEN,
57
+ user: userPayload({ email: 'new@example.com', first_name: 'New', last_name: 'User' }),
58
+ }),
59
+ })
60
+ vi.stubGlobal('fetch', mockFetch)
61
+
62
+ const session = await client.register({
63
+ email: 'new@example.com',
64
+ password: 'SecurePass1!',
65
+ passwordConfirm: 'SecurePass1!',
66
+ name: 'New User',
67
+ })
68
+
69
+ // Request shape
70
+ const call = mockFetch.mock.calls[0]
71
+ expect(call[0]).toContain('/api/v1/auth/register/')
72
+ expect(call[1].method).toBe('POST')
73
+ const body = JSON.parse(call[1].body as string)
74
+ expect(body.email).toBe('new@example.com')
75
+ expect(body.password).toBe('SecurePass1!')
76
+ // Backend expects snake_case for confirmation field
77
+ expect(body.password_confirm).toBe('SecurePass1!')
78
+ expect(body.name).toBe('New User')
79
+
80
+ // Return shape
81
+ expect(session.accessToken).toBe(VALID_TOKEN)
82
+ expect(session.user.email).toBe('new@example.com')
83
+ expect(session.user.firstName).toBe('New')
84
+ expect(session.user.lastName).toBe('User')
85
+ expect(session.expiresAt).toBeGreaterThan(Date.now())
86
+ })
87
+
88
+ it('throws a readable error when backend rejects (e.g. email already exists)', async () => {
89
+ const client = new AuthClient(makeConfig())
90
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
91
+ ok: false,
92
+ status: 400,
93
+ json: async () => ({
94
+ error: 'VALIDATION_ERROR',
95
+ detail: { email: ['user with this email already exists.'] },
96
+ }),
97
+ }))
98
+
99
+ await expect(
100
+ client.register({ email: 'taken@example.com', password: 'x', passwordConfirm: 'x' })
101
+ ).rejects.toThrow(/already exists/i)
102
+ })
103
+
104
+ it('fetches /me/ when the register response has no user payload', async () => {
105
+ const client = new AuthClient(makeConfig())
106
+ vi.stubGlobal('fetch', vi.fn()
107
+ // register response: access only, no user
108
+ .mockResolvedValueOnce({
109
+ ok: true,
110
+ status: 200,
111
+ json: async () => ({ access: VALID_TOKEN }),
112
+ })
113
+ // fallback /me/
114
+ .mockResolvedValueOnce({
115
+ ok: true,
116
+ status: 200,
117
+ json: async () => ({ user: userPayload({ email: 'fetched@example.com' }) }),
118
+ }))
119
+
120
+ const session = await client.register({
121
+ email: 'fetched@example.com',
122
+ password: 'SecurePass1!',
123
+ passwordConfirm: 'SecurePass1!',
124
+ })
125
+
126
+ expect(session.user.email).toBe('fetched@example.com')
127
+ })
128
+
129
+ it('starts the refresh timer on successful register (same as login)', async () => {
130
+ const client = new AuthClient(makeConfig())
131
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
132
+ ok: true,
133
+ status: 200,
134
+ json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
135
+ }))
136
+
137
+ await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
138
+
139
+ // The refresh timer is a private member — asserting via internal state
140
+ // is brittle, but the behavior we care about is observable: getSession
141
+ // should return a non-null session after register.
142
+ const session = client.getSession()
143
+ expect(session).not.toBeNull()
144
+ })
145
+
146
+ it('mirrors the access token into module-level storage (functions.ts getAccessToken)', async () => {
147
+ const client = new AuthClient(makeConfig())
148
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
149
+ ok: true,
150
+ status: 200,
151
+ json: async () => ({ access: VALID_TOKEN, user: userPayload() }),
152
+ }))
153
+
154
+ const { getAccessToken, setAccessToken } = await import('../client/functions')
155
+ setAccessToken(null) // start clean
156
+
157
+ await client.register({ email: 'new@example.com', password: 'x', passwordConfirm: 'x' })
158
+
159
+ // @startsimpli/api's FetchWrapper reads via this module-level getter.
160
+ // Without the mirror, useAuth().session would have a token but API calls
161
+ // would go out unauthenticated.
162
+ expect(getAccessToken()).toBe(VALID_TOKEN)
163
+ })
164
+ })
165
+
166
+ describe('AuthClient.signInWithGoogle (contract)', () => {
167
+ beforeEach(() => {
168
+ vi.restoreAllMocks()
169
+ })
170
+
171
+ it('GETs /api/v1/auth/oauth/google/initiate/ (optionally with redirect_to) and returns the auth URL', async () => {
172
+ const client = new AuthClient(makeConfig())
173
+ const mockFetch = vi.fn().mockResolvedValueOnce({
174
+ ok: true,
175
+ status: 200,
176
+ json: async () => ({ auth_url: 'https://accounts.google.com/o/oauth2/v2/auth?...' }),
177
+ })
178
+ vi.stubGlobal('fetch', mockFetch)
179
+
180
+ const url = await client.signInWithGoogle('/dashboard')
181
+
182
+ const call = mockFetch.mock.calls[0]
183
+ expect(call[0]).toContain('/api/v1/auth/oauth/google/initiate/')
184
+ // Method may be GET or POST depending on implementation — just verify it
185
+ // round-trips the redirect target if the implementation supports it.
186
+ expect(url).toBe('https://accounts.google.com/o/oauth2/v2/auth?...')
187
+ })
188
+
189
+ it('throws when the backend fails to produce an auth URL', async () => {
190
+ const client = new AuthClient(makeConfig())
191
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
192
+ ok: false,
193
+ status: 500,
194
+ json: async () => ({ detail: 'OAuth provider unavailable' }),
195
+ }))
196
+
197
+ await expect(
198
+ client.signInWithGoogle()
199
+ ).rejects.toThrow(/OAuth|unavailable/i)
200
+ })
201
+ })
202
+
203
+ describe('AuthClient.completeGoogleCallback (contract)', () => {
204
+ beforeEach(() => {
205
+ vi.restoreAllMocks()
206
+ })
207
+
208
+ it('exchanges code+state at /api/v1/auth/oauth/google/callback/ and returns a Session', async () => {
209
+ const client = new AuthClient(makeConfig())
210
+ const mockFetch = vi.fn().mockResolvedValueOnce({
211
+ ok: true,
212
+ status: 200,
213
+ json: async () => ({
214
+ access: VALID_TOKEN,
215
+ user: userPayload({ email: 'oauth@example.com', first_name: 'OAuth', last_name: 'User' }),
216
+ }),
217
+ })
218
+ vi.stubGlobal('fetch', mockFetch)
219
+
220
+ const session = await client.completeGoogleCallback('test-code', 'test-state')
221
+
222
+ const call = mockFetch.mock.calls[0]
223
+ const url = typeof call[0] === 'string' ? call[0] : call[0].url
224
+ expect(url).toContain('/api/v1/auth/oauth/google/callback/')
225
+ expect(url).toContain('code=test-code')
226
+ expect(url).toContain('state=test-state')
227
+
228
+ expect(session.accessToken).toBe(VALID_TOKEN)
229
+ expect(session.user.email).toBe('oauth@example.com')
230
+ expect(session.expiresAt).toBeGreaterThan(Date.now())
231
+ })
232
+
233
+ it('throws a readable error when Google rejects the exchange (invalid/expired state)', async () => {
234
+ const client = new AuthClient(makeConfig())
235
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
236
+ ok: false,
237
+ status: 400,
238
+ json: async () => ({ detail: 'Invalid or expired state parameter' }),
239
+ }))
240
+
241
+ await expect(
242
+ client.completeGoogleCallback('bad-code', 'bad-state')
243
+ ).rejects.toThrow(/state|invalid|expired/i)
244
+ })
245
+
246
+ it('populates session from /me/ when callback response has no user payload', async () => {
247
+ const client = new AuthClient(makeConfig())
248
+ vi.stubGlobal('fetch', vi.fn()
249
+ .mockResolvedValueOnce({
250
+ ok: true,
251
+ status: 200,
252
+ json: async () => ({ access: VALID_TOKEN }),
253
+ })
254
+ .mockResolvedValueOnce({
255
+ ok: true,
256
+ status: 200,
257
+ json: async () => ({ user: userPayload({ email: 'oauth-me@example.com' }) }),
258
+ }))
259
+
260
+ const session = await client.completeGoogleCallback('ok-code', 'ok-state')
261
+
262
+ expect(session.user.email).toBe('oauth-me@example.com')
263
+ })
264
+ })
@@ -213,6 +213,196 @@ describe('authFetch session expiration on unrecoverable 401', () => {
213
213
  });
214
214
  });
215
215
 
216
+ describe('verifyEmail error extraction (nested DRF validation shape)', () => {
217
+ beforeEach(() => {
218
+ vi.clearAllMocks();
219
+ mockSessionStorage.clear();
220
+ mockLocalStorage.clear();
221
+ });
222
+
223
+ it('extracts nested detail.token[0] string from validation error', async () => {
224
+ mockFetch.mockImplementation(() =>
225
+ Promise.resolve({
226
+ ok: false,
227
+ status: 400,
228
+ json: async () => ({
229
+ error: 'VALIDATION_ERROR',
230
+ detail: { token: ['Invalid verification link. Please check the link or request a new one.'] },
231
+ }),
232
+ })
233
+ );
234
+
235
+ let caught: Error | null = null;
236
+ try {
237
+ const { verifyEmail } = await import('../client/functions');
238
+ await verifyEmail('bad-token');
239
+ } catch (err) {
240
+ caught = err as Error;
241
+ }
242
+
243
+ expect(caught).not.toBeNull();
244
+ expect(caught!.message).toBe('Invalid verification link. Please check the link or request a new one.');
245
+ expect(caught!.message).not.toBe('[object Object]');
246
+ });
247
+
248
+ it('falls back to fallback text when body has no extractable string', async () => {
249
+ mockFetch.mockImplementation(() =>
250
+ Promise.resolve({
251
+ ok: false,
252
+ status: 500,
253
+ json: async () => ({}),
254
+ })
255
+ );
256
+
257
+ let caught: Error | null = null;
258
+ try {
259
+ const { verifyEmail } = await import('../client/functions');
260
+ await verifyEmail('bad-token');
261
+ } catch (err) {
262
+ caught = err as Error;
263
+ }
264
+
265
+ expect(caught!.message).toBe('Failed to verify email');
266
+ });
267
+
268
+ it('handles string detail (standard DRF shape)', async () => {
269
+ mockFetch.mockImplementation(() =>
270
+ Promise.resolve({
271
+ ok: false,
272
+ status: 400,
273
+ json: async () => ({ detail: 'Token has expired.' }),
274
+ })
275
+ );
276
+
277
+ let caught: Error | null = null;
278
+ try {
279
+ const { verifyEmail } = await import('../client/functions');
280
+ await verifyEmail('expired');
281
+ } catch (err) {
282
+ caught = err as Error;
283
+ }
284
+
285
+ expect(caught!.message).toBe('Token has expired.');
286
+ });
287
+ });
288
+
289
+ describe('extractApiError prefers human-readable error string over code fields', () => {
290
+ it('returns "error" message (not "code") for { error, code } shape', async () => {
291
+ // Re-import so we pick up the current module for each test run
292
+ const { extractApiError } = await import('../client/functions')
293
+ const msg = extractApiError(
294
+ {
295
+ error: 'No active account found with the given credentials',
296
+ code: 'unauthorized',
297
+ statusCode: 401,
298
+ },
299
+ 'Login failed'
300
+ )
301
+ expect(msg).toBe('No active account found with the given credentials')
302
+ })
303
+
304
+ it('prefers detail.string over error', async () => {
305
+ const { extractApiError } = await import('../client/functions')
306
+ const msg = extractApiError({ detail: 'Account is locked', error: 'locked' }, 'x')
307
+ expect(msg).toBe('Account is locked')
308
+ })
309
+
310
+ it('returns nested detail.field[0] when detail is an object', async () => {
311
+ const { extractApiError } = await import('../client/functions')
312
+ const msg = extractApiError({ detail: { token: ['Invalid token'] } }, 'x')
313
+ expect(msg).toBe('Invalid token')
314
+ })
315
+
316
+ it('ignores code/statusCode/timestamp fields when looking for a message', async () => {
317
+ const { extractApiError } = await import('../client/functions')
318
+ const msg = extractApiError(
319
+ { code: 'unauthorized', statusCode: 401, timestamp: '2026-01-01' },
320
+ 'fallback'
321
+ )
322
+ expect(msg).toBe('fallback')
323
+ })
324
+
325
+ it('falls back when no message fields exist', async () => {
326
+ const { extractApiError } = await import('../client/functions')
327
+ expect(extractApiError({}, 'Login failed')).toBe('Login failed')
328
+ })
329
+ })
330
+
331
+ describe('refreshAccessToken — transient vs permanent failures', () => {
332
+ beforeEach(() => {
333
+ vi.clearAllMocks()
334
+ mockSessionStorage.clear()
335
+ mockLocalStorage.clear()
336
+ setAccessToken(VALID_TOKEN)
337
+ })
338
+
339
+ it('returns null and CLEARS the access token on 401 (session dead)', async () => {
340
+ const { refreshAccessToken } = await import('../client/functions')
341
+ mockFetch.mockImplementation(() =>
342
+ Promise.resolve({
343
+ ok: false,
344
+ status: 401,
345
+ json: async () => ({}),
346
+ })
347
+ )
348
+ const result = await refreshAccessToken()
349
+ expect(result).toBeNull()
350
+ expect(getAccessToken()).toBeNull()
351
+ })
352
+
353
+ it('returns null and CLEARS the access token on 403 (session rejected)', async () => {
354
+ const { refreshAccessToken } = await import('../client/functions')
355
+ mockFetch.mockImplementation(() =>
356
+ Promise.resolve({
357
+ ok: false,
358
+ status: 403,
359
+ json: async () => ({}),
360
+ })
361
+ )
362
+ const result = await refreshAccessToken()
363
+ expect(result).toBeNull()
364
+ expect(getAccessToken()).toBeNull()
365
+ })
366
+
367
+ it('THROWS TransientRefreshError on 500 (backend down) and KEEPS the access token', async () => {
368
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
369
+ mockFetch.mockImplementation(() =>
370
+ Promise.resolve({
371
+ ok: false,
372
+ status: 500,
373
+ statusText: 'Internal Server Error',
374
+ json: async () => ({}),
375
+ })
376
+ )
377
+
378
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
379
+ expect(getAccessToken()).toBe(VALID_TOKEN)
380
+ })
381
+
382
+ it('THROWS TransientRefreshError on 503 (service unavailable) and KEEPS the token', async () => {
383
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
384
+ mockFetch.mockImplementation(() =>
385
+ Promise.resolve({
386
+ ok: false,
387
+ status: 503,
388
+ statusText: 'Service Unavailable',
389
+ json: async () => ({}),
390
+ })
391
+ )
392
+
393
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
394
+ expect(getAccessToken()).toBe(VALID_TOKEN)
395
+ })
396
+
397
+ it('THROWS TransientRefreshError on fetch network failure and KEEPS the token', async () => {
398
+ const { refreshAccessToken, TransientRefreshError } = await import('../client/functions')
399
+ mockFetch.mockImplementation(() => Promise.reject(new TypeError('Failed to fetch')))
400
+
401
+ await expect(refreshAccessToken()).rejects.toBeInstanceOf(TransientRefreshError)
402
+ expect(getAccessToken()).toBe(VALID_TOKEN)
403
+ })
404
+ })
405
+
216
406
  describe('notifySessionExpired cooldown', () => {
217
407
  beforeEach(() => {
218
408
  setOnSessionExpired(null);
@@ -0,0 +1,38 @@
1
+ // Node 25 installs globalThis.localStorage / sessionStorage = {} (stub with
2
+ // no Storage methods) before jsdom/happy-dom load, and the DOM env can't
3
+ // override an already-defined global. Replace both with working in-memory
4
+ // Storage impls so tests can use bare `localStorage` / `sessionStorage`.
5
+ class MemStorage implements Storage {
6
+ private store: Record<string, string> = {};
7
+ get length(): number {
8
+ return Object.keys(this.store).length;
9
+ }
10
+ clear(): void {
11
+ this.store = {};
12
+ }
13
+ getItem(key: string): string | null {
14
+ return Object.prototype.hasOwnProperty.call(this.store, key) ? this.store[key] : null;
15
+ }
16
+ setItem(key: string, value: string): void {
17
+ this.store[key] = String(value);
18
+ }
19
+ removeItem(key: string): void {
20
+ delete this.store[key];
21
+ }
22
+ key(i: number): string | null {
23
+ return Object.keys(this.store)[i] ?? null;
24
+ }
25
+ }
26
+
27
+ Object.defineProperty(globalThis, 'localStorage', {
28
+ configurable: true,
29
+ writable: true,
30
+ value: new MemStorage(),
31
+ });
32
+ Object.defineProperty(globalThis, 'sessionStorage', {
33
+ configurable: true,
34
+ writable: true,
35
+ value: new MemStorage(),
36
+ });
37
+
38
+ export {};
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Type-level contract test for the target useAuth() return shape (bead gkat).
3
+ *
4
+ * The goal of bead q6vf is to let raise-simpli replace its forked AuthProvider
5
+ * with @startsimpli/auth's shared AuthProvider. For that to work, useAuth()
6
+ * must surface OAuth + registration (currently only exposed through the
7
+ * standalone functional API).
8
+ *
9
+ * This file documents — at the type level — the shape we're committing to.
10
+ * It fails to compile until UseAuthReturn in use-auth.ts is extended.
11
+ */
12
+
13
+ import { describe, it, expect } from 'vitest'
14
+ import type { UseAuthReturn } from '../client/use-auth'
15
+
16
+ describe('useAuth() shape contract (target API)', () => {
17
+ it('has existing methods (baseline)', () => {
18
+ type Baseline = Pick<
19
+ UseAuthReturn,
20
+ 'user' | 'session' | 'isLoading' | 'isAuthenticated' | 'login' | 'logout' | 'refreshUser' | 'getAccessToken'
21
+ >
22
+ const _: Baseline = null as unknown as Baseline
23
+ expect(true).toBe(true)
24
+ })
25
+
26
+ it('exposes register(payload) returning Promise<void>', () => {
27
+ type Register = UseAuthReturn['register']
28
+ // Lock the parameter shape. The AuthProvider-wrapped return is Promise<void>
29
+ // because the provider updates context state rather than returning Session
30
+ // (consumers call useAuth().session / user after the promise resolves).
31
+ const _: (payload: {
32
+ email: string
33
+ password: string
34
+ passwordConfirm: string
35
+ name?: string
36
+ firstName?: string
37
+ lastName?: string
38
+ }) => Promise<void> = null as unknown as Register
39
+ expect(_).toBeNull()
40
+ })
41
+
42
+ it('exposes signInWithGoogle(redirectTo?) returning Promise<string> (the auth URL)', () => {
43
+ type SignIn = UseAuthReturn['signInWithGoogle']
44
+ const _: (redirectTo?: string) => Promise<string> = null as unknown as SignIn
45
+ expect(_).toBeNull()
46
+ })
47
+
48
+ it('exposes completeGoogleCallback(code, state) returning Promise<void>', () => {
49
+ type Callback = UseAuthReturn['completeGoogleCallback']
50
+ const _: (code: string, state: string) => Promise<void> = null as unknown as Callback
51
+ expect(_).toBeNull()
52
+ })
53
+ })
54
+
55
+ /**
56
+ * Shape reference (what raise-simpli's forked auth-context currently expects
57
+ * from its own useAuth; we need the shared hook to cover the same surface
58
+ * modulo naming — see bead q6vf for the full call-site mapping).
59
+ *
60
+ * useAuth().signIn(email, password) → useAuth().login(email, password)
61
+ * useAuth().signOut() → useAuth().logout()
62
+ * useAuth().refresh() → useAuth().refreshUser() (semantics differ, see q6vf)
63
+ * useAuth().register(payload) → useAuth().register(payload) [NEW]
64
+ * useAuth().handleOAuthSuccess({access,user}) → useAuth().completeGoogleCallback() [NEW]
65
+ * useAuth().user → useAuth().user
66
+ * useAuth().status → useAuth().isLoading + isAuthenticated
67
+ * useAuth().accessToken → useAuth().getAccessToken()
68
+ */