@object-ui/auth 15.0.0 → 16.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.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,93 @@
1
1
  # @object-ui/auth
2
2
 
3
+ ## 16.1.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 803558e: feat(data): thread the host's authenticated fetch into `provider: 'api'` data sources (#2725)
8
+
9
+ `provider: 'api'` view data sources went through a bare `globalThis.fetch`, so
10
+ custom endpoints (gantt composite trees, report aggregates) carried only
11
+ same-origin cookies while every native `/api/v1/*` request carried
12
+ `Authorization: Bearer` — the moment cookie HMAC verification failed (dev
13
+ restart rotating the fallback auth secret, cookie expiry/rotation in prod)
14
+ those views 401'd while the rest of the app kept working.
15
+
16
+ - **`@object-ui/react`** — `SchemaRendererProvider` accepts an optional
17
+ `apiFetch`; nested providers inherit it from their parent so re-wrapped
18
+ subtrees (react pages, preview surfaces) keep the host's authentication.
19
+ `useViewData` defaults the api-provider adapter's fetch to the context
20
+ `apiFetch` (explicit `adapterOptions.fetch` still wins).
21
+ - **`@object-ui/auth`** — `createAuthenticatedFetch` gains a
22
+ `sameOriginOnly` option: cross-origin URLs pass through to the bare fetch
23
+ with no `Authorization` / `X-Tenant-ID` / `Accept-Language`, so metadata-
24
+ supplied third-party URLs never see the platform token.
25
+ - **`@object-ui/app-shell`** — the console wires
26
+ `createAuthenticatedFetch({ sameOriginOnly: true })` (settle-signal wrapped)
27
+ as `apiFetch` on the root `SchemaRendererProvider`.
28
+ - **`@object-ui/plugin-gantt`** — `ObjectGantt` resolves its api-provider
29
+ DataSource with the context `apiFetch`, covering reads and write-backs.
30
+
31
+ Behaviour is unchanged for hosts that don't provide `apiFetch` (bare fetch +
32
+ cookies, as before).
33
+
34
+ ### Patch Changes
35
+
36
+ - Updated dependencies [7cf4051]
37
+ - Updated dependencies [94d4876]
38
+ - Updated dependencies [2b17339]
39
+ - Updated dependencies [31b77d4]
40
+ - Updated dependencies [6d4fbe6]
41
+ - Updated dependencies [62b9ab5]
42
+ - Updated dependencies [29c6040]
43
+ - Updated dependencies [faebac3]
44
+ - Updated dependencies [199fa83]
45
+ - @object-ui/types@16.1.0
46
+
47
+ ## 16.0.0
48
+
49
+ ### Patch Changes
50
+
51
+ - 45c6fb4: Login-page auth-config hardening (#2625, #2626):
52
+
53
+ - `createAuthClient.getConfig` now single-flights + caches the `/auth/config`
54
+ fetch (the login page's three consumers used to fire three requests) and
55
+ retries failures with backoff (500ms/1.5s/3.5s, 8s per-attempt abort) before
56
+ rejecting. A cold-starting environment kernel no longer strands the page
57
+ without its SSO buttons; a final failure clears the cache so later callers
58
+ retry.
59
+ - `LoginForm` holds a spinner instead of painting the password-form defaults
60
+ while config resolves — an SSO-only deployment must never flash a password
61
+ wall at JIT users who have no password. A failed config still falls back to
62
+ the password form (break-glass beats lock-out).
63
+ - `signInWithProvider` gains a 20s watchdog: a sign-in request that hangs now
64
+ rejects with a clear timeout error so the provider button recovers instead
65
+ of spinning forever.
66
+ - Removed LoginForm's duplicate "or" divider — SocialSignInButtons already
67
+ renders its own, and the stacked pair read as a rendering glitch.
68
+
69
+ - 077e45b: `signInWithProvider` with `type: 'oidc'` now signs in through better-auth's
70
+ core social route (`POST /sign-in/social`) and only falls back to the legacy
71
+ `POST /sign-in/oauth2` endpoint when the social route rejects the provider.
72
+
73
+ better-auth ≥ 1.7 restructured the `genericOAuth` plugin: generic OAuth/OIDC
74
+ providers are injected into the core social sign-in flow and the dedicated
75
+ `/sign-in/oauth2` endpoint no longer exists. The old client therefore 404'd on
76
+ every "Continue with ObjectStack" click (platform SSO broken end-to-end on
77
+ current framework). The fallback keeps the button working against older
78
+ (< 1.7) servers during the coordinated rollout; when both routes fail, the
79
+ social-route error is surfaced since on a ≥ 1.7 server it is the real failure.
80
+
81
+ - 022735f: RegisterForm: drop the duplicate "or" divider (matching the LoginForm fix in
82
+ #2629). SocialSignInButtons already renders its own "or continue with email"
83
+ divider under the provider buttons; RegisterForm stacked a second "OR" line on
84
+ top, which read as a rendering glitch on the sign-up page.
85
+ - Updated dependencies [210806a]
86
+ - Updated dependencies [b4ef588]
87
+ - Updated dependencies [5534535]
88
+ - Updated dependencies [9b8f978]
89
+ - @object-ui/types@16.0.0
90
+
3
91
  ## 15.0.0
4
92
 
5
93
  ### Patch Changes
package/README.md CHANGED
@@ -126,10 +126,16 @@ Displays current user info with avatar and sign-out:
126
126
 
127
127
  ### createAuthenticatedFetch
128
128
 
129
- Creates a fetch wrapper that injects auth tokens into DataSource requests:
129
+ Creates a fetch wrapper that injects the stored Bearer token (plus
130
+ `X-Tenant-ID` and `Accept-Language`) into API requests:
130
131
 
131
132
  ```tsx
132
- const authedFetch = createAuthenticatedFetch({ getToken: () => session.token });
133
+ const authedFetch = createAuthenticatedFetch();
134
+
135
+ // For fetches whose target URL comes from view metadata (`provider: 'api'`
136
+ // data sources), restrict credential injection to the current page's origin
137
+ // so the platform token never leaks to third-party hosts:
138
+ const apiProviderFetch = createAuthenticatedFetch({ sameOriginOnly: true });
133
139
  ```
134
140
 
135
141
  ## Preview Mode
@@ -1 +1 @@
1
- {"version":3,"file":"LoginForm.d.ts","sourceRoot":"","sources":["../src/LoginForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAInD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAYtD,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8GAA8G;IAC9G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gGAAgG;IAChG,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,8FAA8F;IAC9F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iGAAiG;IACjG,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,yHAAyH;IACzH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,gCAAgC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mCAAmC;IACnC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAsBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,EACxB,SAAS,EACT,OAAO,EACP,WAAyB,EACzB,iBAAsC,EACtC,KAAiC,EACjC,WAAyD,EACzD,IAAI,EACJ,QAAgB,EAChB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,cAAc,qBAsZhB"}
1
+ {"version":3,"file":"LoginForm.d.ts","sourceRoot":"","sources":["../src/LoginForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAA8B,MAAM,OAAO,CAAC;AAInD,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAYtD,4CAA4C;AAC5C,MAAM,WAAW,eAAe;IAC9B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,6FAA6F;IAC7F,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,6DAA6D;IAC7D,uBAAuB,CAAC,EAAE,MAAM,CAAC;IACjC,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wEAAwE;IACxE,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;OAIG;IACH,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,8GAA8G;IAC9G,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,wFAAwF;IACxF,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gFAAgF;IAChF,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,4EAA4E;IAC5E,kBAAkB,CAAC,EAAE,MAAM,CAAC;IAC5B,oEAAoE;IACpE,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,gGAAgG;IAChG,sBAAsB,CAAC,EAAE,MAAM,CAAC;IAChC,8FAA8F;IAC9F,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,iGAAiG;IACjG,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,yHAAyH;IACzH,kBAAkB,CAAC,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,cAAc;IAC7B,mCAAmC;IACnC,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB,8BAA8B;IAC9B,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,gCAAgC;IAChC,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,mCAAmC;IACnC,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,wEAAwE;IACxE,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,eAAe,CAAC;IACzB;;;;;OAKG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAsBD;;;;;;;;;;;;;GAaG;AACH,wBAAgB,SAAS,CAAC,EACxB,SAAS,EACT,OAAO,EACP,WAAyB,EACzB,iBAAsC,EACtC,KAAiC,EACjC,WAAyD,EACzD,IAAI,EACJ,QAAgB,EAChB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,cAAc,qBA4ahB"}
package/dist/LoginForm.js CHANGED
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  /**
3
3
  * ObjectUI
4
4
  * Copyright (c) 2024-present ObjectStack Inc.
@@ -10,7 +10,7 @@ import { useEffect, useState } from 'react';
10
10
  import { useAuth } from './useAuth';
11
11
  import { SocialSignInButtons } from './SocialSignInButtons';
12
12
  import { looksLikePhoneIdentifier, normalizePhoneIdentifier } from './phone-identifier';
13
- import { AUTH_FIELD_LABEL_CLASS, AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, AuthDivider, AuthErrorBanner, AuthFormHeader, AuthSpinner, } from './authStyles';
13
+ import { AUTH_FIELD_LABEL_CLASS, AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, AuthErrorBanner, AuthFormHeader, AuthSpinner, } from './authStyles';
14
14
  const DefaultLink = ({ href, className, children }) => (_jsx("a", { href: href, className: className, children: children }));
15
15
  const DefaultLockIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", className: "h-6 w-6", "aria-hidden": "true", children: [_jsx("rect", { x: "3", y: "11", width: "18", height: "11", rx: "2" }), _jsx("path", { d: "M7 11V7a5 5 0 0 1 10 0v4" })] }));
16
16
  /**
@@ -70,6 +70,13 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
70
70
  // state because SSO routing is a raw fetch, not the shared `signIn` (whose
71
71
  // `isLoading` drives the password submit button).
72
72
  const [ssoSubmitting, setSsoSubmitting] = useState(false);
73
+ // Config not resolved yet: render a spinner INSTEAD of the password-form
74
+ // defaults. Painting the defaults first showed an SSO-only deployment a
75
+ // password wall (no SSO button, no enforced collapse) whenever the config
76
+ // fetch was slow/failed on first load — and platform-SSO JIT users have no
77
+ // password at all (#2625). A FAILED fetch (after the client's retries)
78
+ // still falls back to the password form: break-glass beats lock-out.
79
+ const [configPending, setConfigPending] = useState(true);
73
80
  useEffect(() => {
74
81
  let cancelled = false;
75
82
  Promise.resolve()
@@ -86,6 +93,10 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
86
93
  .catch(() => {
87
94
  // SSO is an enhancement, not required — leave the buttons/form hidden
88
95
  // or shown at their safe defaults.
96
+ })
97
+ .finally(() => {
98
+ if (!cancelled)
99
+ setConfigPending(false);
89
100
  });
90
101
  return () => {
91
102
  cancelled = true;
@@ -219,10 +230,10 @@ export function LoginForm({ onSuccess, onError, registerUrl = '/register', forgo
219
230
  setSsoSubmitting(false);
220
231
  }
221
232
  };
222
- return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultLockIcon, {})), title: title, description: ssoEnforced && !showPasswordFallback ? l.ssoOnlyDescription : description }), _jsxs("div", { className: "space-y-5", children: [_jsx(SocialSignInButtons, { mode: "sign-in", onProvidersResolved: (hasProviders) => setHasSocialProviders(hasProviders) }), passwordFormVisible ? (mode === 'phone-otp' ? (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [hasSocialProviders && _jsx(AuthDivider, { label: l.orText }), error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-phone", className: AUTH_FIELD_LABEL_CLASS, children: l.phoneLabel }), _jsx("input", { id: "login-phone", type: "tel", placeholder: l.phonePlaceholder, value: phone, onChange: (e) => setPhone(e.target.value), required: true, autoComplete: "tel", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-otp", className: AUTH_FIELD_LABEL_CLASS, children: l.otpCodeLabel }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: "login-otp", type: "text", inputMode: "numeric", placeholder: l.otpCodePlaceholder, value: otpCode, onChange: (e) => setOtpCode(e.target.value), required: true, autoComplete: "one-time-code", disabled: isLoading, className: AUTH_INPUT_CLASS }), _jsxs("button", { type: "button", onClick: handleSendOtp, disabled: otpSending || otpCooldown > 0 || !phone.trim() || isLoading, className: "shrink-0 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [otpSending && _jsx(AuthSpinner, {}), otpCooldown > 0
223
- ? l.resendOtpCountdownText.replace('{seconds}', String(otpCooldown))
224
- : l.sendOtpButton] })] })] }), _jsxs("button", { type: "submit", disabled: isLoading || !otpSent, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), _jsx("button", { type: "button", onClick: () => switchMode('password'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordSignInText })] })) : (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [hasSocialProviders && _jsx(AuthDivider, { label: l.orText }), error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-email", className: AUTH_FIELD_LABEL_CLASS, children: phonePasswordEnabled ? l.emailOrPhoneLabel : l.emailLabel }), _jsx("input", { id: "login-email",
225
- // `text` (not `email`) when a phone number is also accepted, so the
226
- // browser doesn't reject a phone-shaped value on native validation.
227
- type: phonePasswordEnabled ? 'text' : 'email', inputMode: phonePasswordEnabled ? 'text' : 'email', placeholder: phonePasswordEnabled ? l.emailOrPhonePlaceholder : l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "username", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("label", { htmlFor: "login-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), forgotPasswordUrl && (_jsx(LinkComp, { href: forgotPasswordUrl, className: "text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.forgotPasswordText }))] }), _jsx("input", { id: "login-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, autoComplete: "current-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), ssoEnabled && (_jsxs("button", { type: "button", onClick: handleSso, disabled: isLoading || ssoSubmitting, "aria-busy": ssoSubmitting, className: "flex w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [ssoSubmitting && _jsx(AuthSpinner, {}), l.ssoButton] })), phoneOtpEnabled && (_jsx("button", { type: "button", onClick: () => switchMode('phone-otp'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePhoneOtpText })), ssoEnforced && showPasswordFallback && (_jsx("button", { type: "button", onClick: () => setShowPasswordFallback(false), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.backToSsoText }))] }))) : (_jsxs("div", { className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsx("button", { type: "button", onClick: () => setShowPasswordFallback(true), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordText })] }))] }), registerUrl && !ssoEnforced && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.noAccountText, ' ', _jsx(LinkComp, { href: registerUrl, className: AUTH_LINK_CLASS, children: l.signUpText })] }))] }));
233
+ return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultLockIcon, {})), title: title, description: ssoEnforced && !showPasswordFallback ? l.ssoOnlyDescription : description }), _jsx("div", { className: "space-y-5", children: configPending ? (_jsx("div", { className: "flex justify-center py-10", role: "status", "aria-live": "polite", "data-testid": "login-config-loading", children: _jsx(AuthSpinner, {}) })) : (_jsxs(_Fragment, { children: [_jsx(SocialSignInButtons, { mode: "sign-in", onProvidersResolved: (hasProviders) => setHasSocialProviders(hasProviders) }), passwordFormVisible ? (mode === 'phone-otp' ? (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-phone", className: AUTH_FIELD_LABEL_CLASS, children: l.phoneLabel }), _jsx("input", { id: "login-phone", type: "tel", placeholder: l.phonePlaceholder, value: phone, onChange: (e) => setPhone(e.target.value), required: true, autoComplete: "tel", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-otp", className: AUTH_FIELD_LABEL_CLASS, children: l.otpCodeLabel }), _jsxs("div", { className: "flex gap-2", children: [_jsx("input", { id: "login-otp", type: "text", inputMode: "numeric", placeholder: l.otpCodePlaceholder, value: otpCode, onChange: (e) => setOtpCode(e.target.value), required: true, autoComplete: "one-time-code", disabled: isLoading, className: AUTH_INPUT_CLASS }), _jsxs("button", { type: "button", onClick: handleSendOtp, disabled: otpSending || otpCooldown > 0 || !phone.trim() || isLoading, className: "shrink-0 rounded-md border border-input bg-background px-3 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [otpSending && _jsx(AuthSpinner, {}), otpCooldown > 0
234
+ ? l.resendOtpCountdownText.replace('{seconds}', String(otpCooldown))
235
+ : l.sendOtpButton] })] })] }), _jsxs("button", { type: "submit", disabled: isLoading || !otpSent, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), _jsx("button", { type: "button", onClick: () => switchMode('password'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordSignInText })] })) : (_jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "login-email", className: AUTH_FIELD_LABEL_CLASS, children: phonePasswordEnabled ? l.emailOrPhoneLabel : l.emailLabel }), _jsx("input", { id: "login-email",
236
+ // `text` (not `email`) when a phone number is also accepted, so the
237
+ // browser doesn't reject a phone-shaped value on native validation.
238
+ type: phonePasswordEnabled ? 'text' : 'email', inputMode: phonePasswordEnabled ? 'text' : 'email', placeholder: phonePasswordEnabled ? l.emailOrPhonePlaceholder : l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "username", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsx("label", { htmlFor: "login-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), forgotPasswordUrl && (_jsx(LinkComp, { href: forgotPasswordUrl, className: "text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.forgotPasswordText }))] }), _jsx("input", { id: "login-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, autoComplete: "current-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] }), ssoEnabled && (_jsxs("button", { type: "button", onClick: handleSso, disabled: isLoading || ssoSubmitting, "aria-busy": ssoSubmitting, className: "flex w-full items-center justify-center rounded-md border border-input bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent disabled:opacity-50", children: [ssoSubmitting && _jsx(AuthSpinner, {}), l.ssoButton] })), phoneOtpEnabled && (_jsx("button", { type: "button", onClick: () => switchMode('phone-otp'), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePhoneOtpText })), ssoEnforced && showPasswordFallback && (_jsx("button", { type: "button", onClick: () => setShowPasswordFallback(false), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.backToSsoText }))] }))) : (_jsxs("div", { className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsx("button", { type: "button", onClick: () => setShowPasswordFallback(true), className: "w-full text-center text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-primary hover:underline", children: l.usePasswordText })] }))] })) }), registerUrl && !ssoEnforced && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.noAccountText, ' ', _jsx(LinkComp, { href: registerUrl, className: AUTH_LINK_CLASS, children: l.signUpText })] }))] }));
228
239
  }
@@ -1 +1 @@
1
- {"version":3,"file":"RegisterForm.d.ts","sourceRoot":"","sources":["../src/RegisterForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAGxC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAYtD,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC;sFACkF;IAClF,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;2CAEuC;IACvC,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,qCAAqC;IACrC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,yBAAyB;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,sBAAsB,EACtB,OAAO,EACP,QAAmB,EACnB,KAA2B,EAC3B,WAAqD,EACrD,IAAI,EACJ,QAAgB,EAChB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,iBAAiB,qBAmKnB"}
1
+ {"version":3,"file":"RegisterForm.d.ts","sourceRoot":"","sources":["../src/RegisterForm.tsx"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAEH,OAAO,KAAmB,MAAM,OAAO,CAAC;AAGxC,OAAO,KAAK,EAAE,sBAAsB,EAAE,MAAM,SAAS,CAAC;AAWtD,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,oBAAoB,CAAC,EAAE,MAAM,CAAC;IAC9B,0BAA0B,CAAC,EAAE,MAAM,CAAC;IACpC,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,qBAAqB,CAAC,EAAE,MAAM,CAAC;IAC/B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,iFAAiF;IACjF,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC;sFACkF;IAClF,SAAS,CAAC,EAAE,MAAM,IAAI,CAAC;IACvB;;2CAEuC;IACvC,sBAAsB,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACjD,qCAAqC;IACrC,OAAO,CAAC,EAAE,CAAC,KAAK,EAAE,KAAK,KAAK,IAAI,CAAC;IACjC,yBAAyB;IACzB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,mBAAmB;IACnB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yBAAyB;IACzB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,6EAA6E;IAC7E,IAAI,CAAC,EAAE,KAAK,CAAC,SAAS,CAAC;IACvB,0EAA0E;IAC1E,aAAa,CAAC,EAAE,KAAK,CAAC,aAAa,CAAC,sBAAsB,CAAC,CAAC;IAC5D,uCAAuC;IACvC,MAAM,CAAC,EAAE,kBAAkB,CAAC;IAC5B;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACvC,kEAAkE;IAClE,QAAQ,CAAC,EAAE,OAAO,CAAC;CACpB;AAwBD;;;;;;;;;;;;GAYG;AACH,wBAAgB,YAAY,CAAC,EAC3B,SAAS,EACT,sBAAsB,EACtB,OAAO,EACP,QAAmB,EACnB,KAA2B,EAC3B,WAAqD,EACrD,IAAI,EACJ,QAAgB,EAChB,aAAa,EAAE,QAAsB,EACrC,MAAW,EACX,aAAa,GACd,EAAE,iBAAiB,qBAoKnB"}
@@ -9,7 +9,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
9
9
  import { useState } from 'react';
10
10
  import { useAuth } from './useAuth';
11
11
  import { SocialSignInButtons } from './SocialSignInButtons';
12
- import { AUTH_FIELD_LABEL_CLASS, AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, AuthDivider, AuthErrorBanner, AuthFormHeader, AuthSpinner, } from './authStyles';
12
+ import { AUTH_FIELD_LABEL_CLASS, AUTH_INPUT_CLASS, AUTH_LINK_CLASS, AUTH_PRIMARY_BUTTON_CLASS, AuthErrorBanner, AuthFormHeader, AuthSpinner, } from './authStyles';
13
13
  const DefaultLink = ({ href, className, children }) => (_jsx("a", { href: href, className: className, children: children }));
14
14
  const DefaultUserPlusIcon = () => (_jsxs("svg", { viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "1.75", strokeLinecap: "round", strokeLinejoin: "round", className: "h-6 w-6", "aria-hidden": "true", children: [_jsx("path", { d: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" }), _jsx("circle", { cx: "9", cy: "7", r: "4" }), _jsx("line", { x1: "19", y1: "8", x2: "19", y2: "14" }), _jsx("line", { x1: "22", y1: "11", x2: "16", y2: "11" })] }));
15
15
  /**
@@ -32,7 +32,6 @@ export function RegisterForm({ onSuccess, onVerificationRequired, onError, login
32
32
  const [password, setPassword] = useState('');
33
33
  const [confirmPassword, setConfirmPassword] = useState('');
34
34
  const [error, setError] = useState(null);
35
- const [hasSocialProviders, setHasSocialProviders] = useState(false);
36
35
  const l = {
37
36
  nameLabel: labels.nameLabel ?? 'Name',
38
37
  namePlaceholder: labels.namePlaceholder ?? 'John Doe',
@@ -76,5 +75,5 @@ export function RegisterForm({ onSuccess, onVerificationRequired, onError, login
76
75
  onError?.(authError);
77
76
  }
78
77
  };
79
- return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultUserPlusIcon, {})), title: title, description: description }), _jsxs("div", { className: "space-y-5", children: [_jsx(SocialSignInButtons, { mode: "sign-up", onProvidersResolved: (has) => setHasSocialProviders(has) }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [hasSocialProviders && _jsx(AuthDivider, { label: l.orText }), error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-name", className: AUTH_FIELD_LABEL_CLASS, children: l.nameLabel }), _jsx("input", { id: "register-name", type: "text", placeholder: l.namePlaceholder, value: name, onChange: (e) => setName(e.target.value), required: true, autoComplete: "name", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-email", className: AUTH_FIELD_LABEL_CLASS, children: l.emailLabel }), _jsx("input", { id: "register-email", type: "email", placeholder: l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "email", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), _jsx("input", { id: "register-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-confirm-password", className: AUTH_FIELD_LABEL_CLASS, children: l.confirmPasswordLabel }), _jsx("input", { id: "register-confirm-password", type: "password", placeholder: l.confirmPasswordPlaceholder, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] })] })] }), loginUrl && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.hasAccountText, ' ', _jsx(LinkComp, { href: loginUrl, className: AUTH_LINK_CLASS, children: l.signInText })] }))] }));
78
+ return (_jsxs("div", { className: "mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]", children: [_jsx(AuthFormHeader, { icon: hideIcon ? undefined : (icon ?? _jsx(DefaultUserPlusIcon, {})), title: title, description: description }), _jsxs("div", { className: "space-y-5", children: [_jsx(SocialSignInButtons, { mode: "sign-up" }), _jsxs("form", { onSubmit: handleSubmit, className: "space-y-4", children: [error && _jsx(AuthErrorBanner, { message: error }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-name", className: AUTH_FIELD_LABEL_CLASS, children: l.nameLabel }), _jsx("input", { id: "register-name", type: "text", placeholder: l.namePlaceholder, value: name, onChange: (e) => setName(e.target.value), required: true, autoComplete: "name", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-email", className: AUTH_FIELD_LABEL_CLASS, children: l.emailLabel }), _jsx("input", { id: "register-email", type: "email", placeholder: l.emailPlaceholder, value: email, onChange: (e) => setEmail(e.target.value), required: true, autoComplete: "email", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-password", className: AUTH_FIELD_LABEL_CLASS, children: l.passwordLabel }), _jsx("input", { id: "register-password", type: "password", placeholder: l.passwordPlaceholder, value: password, onChange: (e) => setPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("div", { className: "space-y-2", children: [_jsx("label", { htmlFor: "register-confirm-password", className: AUTH_FIELD_LABEL_CLASS, children: l.confirmPasswordLabel }), _jsx("input", { id: "register-confirm-password", type: "password", placeholder: l.confirmPasswordPlaceholder, value: confirmPassword, onChange: (e) => setConfirmPassword(e.target.value), required: true, minLength: 8, autoComplete: "new-password", disabled: isLoading, className: AUTH_INPUT_CLASS })] }), _jsxs("button", { type: "submit", disabled: isLoading, className: AUTH_PRIMARY_BUTTON_CLASS, children: [isLoading && _jsx(AuthSpinner, {}), isLoading ? l.submittingButton : l.submitButton] })] })] }), loginUrl && (_jsxs("p", { className: "px-8 text-center text-sm text-muted-foreground", children: [l.hasAccountText, ' ', _jsx(LinkComp, { href: loginUrl, className: AUTH_LINK_CLASS, children: l.signInText })] }))] }));
80
79
  }
@@ -1 +1 @@
1
- {"version":3,"file":"createAuthClient.d.ts","sourceRoot":"","sources":["../src/createAuthClient.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EACV,UAAU,EAAE,gBAAgB,EAE7B,MAAM,SAAS,CAAC;AAIjB;;;GAGG;AACH,eAAO,MAAM,YAAY;kBACD,MAAM,GAAG,IAAI;WAE5B,MAAM,GAAG,IAAI;eAST,MAAM,GAAG,IAAI;aASf,IAAI;CAQd,CAAC;AAmHF;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CAkkBrE"}
1
+ {"version":3,"file":"createAuthClient.d.ts","sourceRoot":"","sources":["../src/createAuthClient.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAIH,OAAO,KAAK,EACV,UAAU,EAAE,gBAAgB,EAE7B,MAAM,SAAS,CAAC;AAIjB;;;GAGG;AACH,eAAO,MAAM,YAAY;kBACD,MAAM,GAAG,IAAI;WAE5B,MAAM,GAAG,IAAI;eAST,MAAM,GAAG,IAAI;aASf,IAAI;CAQd,CAAC;AAmHF;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,gBAAgB,CAAC,MAAM,EAAE,gBAAgB,GAAG,UAAU,CA2oBrE"}
@@ -196,6 +196,62 @@ export function createAuthClient(config) {
196
196
  }
197
197
  return payload ?? {};
198
198
  }
199
+ /**
200
+ * Fetch `GET {authUrl}/config` once, with a per-attempt timeout so a
201
+ * request that HANGS (a freshly provisioned environment whose kernel is
202
+ * still cold-starting) converts into a retryable failure instead of
203
+ * stalling the login page forever.
204
+ */
205
+ async function fetchConfigAttempt(timeoutMs) {
206
+ const controller = typeof AbortController !== 'undefined' ? new AbortController() : undefined;
207
+ const timer = controller ? setTimeout(() => controller.abort(), timeoutMs) : undefined;
208
+ try {
209
+ const url = `${origin}${basePath}/config`;
210
+ const response = await bearerFetch(url, {
211
+ method: 'GET',
212
+ headers: { 'Content-Type': 'application/json' },
213
+ ...(controller ? { signal: controller.signal } : {}),
214
+ });
215
+ if (!response.ok) {
216
+ throw new Error(`Failed to load auth config (status ${response.status})`);
217
+ }
218
+ const body = (await response.json());
219
+ // Server wraps the payload as `{ success, data }`; tolerate both shapes.
220
+ if (body && typeof body === 'object' && 'data' in body && body.data) {
221
+ return body.data;
222
+ }
223
+ return body;
224
+ }
225
+ finally {
226
+ if (timer !== undefined)
227
+ clearTimeout(timer);
228
+ }
229
+ }
230
+ // Auth config is static per server boot: single-flight + cache the success
231
+ // so the login page's multiple consumers (LoginForm, SocialSignInButtons,
232
+ // RegisterForm) share ONE request instead of three. Failures RETRY with a
233
+ // short backoff before rejecting — a first-load failure used to render the
234
+ // page without its SSO buttons, which strands SSO-only users on a password
235
+ // wall they have no password for (#2625). A final failure clears the cache
236
+ // so the next caller starts a fresh cycle.
237
+ const CONFIG_RETRY_DELAYS_MS = [500, 1500, 3500];
238
+ const CONFIG_ATTEMPT_TIMEOUT_MS = 8000;
239
+ let configPromise = null;
240
+ async function loadConfigWithRetry() {
241
+ let lastError;
242
+ for (let attempt = 0; attempt <= CONFIG_RETRY_DELAYS_MS.length; attempt++) {
243
+ try {
244
+ return await fetchConfigAttempt(CONFIG_ATTEMPT_TIMEOUT_MS);
245
+ }
246
+ catch (err) {
247
+ lastError = err;
248
+ if (attempt < CONFIG_RETRY_DELAYS_MS.length) {
249
+ await new Promise((resolve) => setTimeout(resolve, CONFIG_RETRY_DELAYS_MS[attempt]));
250
+ }
251
+ }
252
+ }
253
+ throw lastError instanceof Error ? lastError : new Error(String(lastError));
254
+ }
199
255
  return {
200
256
  async signIn(credentials) {
201
257
  const { data, error } = await betterAuth.signIn.email({
@@ -471,61 +527,73 @@ export function createAuthClient(config) {
471
527
  return (raw && typeof raw === 'object' && 'user' in raw ? raw.user : raw);
472
528
  },
473
529
  async getConfig() {
474
- const url = `${origin}${basePath}/config`;
475
- const response = await bearerFetch(url, {
476
- method: 'GET',
477
- headers: { 'Content-Type': 'application/json' },
478
- });
479
- if (!response.ok) {
480
- throw new Error(`Failed to load auth config (status ${response.status})`);
481
- }
482
- const body = (await response.json());
483
- // Server wraps the payload as `{ success, data }`; tolerate both shapes.
484
- if (body && typeof body === 'object' && 'data' in body && body.data) {
485
- return body.data;
530
+ if (!configPromise) {
531
+ configPromise = loadConfigWithRetry().catch((err) => {
532
+ configPromise = null;
533
+ throw err;
534
+ });
486
535
  }
487
- return body;
536
+ return configPromise;
488
537
  },
489
538
  async signInWithProvider(providerId, options = {}) {
490
539
  const { type = 'social', callbackURL, errorCallbackURL } = options;
540
+ // Watchdog (#2626): a sign-in request that HANGS (e.g. the environment
541
+ // kernel is cold-starting on first open) used to leave the provider
542
+ // button spinning forever — no error, no way to retry. Convert a
543
+ // no-response into a rejection so the button contract (#2458 pending +
544
+ // inline error) can recover it. The abandoned request may still land
545
+ // later; the user retrying just starts a fresh redirect, which is safe.
546
+ const SIGN_IN_TIMEOUT_MS = 20000;
547
+ const withTimeout = (p) => new Promise((resolve, reject) => {
548
+ const timer = setTimeout(() => reject(new Error('Sign-in timed out — the server did not respond. Please try again.')), SIGN_IN_TIMEOUT_MS);
549
+ p.then((v) => { clearTimeout(timer); resolve(v); }, (e) => { clearTimeout(timer); reject(e); });
550
+ });
491
551
  // We pass `disableDefaultFetchPlugins: true` to better-auth above,
492
552
  // which also disables better-auth's `redirectPlugin` (the one that
493
553
  // navigates the browser to `data.url` when the server responds with
494
554
  // `{ url, redirect: true }`). The server still returns that payload
495
555
  // for OAuth/OIDC providers — we just have to honour it ourselves.
496
- if (type === 'oidc') {
497
- const oauth2 = betterAuth.signIn.oauth2;
498
- if (!oauth2) {
499
- throw new Error('OIDC sign-in is not supported by this auth client build');
500
- }
501
- const { data, error } = await oauth2({ providerId, callbackURL, errorCallbackURL });
502
- if (error) {
503
- throw new Error(error.message ?? `Auth request failed with status ${error.status}`);
504
- }
505
- // A missing redirect URL means the flow CANNOT continue — surface it
506
- // instead of resolving silently (the user would see a dead button).
507
- if (!data?.url) {
508
- throw new Error(`Sign-in with "${providerId}" did not return a redirect URL — please try again or contact your administrator.`);
509
- }
510
- if (typeof window !== 'undefined') {
511
- window.location.href = data.url;
512
- }
513
- return;
514
- }
515
- const { data, error } = await betterAuth.signIn.social({
556
+ const signInSocial = async () => await withTimeout(betterAuth.signIn.social({
516
557
  provider: providerId,
517
558
  callbackURL,
518
559
  errorCallbackURL,
519
- });
560
+ }));
561
+ let result;
562
+ if (type === 'oidc') {
563
+ // better-auth ≥ 1.7 registers generic OAuth/OIDC providers through
564
+ // the core social sign-in flow — `POST /sign-in/social` with the
565
+ // provider id — and no longer mounts the dedicated
566
+ // `/sign-in/oauth2` endpoint. Try the social route first; when it
567
+ // rejects the provider (an older < 1.7 server that only knows OIDC
568
+ // providers via `/sign-in/oauth2`), fall back to the legacy route.
569
+ result = await signInSocial();
570
+ if (result.error) {
571
+ const oauth2 = betterAuth.signIn.oauth2;
572
+ const legacy = oauth2
573
+ ? await withTimeout(oauth2({ providerId, callbackURL, errorCallbackURL })).catch(() => null)
574
+ : null;
575
+ if (legacy && !legacy.error) {
576
+ result = legacy;
577
+ }
578
+ // Legacy also failed (or is unavailable) — keep the social-route
579
+ // error below: on a ≥ 1.7 server it is the real failure, while the
580
+ // legacy route only 404s.
581
+ }
582
+ }
583
+ else {
584
+ result = await signInSocial();
585
+ }
586
+ const { data, error } = result;
520
587
  if (error) {
521
588
  throw new Error(error.message ?? `Auth request failed with status ${error.status}`);
522
589
  }
523
- const socialUrl = data?.url;
524
- if (!socialUrl) {
590
+ // A missing redirect URL means the flow CANNOT continue — surface it
591
+ // instead of resolving silently (the user would see a dead button).
592
+ if (!data?.url) {
525
593
  throw new Error(`Sign-in with "${providerId}" did not return a redirect URL — please try again or contact your administrator.`);
526
594
  }
527
595
  if (typeof window !== 'undefined') {
528
- window.location.href = socialUrl;
596
+ window.location.href = data.url;
529
597
  }
530
598
  },
531
599
  // --- Organization / Workspace methods ---
@@ -24,6 +24,18 @@ export declare const ActiveOrganizationStorage: {
24
24
  set(orgId: string): void;
25
25
  clear(): void;
26
26
  };
27
+ export interface CreateAuthenticatedFetchOptions {
28
+ /**
29
+ * When true, requests whose URL resolves to a different origin than the
30
+ * current page are passed through to the bare global fetch — no
31
+ * Authorization, X-Tenant-ID, or Accept-Language headers are attached.
32
+ *
33
+ * Use this for fetches whose target URL comes from view metadata
34
+ * (`provider: 'api'` data sources): those may point at third-party hosts
35
+ * that must never see the platform bearer token.
36
+ */
37
+ sameOriginOnly?: boolean;
38
+ }
27
39
  /**
28
40
  * Creates an authenticated fetch wrapper that injects the Bearer token
29
41
  * from localStorage into every request to the ObjectStack API.
@@ -42,5 +54,5 @@ export declare const ActiveOrganizationStorage: {
42
54
  * });
43
55
  * ```
44
56
  */
45
- export declare function createAuthenticatedFetch(): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
57
+ export declare function createAuthenticatedFetch(options?: CreateAuthenticatedFetchOptions): (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
46
58
  //# sourceMappingURL=createAuthenticatedFetch.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"createAuthenticatedFetch.d.ts","sourceRoot":"","sources":["../src/createAuthenticatedFetch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID;;;GAGG;AACH,eAAO,MAAM,yBAAyB;kBACd,MAAM,GAAG,IAAI;WAE5B,MAAM,GAAG,IAAI;eAST,MAAM,GAAG,IAAI;aASf,IAAI;CAQd,CAAC;AAEF;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,IAAI,CAAC,KAAK,EAAE,WAAW,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CAuC9G"}
1
+ {"version":3,"file":"createAuthenticatedFetch.d.ts","sourceRoot":"","sources":["../src/createAuthenticatedFetch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AAKH;;GAEG;AACH,MAAM,WAAW,2BAA2B;IAC1C,uCAAuC;IACvC,OAAO,EAAE,MAAM,CAAC;IAChB,iCAAiC;IACjC,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAID;;;GAGG;AACH,eAAO,MAAM,yBAAyB;kBACd,MAAM,GAAG,IAAI;WAE5B,MAAM,GAAG,IAAI;eAST,MAAM,GAAG,IAAI;aASf,IAAI;CAQd,CAAC;AAEF,MAAM,WAAW,+BAA+B;IAC9C;;;;;;;;OAQG;IACH,cAAc,CAAC,EAAE,OAAO,CAAC;CAC1B;AAYD;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,wBAAwB,CACtC,OAAO,CAAC,EAAE,+BAA+B,GACxC,CAAC,KAAK,EAAE,WAAW,GAAG,GAAG,EAAE,IAAI,CAAC,EAAE,WAAW,KAAK,OAAO,CAAC,QAAQ,CAAC,CA0CrE"}
@@ -42,6 +42,17 @@ export const ActiveOrganizationStorage = {
42
42
  catch { /* SSR / test */ }
43
43
  },
44
44
  };
45
+ /** True when `url` resolves to a different origin than the current page. */
46
+ function isCrossOrigin(url) {
47
+ if (typeof window === 'undefined' || !window.location)
48
+ return true;
49
+ try {
50
+ return new URL(url, window.location.href).origin !== window.location.origin;
51
+ }
52
+ catch {
53
+ return true;
54
+ }
55
+ }
45
56
  /**
46
57
  * Creates an authenticated fetch wrapper that injects the Bearer token
47
58
  * from localStorage into every request to the ObjectStack API.
@@ -60,10 +71,13 @@ export const ActiveOrganizationStorage = {
60
71
  * });
61
72
  * ```
62
73
  */
63
- export function createAuthenticatedFetch() {
74
+ export function createAuthenticatedFetch(options) {
64
75
  return async (input, init) => {
65
76
  const headers = new Headers(init?.headers);
66
77
  const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
78
+ if (options?.sameOriginOnly && isCrossOrigin(url)) {
79
+ return fetch(input, init);
80
+ }
67
81
  const isApiCall = /\/api\//i.test(url);
68
82
  const token = TokenStorage.get();
69
83
  if (token && isApiCall) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@object-ui/auth",
3
- "version": "15.0.0",
3
+ "version": "16.1.0",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "description": "Authentication system for Object UI with AuthProvider, useAuth hook, AuthGuard, and form components.",
@@ -31,7 +31,7 @@
31
31
  },
32
32
  "dependencies": {
33
33
  "better-auth": "^1.6.23",
34
- "@object-ui/types": "15.0.0"
34
+ "@object-ui/types": "16.1.0"
35
35
  },
36
36
  "devDependencies": {
37
37
  "@types/react": "19.2.17",