@reauth-dev/sdk 0.1.0 → 0.3.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.
@@ -23,16 +23,35 @@ __export(react_exports, {
23
23
  AuthProvider: () => AuthProvider,
24
24
  ProtectedRoute: () => ProtectedRoute,
25
25
  useAuth: () => useAuth,
26
- useAuthContext: () => useAuthContext
26
+ useAuthContext: () => useAuthContext,
27
+ useHeadlessAuth: () => useHeadlessAuth
27
28
  });
28
29
  module.exports = __toCommonJS(react_exports);
29
30
 
30
31
  // src/react/useAuth.ts
31
32
  var import_react = require("react");
32
33
 
34
+ // src/types.ts
35
+ var DEFAULT_TIMEOUT_MS = 1e4;
36
+
33
37
  // src/client.ts
38
+ function assertHttpsUrl(url) {
39
+ let parsed;
40
+ try {
41
+ parsed = new URL(url);
42
+ } catch {
43
+ throw new Error("URL must use HTTPS");
44
+ }
45
+ if (parsed.protocol !== "https:") {
46
+ throw new Error("URL must use HTTPS");
47
+ }
48
+ }
34
49
  function createReauthClient(config) {
35
50
  const { domain } = config;
51
+ if (config.timeout !== void 0 && (!Number.isFinite(config.timeout) || config.timeout <= 0)) {
52
+ throw new Error("timeout must be a positive finite number in milliseconds");
53
+ }
54
+ const timeoutMs = config.timeout ?? DEFAULT_TIMEOUT_MS;
36
55
  const baseUrl = `https://reauth.${domain}/api/public`;
37
56
  return {
38
57
  /**
@@ -51,35 +70,47 @@ function createReauthClient(config) {
51
70
  */
52
71
  async getSession() {
53
72
  const res = await fetch(`${baseUrl}/auth/session`, {
54
- credentials: "include"
73
+ credentials: "include",
74
+ signal: AbortSignal.timeout(timeoutMs)
55
75
  });
76
+ if (!res.ok) {
77
+ throw new Error(`Failed to get session: ${res.status}`);
78
+ }
56
79
  return res.json();
57
80
  },
58
81
  /**
59
82
  * Refresh the access token using the refresh token.
60
83
  * Call this when getSession() returns valid: false but no error_code.
61
- * @returns true if refresh succeeded, false otherwise
84
+ * @throws Error on failed refresh (401) or server error
62
85
  */
63
86
  async refresh() {
64
87
  const res = await fetch(`${baseUrl}/auth/refresh`, {
65
88
  method: "POST",
66
- credentials: "include"
89
+ credentials: "include",
90
+ signal: AbortSignal.timeout(timeoutMs)
67
91
  });
68
- return res.ok;
92
+ if (!res.ok) {
93
+ throw new Error(`Failed to refresh: ${res.status}`);
94
+ }
69
95
  },
70
96
  /**
71
97
  * Get an access token for Bearer authentication.
72
98
  * Use this when calling your own API that uses local token verification.
73
99
  *
74
- * @returns TokenResponse with access token, or null if not authenticated
100
+ * @returns TokenResponse with access token, or null if not authenticated or network unreachable
101
+ * @throws Error on server errors (non-401 HTTP status codes) or request timeout
75
102
  *
76
103
  * @example
77
104
  * ```typescript
78
- * const tokenResponse = await reauth.getToken();
79
- * if (tokenResponse) {
80
- * fetch('/api/data', {
81
- * headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }
82
- * });
105
+ * try {
106
+ * const tokenResponse = await reauth.getToken();
107
+ * if (tokenResponse) {
108
+ * fetch('/api/data', {
109
+ * headers: { Authorization: `Bearer ${tokenResponse.accessToken}` }
110
+ * });
111
+ * }
112
+ * } catch (err) {
113
+ * // Server error or timeout — do not log the user out
83
114
  * }
84
115
  * ```
85
116
  */
@@ -87,7 +118,8 @@ function createReauthClient(config) {
87
118
  try {
88
119
  const res = await fetch(`${baseUrl}/auth/token`, {
89
120
  method: "GET",
90
- credentials: "include"
121
+ credentials: "include",
122
+ signal: AbortSignal.timeout(timeoutMs)
91
123
  });
92
124
  if (!res.ok) {
93
125
  if (res.status === 401) return null;
@@ -99,29 +131,158 @@ function createReauthClient(config) {
99
131
  expiresIn: data.expires_in,
100
132
  tokenType: data.token_type
101
133
  };
102
- } catch {
103
- return null;
134
+ } catch (err) {
135
+ if (err instanceof TypeError) return null;
136
+ throw err;
104
137
  }
105
138
  },
106
139
  /**
107
140
  * Log out the user by clearing all session cookies.
141
+ * @throws Error on server error
108
142
  */
109
143
  async logout() {
110
- await fetch(`${baseUrl}/auth/logout`, {
144
+ const res = await fetch(`${baseUrl}/auth/logout`, {
111
145
  method: "POST",
112
- credentials: "include"
146
+ credentials: "include",
147
+ signal: AbortSignal.timeout(timeoutMs)
113
148
  });
149
+ if (!res.ok) {
150
+ throw new Error(`Failed to logout: ${res.status}`);
151
+ }
114
152
  },
115
153
  /**
116
154
  * Delete the user's own account (self-service).
117
- * @returns true if deletion succeeded, false otherwise
155
+ * @throws Error on permission denied or server error
118
156
  */
119
157
  async deleteAccount() {
120
158
  const res = await fetch(`${baseUrl}/auth/account`, {
121
159
  method: "DELETE",
122
- credentials: "include"
160
+ credentials: "include",
161
+ signal: AbortSignal.timeout(timeoutMs)
123
162
  });
124
- return res.ok;
163
+ if (!res.ok) {
164
+ throw new Error(`Failed to delete account: ${res.status}`);
165
+ }
166
+ },
167
+ // ========================================================================
168
+ // Headless Auth Methods
169
+ // ========================================================================
170
+ /**
171
+ * Get the domain's public auth configuration.
172
+ * Returns enabled auth methods and whether headless auth is available.
173
+ */
174
+ async getConfig() {
175
+ const res = await fetch(`${baseUrl}/config`, {
176
+ credentials: "include",
177
+ signal: AbortSignal.timeout(timeoutMs)
178
+ });
179
+ if (!res.ok) {
180
+ throw new Error(`Failed to get config: ${res.status}`);
181
+ }
182
+ const data = await res.json();
183
+ return {
184
+ domain: data.domain,
185
+ authMethods: {
186
+ magicLink: data.auth_methods.magic_link,
187
+ googleOauth: data.auth_methods.google_oauth,
188
+ twitterOauth: data.auth_methods.twitter_oauth
189
+ },
190
+ redirectUrl: data.redirect_url,
191
+ headlessEnabled: data.headless_enabled
192
+ };
193
+ },
194
+ /**
195
+ * Request a magic link email for headless authentication.
196
+ * When callbackUrl is provided, the email link will point to your custom URL
197
+ * with the token as a query parameter.
198
+ * @param opts Options including email and optional callbackUrl
199
+ */
200
+ async requestMagicLink(opts) {
201
+ const body = { email: opts.email };
202
+ if (opts.callbackUrl) {
203
+ body.callback_url = opts.callbackUrl;
204
+ }
205
+ const res = await fetch(`${baseUrl}/auth/request-magic-link`, {
206
+ method: "POST",
207
+ headers: { "Content-Type": "application/json" },
208
+ credentials: "include",
209
+ signal: AbortSignal.timeout(timeoutMs),
210
+ body: JSON.stringify(body)
211
+ });
212
+ if (!res.ok) {
213
+ const err = await res.json().catch(() => ({}));
214
+ throw new Error(err.message || `Failed to request magic link: ${res.status}`);
215
+ }
216
+ },
217
+ /**
218
+ * Verify a magic link token received from the email callback URL.
219
+ * On success, session cookies are set automatically.
220
+ * @param opts Options including the token from the callback URL
221
+ */
222
+ async verifyMagicLink(opts) {
223
+ const res = await fetch(`${baseUrl}/auth/verify-magic-link`, {
224
+ method: "POST",
225
+ headers: { "Content-Type": "application/json" },
226
+ credentials: "include",
227
+ signal: AbortSignal.timeout(timeoutMs),
228
+ body: JSON.stringify({ token: opts.token })
229
+ });
230
+ if (!res.ok) {
231
+ const err = await res.json().catch(() => ({}));
232
+ throw new Error(err.message || `Failed to verify magic link: ${res.status}`);
233
+ }
234
+ const data = await res.json();
235
+ return {
236
+ success: data.success,
237
+ redirectUrl: data.redirect_url,
238
+ endUserId: data.end_user_id,
239
+ email: data.email,
240
+ waitlistPosition: data.waitlist_position
241
+ };
242
+ },
243
+ /**
244
+ * Start a Google OAuth flow for headless authentication.
245
+ * Returns the Google authorization URL to redirect the user to.
246
+ * After Google authentication, the portal handles the callback and
247
+ * redirects to the domain's configured redirect_url.
248
+ */
249
+ async startGoogleOAuth() {
250
+ const res = await fetch(`${baseUrl}/auth/google/start`, {
251
+ method: "POST",
252
+ credentials: "include",
253
+ signal: AbortSignal.timeout(timeoutMs)
254
+ });
255
+ if (!res.ok) {
256
+ const err = await res.json().catch(() => ({}));
257
+ throw new Error(err.message || `Failed to start Google OAuth: ${res.status}`);
258
+ }
259
+ const data = await res.json();
260
+ return {
261
+ authUrl: data.auth_url,
262
+ state: data.state
263
+ };
264
+ },
265
+ /**
266
+ * Start an X (Twitter) OAuth flow for headless authentication.
267
+ * Returns the X authorization URL to redirect the user to.
268
+ * After X authentication, the portal handles the callback and
269
+ * redirects to the domain's configured redirect_url.
270
+ */
271
+ async startTwitterOAuth() {
272
+ const res = await fetch(`${baseUrl}/auth/twitter/start`, {
273
+ method: "POST",
274
+ credentials: "include",
275
+ signal: AbortSignal.timeout(timeoutMs)
276
+ });
277
+ if (!res.ok) {
278
+ const err = await res.json().catch(() => ({}));
279
+ throw new Error(err.message || `Failed to start Twitter OAuth: ${res.status}`);
280
+ }
281
+ const data = await res.json();
282
+ return {
283
+ authUrl: data.auth_url,
284
+ state: data.state
285
+ };
125
286
  },
126
287
  // ========================================================================
127
288
  // Billing Methods
@@ -129,12 +290,16 @@ function createReauthClient(config) {
129
290
  /**
130
291
  * Get available subscription plans for the domain.
131
292
  * Only returns public plans sorted by display order.
293
+ * @throws Error on server error
132
294
  */
133
295
  async getPlans() {
134
296
  const res = await fetch(`${baseUrl}/billing/plans`, {
135
- credentials: "include"
297
+ credentials: "include",
298
+ signal: AbortSignal.timeout(timeoutMs)
136
299
  });
137
- if (!res.ok) return [];
300
+ if (!res.ok) {
301
+ throw new Error(`Failed to get plans: ${res.status}`);
302
+ }
138
303
  const data = await res.json();
139
304
  return data.map(
140
305
  (p) => ({
@@ -147,8 +312,17 @@ function createReauthClient(config) {
147
312
  interval: p.interval,
148
313
  intervalCount: p.interval_count,
149
314
  trialDays: p.trial_days,
150
- features: p.features,
151
- displayOrder: p.display_order
315
+ features: p.features.map((f) => ({
316
+ code: f.code,
317
+ name: f.name,
318
+ featureType: f.feature_type,
319
+ numericValue: f.numeric_value,
320
+ unitLabel: f.unit_label
321
+ })),
322
+ displayOrder: p.display_order,
323
+ creditsAmount: p.credits_amount,
324
+ planType: p.plan_type,
325
+ contactUrl: p.contact_url
152
326
  })
153
327
  );
154
328
  },
@@ -157,8 +331,12 @@ function createReauthClient(config) {
157
331
  */
158
332
  async getSubscription() {
159
333
  const res = await fetch(`${baseUrl}/billing/subscription`, {
160
- credentials: "include"
334
+ credentials: "include",
335
+ signal: AbortSignal.timeout(timeoutMs)
161
336
  });
337
+ if (!res.ok) {
338
+ throw new Error(`Failed to get subscription: ${res.status}`);
339
+ }
162
340
  const data = await res.json();
163
341
  return {
164
342
  id: data.id,
@@ -182,6 +360,7 @@ function createReauthClient(config) {
182
360
  method: "POST",
183
361
  headers: { "Content-Type": "application/json" },
184
362
  credentials: "include",
363
+ signal: AbortSignal.timeout(timeoutMs),
185
364
  body: JSON.stringify({
186
365
  plan_code: planCode,
187
366
  success_url: successUrl,
@@ -210,6 +389,7 @@ function createReauthClient(config) {
210
389
  currentUrl,
211
390
  currentUrl
212
391
  );
392
+ assertHttpsUrl(checkoutUrl);
213
393
  window.location.href = checkoutUrl;
214
394
  },
215
395
  /**
@@ -224,6 +404,7 @@ function createReauthClient(config) {
224
404
  method: "POST",
225
405
  headers: { "Content-Type": "application/json" },
226
406
  credentials: "include",
407
+ signal: AbortSignal.timeout(timeoutMs),
227
408
  body: JSON.stringify({
228
409
  return_url: returnUrl || window.location.href
229
410
  })
@@ -232,18 +413,22 @@ function createReauthClient(config) {
232
413
  throw new Error("Failed to open billing portal");
233
414
  }
234
415
  const data = await res.json();
416
+ assertHttpsUrl(data.portal_url);
235
417
  window.location.href = data.portal_url;
236
418
  },
237
419
  /**
238
420
  * Cancel the user's subscription at period end.
239
- * @returns true if cancellation succeeded
421
+ * @throws Error on failure or server error
240
422
  */
241
423
  async cancelSubscription() {
242
424
  const res = await fetch(`${baseUrl}/billing/cancel`, {
243
425
  method: "POST",
244
- credentials: "include"
426
+ credentials: "include",
427
+ signal: AbortSignal.timeout(timeoutMs)
245
428
  });
246
- return res.ok;
429
+ if (!res.ok) {
430
+ throw new Error(`Failed to cancel subscription: ${res.status}`);
431
+ }
247
432
  },
248
433
  // ========================================================================
249
434
  // Balance Methods
@@ -254,13 +439,15 @@ function createReauthClient(config) {
254
439
  */
255
440
  async getBalance() {
256
441
  const res = await fetch(`${baseUrl}/balance`, {
257
- credentials: "include"
442
+ credentials: "include",
443
+ signal: AbortSignal.timeout(timeoutMs)
258
444
  });
259
445
  if (!res.ok) {
260
446
  if (res.status === 401) throw new Error("Not authenticated");
261
447
  throw new Error(`Failed to get balance: ${res.status}`);
262
448
  }
263
- return res.json();
449
+ const data = await res.json();
450
+ return { balance: data.balance };
264
451
  },
265
452
  /**
266
453
  * Get the current user's balance transaction history.
@@ -274,7 +461,7 @@ function createReauthClient(config) {
274
461
  const qs = params.toString();
275
462
  const res = await fetch(
276
463
  `${baseUrl}/balance/transactions${qs ? `?${qs}` : ""}`,
277
- { credentials: "include" }
464
+ { credentials: "include", signal: AbortSignal.timeout(timeoutMs) }
278
465
  );
279
466
  if (!res.ok) {
280
467
  if (res.status === 401) throw new Error("Not authenticated");
@@ -292,13 +479,602 @@ function createReauthClient(config) {
292
479
  })
293
480
  )
294
481
  };
482
+ },
483
+ // ========================================================================
484
+ // Credits Methods
485
+ // ========================================================================
486
+ /**
487
+ * Get the domain's credits configuration.
488
+ * Returns exchange rate, display settings, and purchase limits.
489
+ */
490
+ async getCreditsConfig() {
491
+ const res = await fetch(`${baseUrl}/billing/credits-config`, {
492
+ credentials: "include",
493
+ signal: AbortSignal.timeout(timeoutMs)
494
+ });
495
+ if (!res.ok) {
496
+ if (res.status === 401) throw new Error("Not authenticated");
497
+ throw new Error(`Failed to get credits config: ${res.status}`);
498
+ }
499
+ const data = await res.json();
500
+ return {
501
+ creditsEnabled: data.credits_enabled,
502
+ creditsPerDollar: data.credits_per_dollar,
503
+ displayName: data.display_name,
504
+ displaySymbol: data.display_symbol,
505
+ displaySymbolPosition: data.display_symbol_position,
506
+ displayDecimals: data.display_decimals,
507
+ minPurchaseCents: data.min_purchase_cents,
508
+ maxPurchaseCents: data.max_purchase_cents,
509
+ manualTopUpAvailable: data.manual_top_up_available,
510
+ autoTopUpAvailable: data.auto_top_up_available,
511
+ overdrawEnabled: data.overdraw_enabled
512
+ };
513
+ },
514
+ // ========================================================================
515
+ // Payment Method Methods
516
+ // ========================================================================
517
+ /**
518
+ * List the current user's stored payment methods.
519
+ * Sorted by priority (lower number = higher priority).
520
+ */
521
+ async getPaymentMethods() {
522
+ const res = await fetch(`${baseUrl}/billing/payment-methods`, {
523
+ credentials: "include",
524
+ signal: AbortSignal.timeout(timeoutMs)
525
+ });
526
+ if (!res.ok) {
527
+ if (res.status === 401) throw new Error("Not authenticated");
528
+ throw new Error(`Failed to get payment methods: ${res.status}`);
529
+ }
530
+ const data = await res.json();
531
+ return data.map(
532
+ (pm) => ({
533
+ id: pm.id,
534
+ provider: pm.provider,
535
+ methodType: pm.method_type,
536
+ cardBrand: pm.card_brand,
537
+ cardLast4: pm.card_last4,
538
+ cardExpMonth: pm.card_exp_month,
539
+ cardExpYear: pm.card_exp_year,
540
+ priority: pm.priority,
541
+ createdAt: pm.created_at
542
+ })
543
+ );
544
+ },
545
+ /**
546
+ * Create a Stripe SetupIntent for adding a new payment method.
547
+ * Use the returned clientSecret with Stripe.js `confirmCardSetup()`.
548
+ */
549
+ async createSetupIntent() {
550
+ const res = await fetch(`${baseUrl}/billing/setup-intent`, {
551
+ method: "POST",
552
+ credentials: "include",
553
+ signal: AbortSignal.timeout(timeoutMs)
554
+ });
555
+ if (!res.ok) {
556
+ if (res.status === 401) throw new Error("Not authenticated");
557
+ throw new Error(`Failed to create setup intent: ${res.status}`);
558
+ }
559
+ const data = await res.json();
560
+ return {
561
+ clientSecret: data.client_secret,
562
+ setupIntentId: data.setup_intent_id
563
+ };
564
+ },
565
+ /**
566
+ * Delete a stored payment method.
567
+ * @param id UUID of the payment method to delete
568
+ */
569
+ async deletePaymentMethod(id) {
570
+ const res = await fetch(`${baseUrl}/billing/payment-methods/${id}`, {
571
+ method: "DELETE",
572
+ credentials: "include",
573
+ signal: AbortSignal.timeout(timeoutMs)
574
+ });
575
+ if (!res.ok) {
576
+ if (res.status === 401) throw new Error("Not authenticated");
577
+ if (res.status === 404) throw new Error("Payment method not found");
578
+ throw new Error(`Failed to delete payment method: ${res.status}`);
579
+ }
580
+ },
581
+ /**
582
+ * Reorder payment method priorities.
583
+ * @param paymentMethodIds Array of payment method UUIDs in desired priority order
584
+ */
585
+ async reorderPaymentMethods(paymentMethodIds) {
586
+ const res = await fetch(`${baseUrl}/billing/payment-methods/reorder`, {
587
+ method: "PUT",
588
+ headers: { "Content-Type": "application/json" },
589
+ credentials: "include",
590
+ signal: AbortSignal.timeout(timeoutMs),
591
+ body: JSON.stringify({ payment_method_ids: paymentMethodIds })
592
+ });
593
+ if (!res.ok) {
594
+ if (res.status === 401) throw new Error("Not authenticated");
595
+ throw new Error(`Failed to reorder payment methods: ${res.status}`);
596
+ }
597
+ },
598
+ /**
599
+ * Purchase credits using a stored payment method.
600
+ * @param opts Purchase options (amount, payment method, idempotency key)
601
+ */
602
+ async purchaseCredits(opts) {
603
+ const res = await fetch(`${baseUrl}/billing/credits/purchase`, {
604
+ method: "POST",
605
+ headers: { "Content-Type": "application/json" },
606
+ credentials: "include",
607
+ signal: AbortSignal.timeout(timeoutMs),
608
+ body: JSON.stringify({
609
+ amount_cents: opts.amountCents,
610
+ payment_method_id: opts.paymentMethodId,
611
+ idempotency_key: opts.idempotencyKey
612
+ })
613
+ });
614
+ if (!res.ok) {
615
+ if (res.status === 401) throw new Error("Not authenticated");
616
+ const err = await res.json().catch(() => ({}));
617
+ throw new Error(err.message || `Failed to purchase credits: ${res.status}`);
618
+ }
619
+ const data = await res.json();
620
+ return {
621
+ creditsPurchased: data.credits_purchased,
622
+ newBalance: data.new_balance,
623
+ paymentIntentId: data.payment_intent_id
624
+ };
625
+ },
626
+ // ========================================================================
627
+ // Auto Top-Up Methods
628
+ // ========================================================================
629
+ /**
630
+ * Get the current user's auto top-up configuration and status.
631
+ */
632
+ async getAutoTopUpStatus() {
633
+ const res = await fetch(`${baseUrl}/billing/auto-top-up`, {
634
+ credentials: "include",
635
+ signal: AbortSignal.timeout(timeoutMs)
636
+ });
637
+ if (!res.ok) {
638
+ if (res.status === 401) throw new Error("Not authenticated");
639
+ throw new Error(`Failed to get auto top-up status: ${res.status}`);
640
+ }
641
+ const data = await res.json();
642
+ return {
643
+ enabled: data.enabled,
644
+ thresholdCents: data.threshold_cents,
645
+ purchaseAmountCents: data.purchase_amount_cents,
646
+ status: data.status,
647
+ lastFailureReason: data.last_failure_reason,
648
+ retriesRemaining: data.retries_remaining,
649
+ nextRetryAt: data.next_retry_at
650
+ };
651
+ },
652
+ /**
653
+ * Update the current user's auto top-up configuration.
654
+ * @param opts New auto top-up settings
655
+ */
656
+ async updateAutoTopUp(opts) {
657
+ const res = await fetch(`${baseUrl}/billing/auto-top-up`, {
658
+ method: "PUT",
659
+ headers: { "Content-Type": "application/json" },
660
+ credentials: "include",
661
+ signal: AbortSignal.timeout(timeoutMs),
662
+ body: JSON.stringify({
663
+ enabled: opts.enabled,
664
+ threshold_cents: opts.thresholdCents,
665
+ purchase_amount_cents: opts.purchaseAmountCents
666
+ })
667
+ });
668
+ if (!res.ok) {
669
+ if (res.status === 401) throw new Error("Not authenticated");
670
+ const err = await res.json().catch(() => ({}));
671
+ throw new Error(err.message || `Failed to update auto top-up: ${res.status}`);
672
+ }
673
+ const data = await res.json();
674
+ return {
675
+ enabled: data.enabled,
676
+ thresholdCents: data.threshold_cents,
677
+ purchaseAmountCents: data.purchase_amount_cents,
678
+ status: data.status,
679
+ lastFailureReason: data.last_failure_reason,
680
+ retriesRemaining: data.retries_remaining,
681
+ nextRetryAt: data.next_retry_at
682
+ };
683
+ },
684
+ // ========================================================================
685
+ // Organization Methods
686
+ // ========================================================================
687
+ /**
688
+ * Create a new organization.
689
+ * @param name The organization name
690
+ * @returns The created organization
691
+ */
692
+ async createOrg(name) {
693
+ const res = await fetch(`${baseUrl}/auth/orgs`, {
694
+ method: "POST",
695
+ headers: { "Content-Type": "application/json" },
696
+ credentials: "include",
697
+ signal: AbortSignal.timeout(timeoutMs),
698
+ body: JSON.stringify({ name })
699
+ });
700
+ if (!res.ok) {
701
+ const err = await res.json().catch(() => ({}));
702
+ throw new Error(err.message || `Failed to create org: ${res.status}`);
703
+ }
704
+ const data = await res.json();
705
+ return transformOrg(data);
706
+ },
707
+ /**
708
+ * List all organizations the current user belongs to.
709
+ */
710
+ async listOrgs() {
711
+ const res = await fetch(`${baseUrl}/auth/orgs`, {
712
+ credentials: "include",
713
+ signal: AbortSignal.timeout(timeoutMs)
714
+ });
715
+ if (!res.ok) {
716
+ throw new Error(`Failed to list orgs: ${res.status}`);
717
+ }
718
+ const data = await res.json();
719
+ return data.map(transformOrg);
720
+ },
721
+ /**
722
+ * Get organization details.
723
+ * @param orgId The organization ID
724
+ */
725
+ async getOrg(orgId) {
726
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}`, {
727
+ credentials: "include",
728
+ signal: AbortSignal.timeout(timeoutMs)
729
+ });
730
+ if (!res.ok) {
731
+ if (res.status === 404) throw new Error("Organization not found");
732
+ throw new Error(`Failed to get org: ${res.status}`);
733
+ }
734
+ const data = await res.json();
735
+ return transformOrg(data);
736
+ },
737
+ /**
738
+ * Update an organization's name. Requires owner role.
739
+ * @param orgId The organization ID
740
+ * @param name The new name
741
+ */
742
+ async updateOrg(orgId, name) {
743
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}`, {
744
+ method: "PATCH",
745
+ headers: { "Content-Type": "application/json" },
746
+ credentials: "include",
747
+ signal: AbortSignal.timeout(timeoutMs),
748
+ body: JSON.stringify({ name })
749
+ });
750
+ if (!res.ok) {
751
+ const err = await res.json().catch(() => ({}));
752
+ throw new Error(err.message || `Failed to update org: ${res.status}`);
753
+ }
754
+ const data = await res.json();
755
+ return transformOrg(data);
756
+ },
757
+ /**
758
+ * Delete an organization. Requires owner role.
759
+ * @param orgId The organization ID
760
+ */
761
+ async deleteOrg(orgId) {
762
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}`, {
763
+ method: "DELETE",
764
+ credentials: "include",
765
+ signal: AbortSignal.timeout(timeoutMs)
766
+ });
767
+ if (!res.ok) {
768
+ throw new Error(`Failed to delete org: ${res.status}`);
769
+ }
770
+ },
771
+ // ========================================================================
772
+ // Organization Member Methods
773
+ // ========================================================================
774
+ /**
775
+ * List members of an organization. Requires membership.
776
+ * @param orgId The organization ID
777
+ */
778
+ async listOrgMembers(orgId) {
779
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/members`, {
780
+ credentials: "include",
781
+ signal: AbortSignal.timeout(timeoutMs)
782
+ });
783
+ if (!res.ok) {
784
+ throw new Error(`Failed to list org members: ${res.status}`);
785
+ }
786
+ const data = await res.json();
787
+ return data.map(transformOrgMember);
788
+ },
789
+ /**
790
+ * Remove a member from an organization. Requires owner role.
791
+ * @param orgId The organization ID
792
+ * @param userId The user ID to remove
793
+ */
794
+ async removeOrgMember(orgId, userId) {
795
+ const res = await fetch(
796
+ `${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`,
797
+ {
798
+ method: "DELETE",
799
+ credentials: "include",
800
+ signal: AbortSignal.timeout(timeoutMs)
801
+ }
802
+ );
803
+ if (!res.ok) {
804
+ throw new Error(`Failed to remove org member: ${res.status}`);
805
+ }
806
+ },
807
+ /**
808
+ * Update a member's role. Requires owner role.
809
+ * @param orgId The organization ID
810
+ * @param userId The user ID to update
811
+ * @param role The new role
812
+ */
813
+ async updateMemberRole(orgId, userId, role) {
814
+ const res = await fetch(
815
+ `${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/members/${encodeURIComponent(userId)}`,
816
+ {
817
+ method: "PATCH",
818
+ headers: { "Content-Type": "application/json" },
819
+ credentials: "include",
820
+ signal: AbortSignal.timeout(timeoutMs),
821
+ body: JSON.stringify({ role })
822
+ }
823
+ );
824
+ if (!res.ok) {
825
+ const err = await res.json().catch(() => ({}));
826
+ throw new Error(err.message || `Failed to update member role: ${res.status}`);
827
+ }
828
+ const data = await res.json();
829
+ return transformOrgMember(data);
830
+ },
831
+ // ========================================================================
832
+ // Organization Context Methods
833
+ // ========================================================================
834
+ /**
835
+ * Switch the active organization. Re-issues session tokens with new org context.
836
+ * @param orgId The organization ID to switch to
837
+ * @returns The new active org context with subscription info
838
+ */
839
+ async switchOrg(orgId) {
840
+ const res = await fetch(`${baseUrl}/auth/switch-org`, {
841
+ method: "POST",
842
+ headers: { "Content-Type": "application/json" },
843
+ credentials: "include",
844
+ signal: AbortSignal.timeout(timeoutMs),
845
+ body: JSON.stringify({ org_id: orgId })
846
+ });
847
+ if (!res.ok) {
848
+ const err = await res.json().catch(() => ({}));
849
+ throw new Error(err.message || `Failed to switch org: ${res.status}`);
850
+ }
851
+ const data = await res.json();
852
+ return {
853
+ activeOrgId: data.active_org_id,
854
+ orgRole: data.org_role,
855
+ subscription: {
856
+ status: data.subscription.status,
857
+ planCode: data.subscription.plan_code,
858
+ planName: data.subscription.plan_name,
859
+ currentPeriodEnd: data.subscription.current_period_end,
860
+ cancelAtPeriodEnd: data.subscription.cancel_at_period_end,
861
+ trialEndsAt: data.subscription.trial_ends_at
862
+ }
863
+ };
864
+ },
865
+ /**
866
+ * Get the active organization from the current session.
867
+ * @returns Active org ID and role, or null if no active org
868
+ */
869
+ async getActiveOrg() {
870
+ const session = await this.getSession();
871
+ if (!session.valid || !session.active_org_id || !session.org_role) {
872
+ return null;
873
+ }
874
+ return {
875
+ id: session.active_org_id,
876
+ role: session.org_role
877
+ };
878
+ },
879
+ // ========================================================================
880
+ // Invitation Methods
881
+ // ========================================================================
882
+ /**
883
+ * Send an email invite to join an organization. Requires owner role.
884
+ * @param orgId The organization ID
885
+ * @param email The email address to invite
886
+ * @param role Optional role (defaults to "member")
887
+ */
888
+ async inviteToOrg(orgId, email, role) {
889
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invites`, {
890
+ method: "POST",
891
+ headers: { "Content-Type": "application/json" },
892
+ credentials: "include",
893
+ signal: AbortSignal.timeout(timeoutMs),
894
+ body: JSON.stringify({ email, role: role ?? "member" })
895
+ });
896
+ if (!res.ok) {
897
+ const err = await res.json().catch(() => ({}));
898
+ throw new Error(err.message || `Failed to send invite: ${res.status}`);
899
+ }
900
+ const data = await res.json();
901
+ return transformInvitation(data);
902
+ },
903
+ /**
904
+ * List pending invites for an organization. Requires owner role.
905
+ * @param orgId The organization ID
906
+ */
907
+ async listPendingInvites(orgId) {
908
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invites`, {
909
+ credentials: "include",
910
+ signal: AbortSignal.timeout(timeoutMs)
911
+ });
912
+ if (!res.ok) {
913
+ throw new Error(`Failed to list invites: ${res.status}`);
914
+ }
915
+ const data = await res.json();
916
+ return data.map(transformInvitation);
917
+ },
918
+ /**
919
+ * Revoke a pending invite. Requires owner role.
920
+ * @param orgId The organization ID
921
+ * @param inviteId The invite ID to revoke
922
+ */
923
+ async revokeInvite(orgId, inviteId) {
924
+ const res = await fetch(
925
+ `${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invites/${encodeURIComponent(inviteId)}`,
926
+ {
927
+ method: "DELETE",
928
+ credentials: "include",
929
+ signal: AbortSignal.timeout(timeoutMs)
930
+ }
931
+ );
932
+ if (!res.ok) {
933
+ throw new Error(`Failed to revoke invite: ${res.status}`);
934
+ }
935
+ },
936
+ /**
937
+ * Accept an email invite using the invite token.
938
+ * @param token The invite token from the email
939
+ * @returns The org ID and role assigned
940
+ */
941
+ async acceptInvite(token) {
942
+ const res = await fetch(
943
+ `${baseUrl}/auth/orgs/invites/${encodeURIComponent(token)}/accept`,
944
+ {
945
+ method: "POST",
946
+ credentials: "include",
947
+ signal: AbortSignal.timeout(timeoutMs)
948
+ }
949
+ );
950
+ if (!res.ok) {
951
+ const err = await res.json().catch(() => ({}));
952
+ throw new Error(err.message || `Failed to accept invite: ${res.status}`);
953
+ }
954
+ const data = await res.json();
955
+ return {
956
+ orgId: data.org_id,
957
+ role: data.role
958
+ };
959
+ },
960
+ /**
961
+ * Create a shareable invite link for an organization. Requires owner role.
962
+ * @param orgId The organization ID
963
+ * @param role Optional role for joiners (defaults to "member")
964
+ */
965
+ async createInviteLink(orgId, role) {
966
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invite-links`, {
967
+ method: "POST",
968
+ headers: { "Content-Type": "application/json" },
969
+ credentials: "include",
970
+ signal: AbortSignal.timeout(timeoutMs),
971
+ body: JSON.stringify({ role: role ?? "member" })
972
+ });
973
+ if (!res.ok) {
974
+ const err = await res.json().catch(() => ({}));
975
+ throw new Error(err.message || `Failed to create invite link: ${res.status}`);
976
+ }
977
+ const data = await res.json();
978
+ return transformInviteLink(data);
979
+ },
980
+ /**
981
+ * List invite links for an organization. Requires owner role.
982
+ * @param orgId The organization ID
983
+ */
984
+ async listInviteLinks(orgId) {
985
+ const res = await fetch(`${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invite-links`, {
986
+ credentials: "include",
987
+ signal: AbortSignal.timeout(timeoutMs)
988
+ });
989
+ if (!res.ok) {
990
+ throw new Error(`Failed to list invite links: ${res.status}`);
991
+ }
992
+ const data = await res.json();
993
+ return data.map(transformInviteLink);
994
+ },
995
+ /**
996
+ * Revoke an invite link. Requires owner role.
997
+ * @param orgId The organization ID
998
+ * @param linkId The invite link ID to revoke
999
+ */
1000
+ async revokeInviteLink(orgId, linkId) {
1001
+ const res = await fetch(
1002
+ `${baseUrl}/auth/orgs/${encodeURIComponent(orgId)}/invite-links/${encodeURIComponent(linkId)}`,
1003
+ {
1004
+ method: "DELETE",
1005
+ credentials: "include",
1006
+ signal: AbortSignal.timeout(timeoutMs)
1007
+ }
1008
+ );
1009
+ if (!res.ok) {
1010
+ throw new Error(`Failed to revoke invite link: ${res.status}`);
1011
+ }
1012
+ },
1013
+ /**
1014
+ * Join an organization via an invite link token.
1015
+ * @param token The invite link token
1016
+ */
1017
+ async joinViaLink(token) {
1018
+ const res = await fetch(`${baseUrl}/auth/orgs/join/${encodeURIComponent(token)}`, {
1019
+ method: "POST",
1020
+ credentials: "include",
1021
+ signal: AbortSignal.timeout(timeoutMs)
1022
+ });
1023
+ if (!res.ok) {
1024
+ const err = await res.json().catch(() => ({}));
1025
+ throw new Error(err.message || `Failed to join via link: ${res.status}`);
1026
+ }
295
1027
  }
296
1028
  };
297
1029
  }
1030
+ function transformOrg(data) {
1031
+ return {
1032
+ id: data.id,
1033
+ name: data.name,
1034
+ isPersonal: data.is_personal,
1035
+ createdAt: data.created_at
1036
+ };
1037
+ }
1038
+ function transformOrgMember(data) {
1039
+ return {
1040
+ id: data.id,
1041
+ orgId: data.org_id,
1042
+ endUserId: data.end_user_id,
1043
+ role: data.role,
1044
+ joinedAt: data.joined_at
1045
+ };
1046
+ }
1047
+ function transformInvitation(data) {
1048
+ return {
1049
+ id: data.id,
1050
+ orgId: data.org_id,
1051
+ email: data.email,
1052
+ token: data.token,
1053
+ role: data.role,
1054
+ status: data.status,
1055
+ expiresAt: data.expires_at,
1056
+ acceptedAt: data.accepted_at,
1057
+ createdAt: data.created_at
1058
+ };
1059
+ }
1060
+ function transformInviteLink(data) {
1061
+ return {
1062
+ id: data.id,
1063
+ orgId: data.org_id,
1064
+ token: data.token,
1065
+ role: data.role,
1066
+ isActive: data.is_active,
1067
+ expiresAt: data.expires_at,
1068
+ createdAt: data.created_at
1069
+ };
1070
+ }
298
1071
 
299
1072
  // src/react/useAuth.ts
1073
+ var DEFAULT_REFRESH_INTERVAL_MS = 5 * 60 * 1e3;
300
1074
  function useAuth(config) {
301
- const client = (0, import_react.useMemo)(() => createReauthClient(config), [config.domain]);
1075
+ const { refreshInterval = DEFAULT_REFRESH_INTERVAL_MS, ...clientConfig } = config;
1076
+ const client = (0, import_react.useMemo)(() => createReauthClient(clientConfig), [clientConfig.domain]);
1077
+ const isRefreshing = (0, import_react.useRef)(false);
302
1078
  const [state, setState] = (0, import_react.useState)({
303
1079
  user: null,
304
1080
  loading: true,
@@ -307,12 +1083,15 @@ function useAuth(config) {
307
1083
  waitlistPosition: null
308
1084
  });
309
1085
  const checkSession = (0, import_react.useCallback)(async () => {
1086
+ if (isRefreshing.current) return;
1087
+ isRefreshing.current = true;
310
1088
  try {
311
1089
  let session = await client.getSession();
312
1090
  if (!session.valid && !session.error_code && !session.end_user_id) {
313
- const refreshed = await client.refresh();
314
- if (refreshed) {
1091
+ try {
1092
+ await client.refresh();
315
1093
  session = await client.getSession();
1094
+ } catch {
316
1095
  }
317
1096
  }
318
1097
  if (session.error_code === "ACCOUNT_SUSPENDED") {
@@ -330,7 +1109,9 @@ function useAuth(config) {
330
1109
  user: {
331
1110
  id: session.end_user_id,
332
1111
  email: session.email,
333
- roles: session.roles || []
1112
+ roles: session.roles || [],
1113
+ activeOrgId: session.active_org_id || "",
1114
+ orgRole: session.org_role || ""
334
1115
  },
335
1116
  loading: false,
336
1117
  error: null,
@@ -344,7 +1125,9 @@ function useAuth(config) {
344
1125
  user: {
345
1126
  id: session.end_user_id,
346
1127
  email: session.email,
347
- roles: session.roles || []
1128
+ roles: session.roles || [],
1129
+ activeOrgId: session.active_org_id || "",
1130
+ orgRole: session.org_role || ""
348
1131
  },
349
1132
  loading: false,
350
1133
  error: null,
@@ -368,11 +1151,20 @@ function useAuth(config) {
368
1151
  isOnWaitlist: false,
369
1152
  waitlistPosition: null
370
1153
  });
1154
+ } finally {
1155
+ isRefreshing.current = false;
371
1156
  }
372
1157
  }, [client]);
373
1158
  (0, import_react.useEffect)(() => {
374
1159
  checkSession();
375
1160
  }, [checkSession]);
1161
+ (0, import_react.useEffect)(() => {
1162
+ if (refreshInterval <= 0) return;
1163
+ const intervalId = setInterval(() => {
1164
+ checkSession();
1165
+ }, refreshInterval);
1166
+ return () => clearInterval(intervalId);
1167
+ }, [checkSession, refreshInterval]);
376
1168
  const logout = (0, import_react.useCallback)(async () => {
377
1169
  await client.logout();
378
1170
  setState({
@@ -391,16 +1183,141 @@ function useAuth(config) {
391
1183
  };
392
1184
  }
393
1185
 
394
- // src/react/AuthProvider.tsx
1186
+ // src/react/useHeadlessAuth.ts
395
1187
  var import_react2 = require("react");
1188
+ function useHeadlessAuth(options) {
1189
+ const client = (0, import_react2.useMemo)(
1190
+ () => createReauthClient({ domain: options.domain, timeout: options.timeout }),
1191
+ [options.domain, options.timeout]
1192
+ );
1193
+ const [state, setState] = (0, import_react2.useState)({
1194
+ loading: false,
1195
+ error: null,
1196
+ step: "idle",
1197
+ config: null,
1198
+ verifyResult: null
1199
+ });
1200
+ const getConfig = (0, import_react2.useCallback)(async () => {
1201
+ setState((s) => ({ ...s, loading: true, error: null }));
1202
+ try {
1203
+ const config = await client.getConfig();
1204
+ setState((s) => ({ ...s, loading: false, config }));
1205
+ return config;
1206
+ } catch (err) {
1207
+ const message = err instanceof Error ? err.message : "Failed to get config";
1208
+ setState((s) => ({ ...s, loading: false, error: message }));
1209
+ throw err;
1210
+ }
1211
+ }, [client]);
1212
+ const requestMagicLink = (0, import_react2.useCallback)(
1213
+ async (email) => {
1214
+ setState((s) => ({ ...s, loading: true, error: null }));
1215
+ try {
1216
+ await client.requestMagicLink({
1217
+ email,
1218
+ callbackUrl: options.callbackUrl
1219
+ });
1220
+ setState((s) => ({
1221
+ ...s,
1222
+ loading: false,
1223
+ step: "magic_link_sent"
1224
+ }));
1225
+ } catch (err) {
1226
+ const message = err instanceof Error ? err.message : "Failed to send magic link";
1227
+ setState((s) => ({ ...s, loading: false, error: message }));
1228
+ throw err;
1229
+ }
1230
+ },
1231
+ [client, options.callbackUrl]
1232
+ );
1233
+ const verifyMagicLink = (0, import_react2.useCallback)(
1234
+ async (token) => {
1235
+ setState((s) => ({ ...s, loading: true, error: null }));
1236
+ try {
1237
+ const result = await client.verifyMagicLink({ token });
1238
+ setState((s) => ({
1239
+ ...s,
1240
+ loading: false,
1241
+ step: "completed",
1242
+ verifyResult: result
1243
+ }));
1244
+ return result;
1245
+ } catch (err) {
1246
+ const message = err instanceof Error ? err.message : "Failed to verify magic link";
1247
+ setState((s) => ({ ...s, loading: false, error: message }));
1248
+ throw err;
1249
+ }
1250
+ },
1251
+ [client]
1252
+ );
1253
+ const startGoogleOAuth = (0, import_react2.useCallback)(async () => {
1254
+ setState((s) => ({ ...s, loading: true, error: null }));
1255
+ try {
1256
+ const result = await client.startGoogleOAuth();
1257
+ setState((s) => ({
1258
+ ...s,
1259
+ loading: false,
1260
+ step: "google_started"
1261
+ }));
1262
+ if (typeof window !== "undefined") {
1263
+ window.location.href = result.authUrl;
1264
+ }
1265
+ return result;
1266
+ } catch (err) {
1267
+ const message = err instanceof Error ? err.message : "Failed to start Google OAuth";
1268
+ setState((s) => ({ ...s, loading: false, error: message }));
1269
+ throw err;
1270
+ }
1271
+ }, [client]);
1272
+ const startTwitterOAuth = (0, import_react2.useCallback)(async () => {
1273
+ setState((s) => ({ ...s, loading: true, error: null }));
1274
+ try {
1275
+ const result = await client.startTwitterOAuth();
1276
+ setState((s) => ({
1277
+ ...s,
1278
+ loading: false,
1279
+ step: "twitter_started"
1280
+ }));
1281
+ if (typeof window !== "undefined") {
1282
+ window.location.href = result.authUrl;
1283
+ }
1284
+ return result;
1285
+ } catch (err) {
1286
+ const message = err instanceof Error ? err.message : "Failed to start Twitter OAuth";
1287
+ setState((s) => ({ ...s, loading: false, error: message }));
1288
+ throw err;
1289
+ }
1290
+ }, [client]);
1291
+ const reset = (0, import_react2.useCallback)(() => {
1292
+ setState({
1293
+ loading: false,
1294
+ error: null,
1295
+ step: "idle",
1296
+ config: null,
1297
+ verifyResult: null
1298
+ });
1299
+ }, []);
1300
+ return {
1301
+ ...state,
1302
+ getConfig,
1303
+ requestMagicLink,
1304
+ verifyMagicLink,
1305
+ startGoogleOAuth,
1306
+ startTwitterOAuth,
1307
+ reset
1308
+ };
1309
+ }
1310
+
1311
+ // src/react/AuthProvider.tsx
1312
+ var import_react3 = require("react");
396
1313
  var import_jsx_runtime = require("react/jsx-runtime");
397
- var AuthContext = (0, import_react2.createContext)(null);
1314
+ var AuthContext = (0, import_react3.createContext)(null);
398
1315
  function AuthProvider({ config, children }) {
399
1316
  const auth = useAuth(config);
400
1317
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AuthContext.Provider, { value: auth, children });
401
1318
  }
402
1319
  function useAuthContext() {
403
- const context = (0, import_react2.useContext)(AuthContext);
1320
+ const context = (0, import_react3.useContext)(AuthContext);
404
1321
  if (!context) {
405
1322
  throw new Error("useAuthContext must be used within AuthProvider");
406
1323
  }
@@ -408,7 +1325,7 @@ function useAuthContext() {
408
1325
  }
409
1326
 
410
1327
  // src/react/ProtectedRoute.tsx
411
- var import_react3 = require("react");
1328
+ var import_react4 = require("react");
412
1329
  var import_jsx_runtime2 = require("react/jsx-runtime");
413
1330
  function ProtectedRoute({
414
1331
  children,
@@ -417,8 +1334,10 @@ function ProtectedRoute({
417
1334
  onWaitlist
418
1335
  }) {
419
1336
  const { user, loading, isOnWaitlist, login } = useAuthContext();
420
- (0, import_react3.useEffect)(() => {
421
- if (!loading && !user) {
1337
+ const hasRedirected = (0, import_react4.useRef)(false);
1338
+ (0, import_react4.useEffect)(() => {
1339
+ if (!loading && !user && !hasRedirected.current) {
1340
+ hasRedirected.current = true;
422
1341
  if (onUnauthenticated) {
423
1342
  onUnauthenticated();
424
1343
  } else {
@@ -426,8 +1345,9 @@ function ProtectedRoute({
426
1345
  }
427
1346
  }
428
1347
  }, [loading, user, login, onUnauthenticated]);
429
- (0, import_react3.useEffect)(() => {
430
- if (!loading && isOnWaitlist && onWaitlist) {
1348
+ (0, import_react4.useEffect)(() => {
1349
+ if (!loading && isOnWaitlist && onWaitlist && !hasRedirected.current) {
1350
+ hasRedirected.current = true;
431
1351
  onWaitlist();
432
1352
  }
433
1353
  }, [loading, isOnWaitlist, onWaitlist]);
@@ -444,5 +1364,6 @@ function ProtectedRoute({
444
1364
  AuthProvider,
445
1365
  ProtectedRoute,
446
1366
  useAuth,
447
- useAuthContext
1367
+ useAuthContext,
1368
+ useHeadlessAuth
448
1369
  });