@codingfactory/socialkit-vue 0.1.0

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.
Files changed (56) hide show
  1. package/dist/composables/useAuth.d.ts +27 -0
  2. package/dist/composables/useAuth.d.ts.map +1 -0
  3. package/dist/composables/useAuth.js +137 -0
  4. package/dist/composables/useAuth.js.map +1 -0
  5. package/dist/index.d.ts +25 -0
  6. package/dist/index.d.ts.map +1 -0
  7. package/dist/index.js +16 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/plugins/terminology/index.d.ts +11 -0
  10. package/dist/plugins/terminology/index.d.ts.map +1 -0
  11. package/dist/plugins/terminology/index.js +91 -0
  12. package/dist/plugins/terminology/index.js.map +1 -0
  13. package/dist/plugins/terminology/terms.d.ts +15 -0
  14. package/dist/plugins/terminology/terms.d.ts.map +1 -0
  15. package/dist/plugins/terminology/terms.js +72 -0
  16. package/dist/plugins/terminology/terms.js.map +1 -0
  17. package/dist/plugins/terminology/types.d.ts +32 -0
  18. package/dist/plugins/terminology/types.d.ts.map +1 -0
  19. package/dist/plugins/terminology/types.js +2 -0
  20. package/dist/plugins/terminology/types.js.map +1 -0
  21. package/dist/services/api.d.ts +50 -0
  22. package/dist/services/api.d.ts.map +1 -0
  23. package/dist/services/api.js +305 -0
  24. package/dist/services/api.js.map +1 -0
  25. package/dist/services/auth.d.ts +127 -0
  26. package/dist/services/auth.d.ts.map +1 -0
  27. package/dist/services/auth.js +562 -0
  28. package/dist/services/auth.js.map +1 -0
  29. package/dist/stores/auth.d.ts +174 -0
  30. package/dist/stores/auth.d.ts.map +1 -0
  31. package/dist/stores/auth.js +262 -0
  32. package/dist/stores/auth.js.map +1 -0
  33. package/dist/types/api.d.ts +52 -0
  34. package/dist/types/api.d.ts.map +1 -0
  35. package/dist/types/api.js +7 -0
  36. package/dist/types/api.js.map +1 -0
  37. package/dist/types/user.d.ts +42 -0
  38. package/dist/types/user.d.ts.map +1 -0
  39. package/dist/types/user.js +45 -0
  40. package/dist/types/user.js.map +1 -0
  41. package/dist/utils/tokenStorage.d.ts +41 -0
  42. package/dist/utils/tokenStorage.d.ts.map +1 -0
  43. package/dist/utils/tokenStorage.js +300 -0
  44. package/dist/utils/tokenStorage.js.map +1 -0
  45. package/package.json +40 -0
  46. package/src/composables/useAuth.ts +164 -0
  47. package/src/index.ts +118 -0
  48. package/src/plugins/terminology/index.ts +114 -0
  49. package/src/plugins/terminology/terms.ts +104 -0
  50. package/src/plugins/terminology/types.ts +28 -0
  51. package/src/services/api.ts +472 -0
  52. package/src/services/auth.ts +874 -0
  53. package/src/stores/auth.ts +400 -0
  54. package/src/types/api.ts +56 -0
  55. package/src/types/user.ts +94 -0
  56. package/src/utils/tokenStorage.ts +394 -0
@@ -0,0 +1,874 @@
1
+ /**
2
+ * Configurable auth services and Axios auth-client wiring for
3
+ * SocialKit-powered frontends.
4
+ */
5
+
6
+ import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
7
+ import type { TokenStorage } from '../utils/tokenStorage.js'
8
+ import { extractTokenFromResponse } from '../utils/tokenStorage.js'
9
+ import type { User } from '../types/user.js'
10
+
11
+ /** Configuration for creating an auth service. */
12
+ export interface AuthServiceConfig {
13
+ /** Base URL for API requests (e.g. '/api' or 'https://api.example.com/api'). */
14
+ baseURL: string
15
+ /** Token storage instance for persisting auth tokens. */
16
+ tokenStorage: TokenStorage
17
+ /** Request timeout in milliseconds (default: 30000). */
18
+ timeout?: number
19
+ }
20
+
21
+ export interface ExtendedAuthServiceConfig extends AuthServiceConfig {
22
+ /** Optional existing Axios client to use instead of creating a dedicated one. */
23
+ client?: AxiosInstance
24
+ /** Storage key for the client device identifier used by login/2FA endpoints. */
25
+ deviceIdStorageKey?: string
26
+ /** Header name used for the device identifier (default: 'X-Device-Id'). */
27
+ deviceIdHeaderName?: string
28
+ /** How forgot-password payloads should be derived from user input. */
29
+ forgotPasswordIdentifierStrategy?: 'email-only' | 'email-or-username'
30
+ }
31
+
32
+ /** Registration data shape. */
33
+ export interface RegisterData {
34
+ handle: string
35
+ name: string
36
+ email: string
37
+ password: string
38
+ password_confirmation: string
39
+ }
40
+
41
+ /** Password reset data shape. */
42
+ export interface ResetPasswordData {
43
+ token: string
44
+ email: string
45
+ password: string
46
+ password_confirmation: string
47
+ }
48
+
49
+ /** Login response shape (user + optional token). */
50
+ export interface LoginResponse {
51
+ user: User
52
+ token?: string
53
+ }
54
+
55
+ export interface AuthenticatedLoginResponse extends LoginResponse {
56
+ requires_2fa?: false
57
+ challenge_id?: never
58
+ }
59
+
60
+ export interface TwoFactorChallengeResponse {
61
+ requires_2fa: true
62
+ challenge_id: string
63
+ }
64
+
65
+ export interface AuthenticatedRegisterResponse extends LoginResponse {
66
+ requires_verification: false
67
+ }
68
+
69
+ export interface VerificationRequiredRegisterResponse {
70
+ requires_verification: true
71
+ }
72
+
73
+ export type AuthLoginResult = AuthenticatedLoginResponse | TwoFactorChallengeResponse
74
+ export type RegisterResponse = AuthenticatedRegisterResponse | VerificationRequiredRegisterResponse
75
+
76
+ /** Public auth service interface. */
77
+ export interface AuthServiceInstance {
78
+ login(loginCredential: string, password: string, remember?: boolean): Promise<LoginResponse>
79
+ register(data: RegisterData): Promise<LoginResponse>
80
+ logout(): Promise<void>
81
+ getUser(): Promise<User>
82
+ forgotPassword(email: string): Promise<unknown>
83
+ resetPassword(data: ResetPasswordData): Promise<unknown>
84
+ hasToken(): boolean
85
+ clearToken(): void
86
+ }
87
+
88
+ export interface ExtendedAuthServiceInstance {
89
+ login(loginCredential: string, password: string, remember?: boolean): Promise<AuthLoginResult>
90
+ register(data: RegisterData): Promise<RegisterResponse>
91
+ verify2faChallenge(
92
+ challengeId: string,
93
+ code?: string,
94
+ recoveryCode?: string,
95
+ rememberDevice?: boolean,
96
+ rememberSession?: boolean
97
+ ): Promise<AuthenticatedLoginResponse>
98
+ logout(): Promise<void>
99
+ getUser(): Promise<User>
100
+ forgotPassword(identifier: string): Promise<unknown>
101
+ resetPassword(data: ResetPasswordData): Promise<unknown>
102
+ hasToken(): boolean
103
+ clearToken(): void
104
+ }
105
+
106
+ interface RetriableRequestConfig extends AxiosRequestConfig {
107
+ _retry?: boolean
108
+ _policyRetry?: boolean
109
+ }
110
+
111
+ interface MissingPolicy {
112
+ policy_id: string
113
+ version: number
114
+ }
115
+
116
+ export interface TokenAuthClientConfig {
117
+ /** Existing Axios client to configure. Defaults to the shared axios export. */
118
+ client?: AxiosInstance
119
+ /** Base URL for API requests. */
120
+ baseURL: string
121
+ /** Token storage instance providing auth state and refresh behavior. */
122
+ tokenStorage: TokenStorage
123
+ /** Request timeout in milliseconds (default: 30000). */
124
+ timeout?: number
125
+ /** Delay before queued auth teardown/redirect executes (default: 1500). */
126
+ authFailureRedirectDelayMs?: number
127
+ /** Paths considered auth pages; queued redirects will not re-target them. */
128
+ authPagePaths?: string[]
129
+ /** Build the redirect target when auth teardown occurs. */
130
+ buildLoginRedirectUrl?: () => string
131
+ /** Called when auth teardown actually executes. */
132
+ onAuthInvalidated?: () => void
133
+ /** Called for response errors that should surface globally. */
134
+ onResponseError?: (error: unknown) => void
135
+ /** Suppress global error reporting for specific responses. */
136
+ shouldSuppressResponseError?: (error: unknown) => boolean
137
+ }
138
+
139
+ export interface ConfiguredTokenAuthClient {
140
+ client: AxiosInstance
141
+ setValidatingAuth(value: boolean): void
142
+ }
143
+
144
+ const DEFAULT_DEVICE_ID_STORAGE_KEY = 'socialkit_device_id'
145
+ const DEFAULT_DEVICE_ID_HEADER_NAME = 'X-Device-Id'
146
+ const DEFAULT_AUTH_FAILURE_REDIRECT_DELAY_MS = 1500
147
+ const DEFAULT_AUTH_PAGE_PATHS = ['/login', '/register']
148
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i
149
+
150
+ function isRecord(value: unknown): value is Record<string, unknown> {
151
+ return typeof value === 'object' && value !== null
152
+ }
153
+
154
+ function deriveRole(roles: string[]): 'admin' | 'moderator' | 'user' {
155
+ if (roles.includes('admin') || roles.includes('super-admin')) {
156
+ return 'admin'
157
+ }
158
+
159
+ if (roles.includes('moderator')) {
160
+ return 'moderator'
161
+ }
162
+
163
+ return 'user'
164
+ }
165
+
166
+ function normalizeUserPayload(raw: Record<string, unknown>): User {
167
+ const rawRoles = raw.roles
168
+ const roles = Array.isArray(rawRoles)
169
+ ? rawRoles.filter((value): value is string => typeof value === 'string')
170
+ : []
171
+
172
+ const id = typeof raw.id === 'string' ? raw.id : ''
173
+ const name = typeof raw.name === 'string' ? raw.name : ''
174
+ const email = typeof raw.email === 'string' ? raw.email : ''
175
+ const avatarValue = isRecord(raw.avatar) ? raw.avatar : null
176
+ const avatarUrl = typeof avatarValue?.url === 'string' ? avatarValue.url : null
177
+
178
+ return {
179
+ ...raw,
180
+ id,
181
+ name,
182
+ email,
183
+ role: deriveRole(roles),
184
+ avatar: avatarValue,
185
+ avatar_url: avatarUrl
186
+ }
187
+ }
188
+
189
+ function createConfiguredClient(baseURL: string, timeout: number, client?: AxiosInstance): AxiosInstance {
190
+ if (client) {
191
+ client.defaults.baseURL = baseURL
192
+ client.defaults.timeout = timeout
193
+ client.defaults.headers.common.Accept = 'application/json'
194
+ client.defaults.headers.common['Content-Type'] = 'application/json'
195
+ client.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
196
+ client.defaults.withCredentials = false
197
+ return client
198
+ }
199
+
200
+ return axios.create({
201
+ baseURL,
202
+ timeout,
203
+ headers: {
204
+ Accept: 'application/json',
205
+ 'Content-Type': 'application/json',
206
+ 'X-Requested-With': 'XMLHttpRequest'
207
+ },
208
+ withCredentials: false
209
+ })
210
+ }
211
+
212
+ function isUuid(value: string): boolean {
213
+ return UUID_PATTERN.test(value)
214
+ }
215
+
216
+ function generateDeviceIdWithCrypto(): string {
217
+ const bytes = new Uint8Array(16)
218
+ globalThis.crypto.getRandomValues(bytes)
219
+
220
+ const byteSix = bytes[6] ?? 0
221
+ const byteEight = bytes[8] ?? 0
222
+ bytes[6] = (byteSix & 0x0f) | 0x40
223
+ bytes[8] = (byteEight & 0x3f) | 0x80
224
+
225
+ const hex = [...bytes].map((byte) => byte.toString(16).padStart(2, '0')).join('')
226
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`
227
+ }
228
+
229
+ function generateDeviceId(): string {
230
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
231
+ return globalThis.crypto.randomUUID().toLowerCase()
232
+ }
233
+
234
+ if (typeof globalThis.crypto?.getRandomValues === 'function') {
235
+ return generateDeviceIdWithCrypto()
236
+ }
237
+
238
+ return `${Date.now().toString(16).padStart(12, '0')}-fallback-device-id`
239
+ }
240
+
241
+ function createDeviceIdentifierHeadersProvider(
242
+ storageKey = DEFAULT_DEVICE_ID_STORAGE_KEY,
243
+ headerName = DEFAULT_DEVICE_ID_HEADER_NAME
244
+ ): () => Record<string, string> {
245
+ let cachedDeviceId: string | null = null
246
+
247
+ function getStoredDeviceId(): string | null {
248
+ try {
249
+ const storedValue = localStorage.getItem(storageKey)
250
+ if (storedValue && isUuid(storedValue)) {
251
+ return storedValue.toLowerCase()
252
+ }
253
+ } catch {
254
+ return null
255
+ }
256
+
257
+ return null
258
+ }
259
+
260
+ function persistDeviceId(deviceId: string): void {
261
+ try {
262
+ localStorage.setItem(storageKey, deviceId)
263
+ } catch {
264
+ // Ignore storage failures; request-level usage still works for this session.
265
+ }
266
+ }
267
+
268
+ function getOrCreateDeviceId(): string {
269
+ if (cachedDeviceId && isUuid(cachedDeviceId)) {
270
+ return cachedDeviceId
271
+ }
272
+
273
+ const storedDeviceId = getStoredDeviceId()
274
+ if (storedDeviceId) {
275
+ cachedDeviceId = storedDeviceId
276
+ return storedDeviceId
277
+ }
278
+
279
+ const generatedDeviceId = generateDeviceId()
280
+ if (!isUuid(generatedDeviceId)) {
281
+ return ''
282
+ }
283
+
284
+ const normalizedDeviceId = generatedDeviceId.toLowerCase()
285
+ cachedDeviceId = normalizedDeviceId
286
+ persistDeviceId(normalizedDeviceId)
287
+ return normalizedDeviceId
288
+ }
289
+
290
+ return (): Record<string, string> => {
291
+ const deviceId = getOrCreateDeviceId()
292
+ if (deviceId === '') {
293
+ return {}
294
+ }
295
+
296
+ return {
297
+ [headerName]: deviceId
298
+ }
299
+ }
300
+ }
301
+
302
+ function buildForgotPasswordPayload(
303
+ identifier: string,
304
+ strategy: 'email-only' | 'email-or-username'
305
+ ): Record<string, string> {
306
+ if (strategy === 'email-only') {
307
+ return { email: identifier.trim() }
308
+ }
309
+
310
+ const trimmedIdentifier = identifier.trim()
311
+ const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
312
+
313
+ if (emailPattern.test(trimmedIdentifier)) {
314
+ return { email: trimmedIdentifier }
315
+ }
316
+
317
+ return { username: trimmedIdentifier.replace(/^@+/, '') }
318
+ }
319
+
320
+ function withAuthorizationHeader(config: RetriableRequestConfig, token: string): RetriableRequestConfig {
321
+ return {
322
+ ...config,
323
+ headers: {
324
+ ...(config.headers as Record<string, string> | undefined),
325
+ Authorization: `Bearer ${token}`
326
+ }
327
+ }
328
+ }
329
+
330
+ function parseMissingPolicies(payload: unknown): MissingPolicy[] {
331
+ if (!isRecord(payload)) {
332
+ return []
333
+ }
334
+
335
+ const errors = payload.errors
336
+ if (!isRecord(errors)) {
337
+ return []
338
+ }
339
+
340
+ const missing = errors.missing
341
+ if (!Array.isArray(missing)) {
342
+ return []
343
+ }
344
+
345
+ const parsed: MissingPolicy[] = []
346
+
347
+ for (const item of missing) {
348
+ if (!isRecord(item)) {
349
+ continue
350
+ }
351
+
352
+ const policyId = item.policy_id
353
+ const version = item.version
354
+ if (typeof policyId !== 'string') {
355
+ continue
356
+ }
357
+
358
+ const parsedVersion = typeof version === 'number'
359
+ ? version
360
+ : typeof version === 'string'
361
+ ? Number.parseInt(version, 10)
362
+ : Number.NaN
363
+
364
+ if (!Number.isInteger(parsedVersion)) {
365
+ continue
366
+ }
367
+
368
+ parsed.push({
369
+ policy_id: policyId,
370
+ version: parsedVersion
371
+ })
372
+ }
373
+
374
+ return parsed
375
+ }
376
+
377
+ function dedupeMissingPolicies(policies: MissingPolicy[]): MissingPolicy[] {
378
+ const deduped = new Map<string, MissingPolicy>()
379
+
380
+ for (const policy of policies) {
381
+ const existing = deduped.get(policy.policy_id)
382
+ if (!existing || policy.version > existing.version) {
383
+ deduped.set(policy.policy_id, policy)
384
+ }
385
+ }
386
+
387
+ return [...deduped.values()]
388
+ }
389
+
390
+ function isPoliciesPendingErrorPayload(payload: unknown): boolean {
391
+ return isRecord(payload) && payload.code === 'POLICIES_PENDING'
392
+ }
393
+
394
+ function isPolicyAcceptRequest(url: unknown): boolean {
395
+ return typeof url === 'string' && url.includes('/v1/policy/accept')
396
+ }
397
+
398
+ /**
399
+ * Create a configured auth service instance.
400
+ *
401
+ * This is the conservative base service used by shared auth-store code.
402
+ */
403
+ export function createAuthService(config: AuthServiceConfig): AuthServiceInstance {
404
+ const {
405
+ baseURL,
406
+ tokenStorage,
407
+ timeout = 30000
408
+ } = config
409
+
410
+ const client = createConfiguredClient(baseURL, timeout)
411
+
412
+ client.interceptors.request.use((reqConfig) => {
413
+ const url = reqConfig.url ?? ''
414
+
415
+ if (tokenStorage.shouldSkipAuth(url)) {
416
+ return reqConfig
417
+ }
418
+
419
+ const token = tokenStorage.getToken()
420
+ if (token) {
421
+ reqConfig.headers.Authorization = `Bearer ${token}`
422
+ }
423
+
424
+ return reqConfig
425
+ })
426
+
427
+ return {
428
+ async login(loginCredential: string, password: string, remember = false): Promise<LoginResponse> {
429
+ const response = await client.post('/v1/auth/login', {
430
+ login: loginCredential,
431
+ password,
432
+ remember
433
+ })
434
+
435
+ const responseData = response.data
436
+ const token = extractTokenFromResponse(responseData)
437
+
438
+ if (token) {
439
+ tokenStorage.setToken(token)
440
+ }
441
+
442
+ return responseData.data ?? responseData
443
+ },
444
+
445
+ async register(data: RegisterData): Promise<LoginResponse> {
446
+ const response = await client.post('/v1/auth/register', data)
447
+
448
+ const responseData = response.data
449
+ const token = extractTokenFromResponse(responseData)
450
+
451
+ if (token) {
452
+ tokenStorage.setToken(token)
453
+ }
454
+
455
+ return responseData.data ?? responseData
456
+ },
457
+
458
+ async logout(): Promise<void> {
459
+ try {
460
+ await client.post('/v1/auth/logout')
461
+ } catch {
462
+ // Even if the API call fails, we clear local state.
463
+ } finally {
464
+ tokenStorage.removeToken()
465
+ }
466
+ },
467
+
468
+ async getUser(): Promise<User> {
469
+ const response = await client.get('/v1/me', {
470
+ params: { _t: Date.now() },
471
+ headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }
472
+ })
473
+
474
+ const rawPayload = isRecord(response.data?.data)
475
+ ? response.data.data
476
+ : isRecord(response.data)
477
+ ? response.data
478
+ : {}
479
+
480
+ return normalizeUserPayload(rawPayload)
481
+ },
482
+
483
+ async forgotPassword(email: string): Promise<unknown> {
484
+ const response = await client.post('/v1/auth/forgot-password', { email })
485
+ return response.data
486
+ },
487
+
488
+ async resetPassword(data: ResetPasswordData): Promise<unknown> {
489
+ const response = await client.post('/v1/auth/reset-password', data)
490
+
491
+ const token = extractTokenFromResponse(response.data)
492
+ if (token) {
493
+ tokenStorage.setToken(token)
494
+ }
495
+
496
+ return response.data
497
+ },
498
+
499
+ hasToken(): boolean {
500
+ return tokenStorage.hasToken()
501
+ },
502
+
503
+ clearToken(): void {
504
+ tokenStorage.removeToken()
505
+ }
506
+ }
507
+ }
508
+
509
+ /**
510
+ * Create a richer auth workflow service for apps that use 2FA and device-id
511
+ * headers during login/verification flows.
512
+ */
513
+ export function createExtendedAuthService(config: ExtendedAuthServiceConfig): ExtendedAuthServiceInstance {
514
+ const {
515
+ baseURL,
516
+ tokenStorage,
517
+ timeout = 30000,
518
+ client,
519
+ deviceIdStorageKey = DEFAULT_DEVICE_ID_STORAGE_KEY,
520
+ deviceIdHeaderName = DEFAULT_DEVICE_ID_HEADER_NAME,
521
+ forgotPasswordIdentifierStrategy = 'email-or-username'
522
+ } = config
523
+
524
+ const authClient = createConfiguredClient(baseURL, timeout, client)
525
+ const getDeviceIdentifierHeaders = createDeviceIdentifierHeadersProvider(
526
+ deviceIdStorageKey,
527
+ deviceIdHeaderName
528
+ )
529
+
530
+ return {
531
+ async login(loginCredential: string, password: string, remember = false): Promise<AuthLoginResult> {
532
+ const response = await authClient.post('/v1/auth/login', {
533
+ login: loginCredential,
534
+ password,
535
+ remember
536
+ }, {
537
+ headers: getDeviceIdentifierHeaders()
538
+ })
539
+
540
+ const responseData = response.data
541
+ const inner = responseData.data ?? responseData
542
+
543
+ if (inner.requires_2fa === true && typeof inner.challenge_id === 'string') {
544
+ return {
545
+ requires_2fa: true,
546
+ challenge_id: inner.challenge_id
547
+ }
548
+ }
549
+
550
+ const token = extractTokenFromResponse(responseData)
551
+ if (token) {
552
+ tokenStorage.setToken(token)
553
+ }
554
+
555
+ return inner as AuthenticatedLoginResponse
556
+ },
557
+
558
+ async register(data: RegisterData): Promise<RegisterResponse> {
559
+ const response = await authClient.post('/v1/auth/register', data)
560
+ const responseData = response.data
561
+ const token = extractTokenFromResponse(responseData)
562
+
563
+ if (token) {
564
+ tokenStorage.setToken(token)
565
+ }
566
+
567
+ const result = responseData.data ?? responseData
568
+
569
+ if (!token) {
570
+ return {
571
+ requires_verification: true
572
+ }
573
+ }
574
+
575
+ return {
576
+ ...result,
577
+ requires_verification: false
578
+ } as AuthenticatedRegisterResponse
579
+ },
580
+
581
+ async verify2faChallenge(
582
+ challengeId: string,
583
+ code?: string,
584
+ recoveryCode?: string,
585
+ rememberDevice = false,
586
+ rememberSession = false
587
+ ): Promise<AuthenticatedLoginResponse> {
588
+ const payload: Record<string, unknown> = {
589
+ challenge_id: challengeId,
590
+ remember_session: rememberSession
591
+ }
592
+
593
+ if (code) {
594
+ payload.code = code
595
+ }
596
+
597
+ if (recoveryCode) {
598
+ payload.recovery_code = recoveryCode
599
+ }
600
+
601
+ if (rememberDevice) {
602
+ payload.remember_device = true
603
+ }
604
+
605
+ const response = await authClient.post('/v1/auth/2fa/verify', payload, {
606
+ headers: getDeviceIdentifierHeaders()
607
+ })
608
+ const responseData = response.data
609
+ const token = extractTokenFromResponse(responseData)
610
+
611
+ if (token) {
612
+ tokenStorage.setToken(token)
613
+ }
614
+
615
+ return (responseData.data ?? responseData) as AuthenticatedLoginResponse
616
+ },
617
+
618
+ async logout(): Promise<void> {
619
+ try {
620
+ await authClient.post('/v1/auth/logout')
621
+ } catch {
622
+ // Even if the API call fails, we clear local state.
623
+ } finally {
624
+ tokenStorage.removeToken()
625
+ }
626
+ },
627
+
628
+ async getUser(): Promise<User> {
629
+ const response = await authClient.get('/v1/me', {
630
+ params: { _t: Date.now() },
631
+ headers: { 'Cache-Control': 'no-cache', Pragma: 'no-cache' }
632
+ })
633
+
634
+ const rawPayload = isRecord(response.data?.data)
635
+ ? response.data.data
636
+ : isRecord(response.data)
637
+ ? response.data
638
+ : {}
639
+
640
+ return normalizeUserPayload(rawPayload)
641
+ },
642
+
643
+ async forgotPassword(identifier: string): Promise<unknown> {
644
+ const response = await authClient.post(
645
+ '/v1/auth/forgot-password',
646
+ buildForgotPasswordPayload(identifier, forgotPasswordIdentifierStrategy)
647
+ )
648
+
649
+ return response.data
650
+ },
651
+
652
+ async resetPassword(data: ResetPasswordData): Promise<unknown> {
653
+ const response = await authClient.post('/v1/auth/reset-password', data)
654
+
655
+ const token = extractTokenFromResponse(response.data)
656
+ if (token) {
657
+ tokenStorage.setToken(token)
658
+ }
659
+
660
+ return response.data
661
+ },
662
+
663
+ hasToken(): boolean {
664
+ return tokenStorage.hasToken()
665
+ },
666
+
667
+ clearToken(): void {
668
+ tokenStorage.removeToken()
669
+ }
670
+ }
671
+ }
672
+
673
+ /**
674
+ * Configure a shared Axios client with generic token-auth request/response
675
+ * interceptors while preserving the raw Axios surface for host apps.
676
+ */
677
+ export function configureTokenAuthClient(config: TokenAuthClientConfig): ConfiguredTokenAuthClient {
678
+ const {
679
+ client = axios,
680
+ baseURL,
681
+ tokenStorage,
682
+ timeout = 30000,
683
+ authFailureRedirectDelayMs = DEFAULT_AUTH_FAILURE_REDIRECT_DELAY_MS,
684
+ authPagePaths = DEFAULT_AUTH_PAGE_PATHS,
685
+ buildLoginRedirectUrl,
686
+ onAuthInvalidated,
687
+ onResponseError,
688
+ shouldSuppressResponseError
689
+ } = config
690
+
691
+ client.defaults.baseURL = baseURL
692
+ client.defaults.timeout = timeout
693
+ client.defaults.headers.common.Accept = 'application/json'
694
+ client.defaults.headers.common['Content-Type'] = 'application/json'
695
+ client.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest'
696
+ client.defaults.withCredentials = false
697
+
698
+ let isValidatingAuth = false
699
+ let authFailureRedirectTimeoutId: number | null = null
700
+ let authFailureQueuedToken: string | null = null
701
+ let policyAcceptancePromise: Promise<boolean> | null = null
702
+
703
+ function cancelQueuedAuthFailureRedirect(): void {
704
+ if (authFailureRedirectTimeoutId === null) {
705
+ return
706
+ }
707
+
708
+ window.clearTimeout(authFailureRedirectTimeoutId)
709
+ authFailureRedirectTimeoutId = null
710
+ authFailureQueuedToken = null
711
+ }
712
+
713
+ function queueAuthFailureRedirect(): void {
714
+ if (authFailureRedirectTimeoutId !== null) {
715
+ return
716
+ }
717
+
718
+ authFailureQueuedToken = tokenStorage.getToken()
719
+
720
+ authFailureRedirectTimeoutId = window.setTimeout(() => {
721
+ authFailureRedirectTimeoutId = null
722
+ const queuedToken = authFailureQueuedToken
723
+ authFailureQueuedToken = null
724
+ const currentToken = tokenStorage.getToken()
725
+
726
+ if (
727
+ queuedToken !== null &&
728
+ currentToken !== null &&
729
+ currentToken !== queuedToken
730
+ ) {
731
+ return
732
+ }
733
+
734
+ onAuthInvalidated?.()
735
+ tokenStorage.removeToken()
736
+
737
+ const currentPath = window.location.pathname
738
+ if (authPagePaths.includes(currentPath)) {
739
+ return
740
+ }
741
+
742
+ const redirectUrl = buildLoginRedirectUrl?.() ?? '/login'
743
+ window.location.assign(redirectUrl)
744
+ }, authFailureRedirectDelayMs)
745
+ }
746
+
747
+ async function acceptMissingPolicies(missingPolicies: MissingPolicy[]): Promise<boolean> {
748
+ const policiesToAccept = dedupeMissingPolicies(missingPolicies)
749
+ if (policiesToAccept.length === 0) {
750
+ return false
751
+ }
752
+
753
+ if (policyAcceptancePromise) {
754
+ return policyAcceptancePromise
755
+ }
756
+
757
+ policyAcceptancePromise = (async () => {
758
+ try {
759
+ for (const policy of policiesToAccept) {
760
+ await client.post('/v1/policy/accept', {
761
+ policy_id: policy.policy_id,
762
+ version: policy.version
763
+ })
764
+ }
765
+
766
+ return true
767
+ } catch {
768
+ return false
769
+ } finally {
770
+ policyAcceptancePromise = null
771
+ }
772
+ })()
773
+
774
+ return policyAcceptancePromise
775
+ }
776
+
777
+ client.interceptors.request.use((reqConfig) => {
778
+ const url = reqConfig.url ?? ''
779
+
780
+ if (tokenStorage.shouldSkipAuth(url)) {
781
+ return reqConfig
782
+ }
783
+
784
+ const token = tokenStorage.getToken()
785
+ if (token) {
786
+ reqConfig.headers.Authorization = `Bearer ${token}`
787
+ }
788
+
789
+ if (typeof FormData !== 'undefined' && reqConfig.data instanceof FormData) {
790
+ delete reqConfig.headers['Content-Type']
791
+ delete reqConfig.headers['content-type']
792
+ }
793
+
794
+ return reqConfig
795
+ })
796
+
797
+ client.interceptors.response.use(
798
+ (response) => {
799
+ cancelQueuedAuthFailureRedirect()
800
+ return response
801
+ },
802
+ async (error) => {
803
+ if (error.response?.status === 428) {
804
+ const originalRequest = error.config as RetriableRequestConfig | undefined
805
+ const requestUrl = originalRequest?.url
806
+ const responsePayload = error.response?.data
807
+ const missingPolicies = parseMissingPolicies(responsePayload)
808
+ const canAttemptPolicyAcceptance = Boolean(
809
+ originalRequest &&
810
+ !originalRequest._policyRetry &&
811
+ !isPolicyAcceptRequest(requestUrl) &&
812
+ isPoliciesPendingErrorPayload(responsePayload) &&
813
+ missingPolicies.length > 0
814
+ )
815
+
816
+ if (canAttemptPolicyAcceptance && originalRequest) {
817
+ originalRequest._policyRetry = true
818
+ const accepted = await acceptMissingPolicies(missingPolicies)
819
+
820
+ if (accepted) {
821
+ return client(originalRequest)
822
+ }
823
+ }
824
+ }
825
+
826
+ if (error.response?.status === 401) {
827
+ const originalRequest = error.config as RetriableRequestConfig | undefined
828
+ const requestUrl = originalRequest?.url
829
+ const canAttemptRefresh = Boolean(
830
+ originalRequest &&
831
+ !originalRequest._retry &&
832
+ !tokenStorage.shouldSkipAuth(requestUrl) &&
833
+ !tokenStorage.isRefreshTokenEndpoint(requestUrl)
834
+ )
835
+
836
+ if (canAttemptRefresh && originalRequest) {
837
+ originalRequest._retry = true
838
+ const refreshedToken = await tokenStorage.tryRefreshToken()
839
+
840
+ if (refreshedToken) {
841
+ cancelQueuedAuthFailureRedirect()
842
+ const retryConfig = withAuthorizationHeader(originalRequest, refreshedToken)
843
+ return client(retryConfig)
844
+ }
845
+ }
846
+
847
+ if (tokenStorage.wasTokenRotatedSince(originalRequest?.headers)) {
848
+ if (!shouldSuppressResponseError?.(error)) {
849
+ onResponseError?.(error)
850
+ }
851
+
852
+ return Promise.reject(error)
853
+ }
854
+
855
+ if (!isValidatingAuth) {
856
+ queueAuthFailureRedirect()
857
+ }
858
+ }
859
+
860
+ if (!shouldSuppressResponseError?.(error)) {
861
+ onResponseError?.(error)
862
+ }
863
+
864
+ return Promise.reject(error)
865
+ }
866
+ )
867
+
868
+ return {
869
+ client,
870
+ setValidatingAuth(value: boolean): void {
871
+ isValidatingAuth = value
872
+ }
873
+ }
874
+ }