diff --git a/.changeset/login-config-race-sso-timeout.md b/.changeset/login-config-race-sso-timeout.md new file mode 100644 index 000000000..038e22666 --- /dev/null +++ b/.changeset/login-config-race-sso-timeout.md @@ -0,0 +1,21 @@ +--- +'@object-ui/auth': patch +--- + +Login-page auth-config hardening (#2625, #2626): + +- `createAuthClient.getConfig` now single-flights + caches the `/auth/config` + fetch (the login page's three consumers used to fire three requests) and + retries failures with backoff (500ms/1.5s/3.5s, 8s per-attempt abort) before + rejecting. A cold-starting environment kernel no longer strands the page + without its SSO buttons; a final failure clears the cache so later callers + retry. +- `LoginForm` holds a spinner instead of painting the password-form defaults + while config resolves — an SSO-only deployment must never flash a password + wall at JIT users who have no password. A failed config still falls back to + the password form (break-glass beats lock-out). +- `signInWithProvider` gains a 20s watchdog: a sign-in request that hangs now + rejects with a clear timeout error so the provider button recovers instead + of spinning forever. +- Removed LoginForm's duplicate "or" divider — SocialSignInButtons already + renders its own, and the stacked pair read as a rendering glitch. diff --git a/packages/auth/src/LoginForm.tsx b/packages/auth/src/LoginForm.tsx index bcd0fbb03..795192a7a 100644 --- a/packages/auth/src/LoginForm.tsx +++ b/packages/auth/src/LoginForm.tsx @@ -188,6 +188,13 @@ export function LoginForm({ // state because SSO routing is a raw fetch, not the shared `signIn` (whose // `isLoading` drives the password submit button). const [ssoSubmitting, setSsoSubmitting] = useState(false); + // Config not resolved yet: render a spinner INSTEAD of the password-form + // defaults. Painting the defaults first showed an SSO-only deployment a + // password wall (no SSO button, no enforced collapse) whenever the config + // fetch was slow/failed on first load — and platform-SSO JIT users have no + // password at all (#2625). A FAILED fetch (after the client's retries) + // still falls back to the password form: break-glass beats lock-out. + const [configPending, setConfigPending] = useState(true); useEffect(() => { let cancelled = false; @@ -206,6 +213,9 @@ export function LoginForm({ .catch(() => { // SSO is an enhancement, not required — leave the buttons/form hidden // or shown at their safe defaults. + }) + .finally(() => { + if (!cancelled) setConfigPending(false); }); return () => { cancelled = true; @@ -349,6 +359,14 @@ export function LoginForm({ />