From b732367e7b03f72a61ad26c6b73b5e47e72b8177 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:18:32 +0000 Subject: [PATCH 1/7] fix(auth): login pages say when the server cannot be reached and wait for the posture before offering Sign up The console's own /login and app-shell's DefaultLoginPage held the public auth config as `null` both while it loaded and after a failed read, and `decideSignUpOffer` answers `null` as "offer the link". With the server down the page drew a live-looking form, offered "Sign up", and the user found out on submit. Each page now tracks the read as loading / failed / known. While the posture is unknown it renders no sign-up link; when the read fails it renders "Cannot connect to server" with a Retry that reads the config again, in place of the form. The exported sign-up decision is unchanged. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- apps/console/src/pages/auth/LoginPage.tsx | 100 +++++++++++++++--- .../app-shell/src/console/auth/LoginPage.tsx | 78 ++++++++++++-- 2 files changed, 156 insertions(+), 22 deletions(-) diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index 1f3f12c50a..f818204175 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -18,12 +18,26 @@ * for an invitation redirect or a deployment with no owner yet — see * `decideSignUpOffer` in `@object-ui/app-shell`, the one decision this page * shares with the package's exported `DefaultLoginPage` (objectui#11691, - * objectui#11705). + * objectui#11705). The link waits for the config read to ANSWER: while it + * is pending, or after it failed, the posture is unknown and nothing is + * offered (objectui#11806). + * - Says so when the server cannot be reached: a failed config read replaces + * the form with "Cannot connect to server" and a Retry that reads it again + * (objectui#11806). The sign-in request goes to the same server, so a + * live-looking form would only have deferred that news to the submit. */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth, LoginForm, AuthErrorBanner } from '@object-ui/auth'; +import { + useAuth, + LoginForm, + AuthErrorBanner, + AuthAlertIcon, + AuthFormHeader, + AuthSpinner, + AUTH_PRIMARY_BUTTON_CLASS, +} from '@object-ui/auth'; import type { AuthPublicConfig } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { Card } from '@object-ui/components'; @@ -70,10 +84,24 @@ export function LoginPage() { getAuthConfig, } = useAuth(); - // The public auth config, once read — `null` until then (and after a failed - // read), which `decideSignUpOffer` answers as "offer the link", the - // behaviour before the config is known. + // The public auth config and where its read stands (objectui#11806): + // `loading` until `getAuthConfig()` settles, `failed` once it rejected — the + // auth client has already retried by then — and `known` once the server + // answered. `authConfig` stays `null` until `known`; `decideSignUpOffer` + // answers `null` as "offer the link", so the page consults it only once the + // read is `known`. `configReadAttempt` counts Retry presses, and re-runs the + // read below. const [authConfig, setAuthConfig] = useState(null); + const [configRead, setConfigRead] = useState<'loading' | 'failed' | 'known'>('loading'); + const [configReadAttempt, setConfigReadAttempt] = useState(0); + const retryConfigRead = () => { + setConfigRead('loading'); + setConfigReadAttempt((n) => n + 1); + }; + // A retry in flight keeps the unreachable state up (its button reads + // "Retrying…") rather than flashing the form before the server has answered. + const serverUnreachable = + configRead === 'failed' || (configRead === 'loading' && configReadAttempt > 0); // Dev-only seeded-admin hint (15.1 third-party eval): the runtime seeds // admin@objectos.ai on an empty dev DB, but nothing on this page said so — // new users clicked "Sign up" and landed in an empty non-admin workspace. @@ -151,14 +179,16 @@ export function LoginPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Read public auth config once to know whether sign-up is offered and - // whether the dev-seeded admin credentials should be surfaced. + // Read public auth config to know whether sign-up is offered and whether + // the dev-seeded admin credentials should be surfaced — once on mount, and + // again on each Retry after a failed read. useEffect(() => { let cancelled = false; getAuthConfig() .then((cfg) => { if (cancelled) return; setAuthConfig(cfg ?? null); + setConfigRead('known'); const seed = (cfg as { devSeedAdmin?: { email?: unknown; password?: unknown } } | null) ?.devSeedAdmin; setDevSeedAdmin( @@ -171,12 +201,14 @@ export function LoginPage() { ); }) .catch(() => { - /* leave defaults — server-side gate is the source of truth */ + // The server did not answer (the auth client retried first): say so + // instead of drawing a form whose submit goes to the same server. + if (!cancelled) setConfigRead('failed'); }); return () => { cancelled = true; }; - }, [getAuthConfig]); + }, [getAuthConfig, configReadAttempt]); // Post-login orchestration — fires once we observe an authenticated user. useEffect(() => { @@ -319,16 +351,58 @@ export function LoginPage() { ) : null} - + {serverUnreachable ? ( + + ) : ( + + )} ); } +/** + * In place of the form while the server cannot be reached (objectui#11806). + * Built from `@object-ui/auth`'s own form primitives so it sits where + * `` would, at the same width and in the same visual language. + */ +function ServerUnreachable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) { + const { t } = useObjectTranslation(); + return ( +
+
+ } + title={t('console.error.connectionFailed', { defaultValue: 'Cannot connect to server' })} + description={t('console.error.checkServer', { + defaultValue: 'Please check your network connection or that the backend is running.', + })} + /> +
+ +
+ ); +} + function LoginFormCard({ registerUrl, redirect, diff --git a/packages/app-shell/src/console/auth/LoginPage.tsx b/packages/app-shell/src/console/auth/LoginPage.tsx index 54a750ff38..cb7f2af6ed 100644 --- a/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/packages/app-shell/src/console/auth/LoginPage.tsx @@ -6,12 +6,28 @@ * an audience posture closed to strangers (`invite_only`, the default) only * for an invitation redirect or a deployment with no owner yet. The decision * is `decideSignUpOffer` (`./signUpOffer`), the same one the console's own - * login page calls (objectui#11691, objectui#11705). + * login page calls (objectui#11691, objectui#11705). The link waits for the + * config read to ANSWER: while it is pending, or after it failed, the posture + * is unknown and nothing is offered (objectui#11806). + * + * When that read fails the page says the server cannot be reached, with a + * Retry that reads it again, in place of the form (objectui#11806). The + * sign-in request goes to the same server, so a live-looking form would only + * have deferred that news to the submit. */ import { useEffect, useState } from 'react'; import { useNavigate, useSearchParams, Link } from 'react-router-dom'; -import { LoginForm, useAuth, type AuthLinkComponentProps, type AuthPublicConfig } from '@object-ui/auth'; +import { + LoginForm, + useAuth, + AuthAlertIcon, + AuthFormHeader, + AuthSpinner, + AUTH_PRIMARY_BUTTON_CLASS, + type AuthLinkComponentProps, + type AuthPublicConfig, +} from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { AuthPageLayout } from './AuthPageLayout.js'; import { signInRefusalMessages } from './signInRefusalMessages.js'; @@ -29,17 +45,35 @@ export function LoginPage() { const { t } = useObjectTranslation(); const { user, getAuthConfig } = useAuth(); - // The public auth config, once read — `null` until then (and after a failed - // read), which `decideSignUpOffer` answers as "offer the link", the - // behaviour before the config is known. + // The public auth config and where its read stands (objectui#11806): + // `loading` until `getAuthConfig()` settles, `failed` once it rejected — the + // auth client has already retried by then — and `known` once the server + // answered. `authConfig` stays `null` until `known`; `decideSignUpOffer` + // answers `null` as "offer the link", so the page consults it only once the + // read is `known`. `configReadAttempt` counts Retry presses, and re-runs the + // read below. const [authConfig, setAuthConfig] = useState(null); + const [configRead, setConfigRead] = useState<'loading' | 'failed' | 'known'>('loading'); + const [configReadAttempt, setConfigReadAttempt] = useState(0); useEffect(() => { let cancelled = false; getAuthConfig() - .then(cfg => { if (!cancelled) setAuthConfig(cfg ?? null); }) - .catch(() => { /* leave `null` — the server-side gate is the source of truth */ }); + .then(cfg => { + if (cancelled) return; + setAuthConfig(cfg ?? null); + setConfigRead('known'); + }) + .catch(() => { if (!cancelled) setConfigRead('failed'); }); return () => { cancelled = true; }; - }, [getAuthConfig]); + }, [getAuthConfig, configReadAttempt]); + const retryConfigRead = () => { + setConfigRead('loading'); + setConfigReadAttempt(n => n + 1); + }; + // A retry in flight keeps the unreachable state up (its button reads + // "Retrying…") rather than flashing the form before the server has answered. + const serverUnreachable = + configRead === 'failed' || (configRead === 'loading' && configReadAttempt > 0); // objectui#11705 — whether this visitor is offered "Sign up". The bootstrap // probe runs only when the posture is closed to strangers and the visitor @@ -54,11 +88,37 @@ export function LoginPage() { ? `/register?redirect=${encodeURIComponent(redirect)}` : '/register'; + if (serverUnreachable) { + const retrying = configRead === 'loading'; + return ( + +
+
+ } + title={t('console.error.connectionFailed')} + description={t('console.error.checkServer')} + /> +
+ +
+
+ ); + } + return ( navigate('/')} - registerUrl={signUpOffer === 'form' ? registerUrl : undefined} + registerUrl={configRead === 'known' && signUpOffer === 'form' ? registerUrl : undefined} forgotPasswordUrl="/forgot-password" title={t('auth.login.title')} description={t('auth.login.description')} From 4fe3b84ed8e01367c181cee3d795bef69334650d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:20:43 +0000 Subject: [PATCH 2/7] test(auth): pin the login pages' unreachable-server state and the posture-unknown offer Both pages: a pending config read offers no "Sign up"; a failed read renders "Cannot connect to server" with Retry in place of the form and no "Sign up"; Retry reads again, keeps the unreachable state up while in flight, and an answer brings back the form with the offer it decides. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- ...oginPage.server-unreachable-11806.test.tsx | 159 +++++++++++++++++ ...faultLoginServerUnreachable-11806.test.tsx | 162 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 apps/console/src/pages/auth/__tests__/LoginPage.server-unreachable-11806.test.tsx create mode 100644 packages/app-shell/src/console/auth/__tests__/defaultLoginServerUnreachable-11806.test.tsx diff --git a/apps/console/src/pages/auth/__tests__/LoginPage.server-unreachable-11806.test.tsx b/apps/console/src/pages/auth/__tests__/LoginPage.server-unreachable-11806.test.tsx new file mode 100644 index 0000000000..c43750c39a --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/LoginPage.server-unreachable-11806.test.tsx @@ -0,0 +1,159 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11806 — the console's `/login` when the server cannot be reached. + * + * The page reads `/api/v1/auth/config` to decide the "Sign up" offer. It held + * that config as `null` both while the read was pending and after it failed, + * and `decideSignUpOffer` answers `null` as "offer the link" — so with the + * server down the page drew a live-looking form, offered "Sign up", and the + * visitor found out only on submit. + * + * The auth client is a stub whose `getConfig` is scripted read by read and, + * like `createAuthClient`'s, shares one in-flight read between callers and + * forgets a failed one. A scripted rejection stands for the real client's + * final answer: `createAuthClient` retries the read itself before it rejects. + * Visible text is read from the en locale pack, not copied here. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient, AuthPublicConfig } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; + +const en = builtInLocales.en; +const SIGN_UP_LINK = { name: en.auth.login.signUpText } as const; +const UNREACHABLE = en.console.error.connectionFailed; +const RETRY = { name: en.console.actions.retry } as const; +const RETRYING = { name: en.console.actions.retrying } as const; + +/** What the real client rejects with once its own retries ran out on a 502. */ +const BAD_GATEWAY = () => Promise.reject(new Error('Failed to load auth config (status 502)')); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * A signed-out visitor whose config reads are answered, in order, by `reads`. + * `serverReads()` counts the reads that reached the "server". + */ +function scriptedClient(reads: Array<() => Promise>) { + let inFlight: Promise | null = null; + let served = 0; + const client = { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn(() => { + if (!inFlight) { + const read = (reads[served] ?? (() => new Promise(() => {})))(); + served += 1; + inFlight = read; + read.catch(() => { + if (inFlight === read) inFlight = null; + }); + } + return inFlight; + }), + } as unknown as AuthClient; + return { client, serverReads: () => served }; +} + +function renderLogin(client: AuthClient) { + window.history.replaceState({}, '', '/login'); + return render( + + + + + + + , + ); +} + +const OPEN: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false }, + features: { audiencePosture: 'open' }, +}; +const SIGN_UP_OFF: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: true }, + features: { audiencePosture: 'open' }, +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('console LoginPage — the server cannot be reached (objectui#11806)', () => { + it('while the config read is pending, offers no "Sign up" and does not call the server unreachable', async () => { + const { client } = scriptedClient([() => new Promise(() => {})]); + renderLogin(client); + + await screen.findByTestId('login-config-loading'); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('when the config read fails, replaces the form with "Cannot connect to server" and Retry, and offers no "Sign up"', async () => { + const { client } = scriptedClient([BAD_GATEWAY]); + renderLogin(client); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText(UNREACHABLE)).toBeInTheDocument(); + expect(within(alert).getByText(en.console.error.checkServer)).toBeInTheDocument(); + expect(screen.getByRole('button', RETRY)).toBeEnabled(); + expect(screen.queryByLabelText(en.auth.login.emailLabel)).toBeNull(); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); + + it.each([ + ['open', OPEN, '/register'], + ['disableSignUp: true', SIGN_UP_OFF, null], + ] as const)( + 'Retry reads the config again; once a %s answer lands the form returns with the offer that answer decides', + async (_label, config, signUpHref) => { + const second = deferred(); + const { client, serverReads } = scriptedClient([BAD_GATEWAY, () => second.promise]); + renderLogin(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + // In flight: still the unreachable state, the button busy — no form yet. + expect(screen.getByRole('button', RETRYING)).toBeDisabled(); + expect(screen.queryByLabelText(en.auth.login.emailLabel)).toBeNull(); + expect(serverReads()).toBe(2); + + second.resolve(config); + + await screen.findByLabelText(en.auth.login.emailLabel); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + const link = screen.queryByRole('link', SIGN_UP_LINK); + expect(link?.getAttribute('href') ?? null).toBe(signUpHref); + }, + ); +}); diff --git a/packages/app-shell/src/console/auth/__tests__/defaultLoginServerUnreachable-11806.test.tsx b/packages/app-shell/src/console/auth/__tests__/defaultLoginServerUnreachable-11806.test.tsx new file mode 100644 index 0000000000..e066ed124b --- /dev/null +++ b/packages/app-shell/src/console/auth/__tests__/defaultLoginServerUnreachable-11806.test.tsx @@ -0,0 +1,162 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11806 — `DefaultLoginPage` when the server cannot be reached. + * + * The page reads `/api/v1/auth/config` to decide the "Sign up" offer. It held + * that config as `null` both while the read was pending and after it failed, + * and `decideSignUpOffer` answers `null` as "offer the link" — so with the + * server down the page drew a live-looking form, offered "Sign up", and the + * visitor found out only on submit. The console's own `/login` had the same + * defect; its pins are `LoginPage.server-unreachable-11806.test.tsx` in + * `apps/console/src/pages/auth/__tests__`. + * + * The auth client is a stub whose `getConfig` is scripted read by read and, + * like `createAuthClient`'s, shares one in-flight read between callers and + * forgets a failed one. A scripted rejection stands for the real client's + * final answer: `createAuthClient` retries the read itself before it rejects. + * Visible text is read from the en locale pack, not copied here — the page + * passes no inline defaults, so the text appearing is the proof that its + * `console.*` keys resolve through its own `t`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient, AuthPublicConfig } from '@object-ui/auth'; +import { LoginPage } from '../LoginPage'; + +const en = builtInLocales.en; +const SIGN_UP_LINK = { name: en.auth.login.signUpText } as const; +const UNREACHABLE = en.console.error.connectionFailed; +const RETRY = { name: en.console.actions.retry } as const; +const RETRYING = { name: en.console.actions.retrying } as const; + +/** What the real client rejects with once its own retries ran out on a 502. */ +const BAD_GATEWAY = () => Promise.reject(new Error('Failed to load auth config (status 502)')); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * A signed-out visitor whose config reads are answered, in order, by `reads`. + * `serverReads()` counts the reads that reached the "server". + */ +function scriptedClient(reads: Array<() => Promise>) { + let inFlight: Promise | null = null; + let served = 0; + const client = { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn(() => { + if (!inFlight) { + const read = (reads[served] ?? (() => new Promise(() => {})))(); + served += 1; + inFlight = read; + read.catch(() => { + if (inFlight === read) inFlight = null; + }); + } + return inFlight; + }), + } as unknown as AuthClient; + return { client, serverReads: () => served }; +} + +function renderLogin(client: AuthClient) { + return render( + + + + + + + , + ); +} + +const OPEN: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false }, + features: { audiencePosture: 'open' }, +}; +const SIGN_UP_OFF: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: true }, + features: { audiencePosture: 'open' }, +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('DefaultLoginPage — the server cannot be reached (objectui#11806)', () => { + it('while the config read is pending, offers no "Sign up" and does not call the server unreachable', async () => { + const { client } = scriptedClient([() => new Promise(() => {})]); + renderLogin(client); + + await screen.findByTestId('login-config-loading'); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('when the config read fails, replaces the form with "Cannot connect to server" and Retry, and offers no "Sign up"', async () => { + const { client } = scriptedClient([BAD_GATEWAY]); + renderLogin(client); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText(UNREACHABLE)).toBeInTheDocument(); + expect(within(alert).getByText(en.console.error.checkServer)).toBeInTheDocument(); + expect(screen.getByRole('button', RETRY)).toBeEnabled(); + expect(screen.queryByLabelText(en.auth.login.emailLabel)).toBeNull(); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); + + it.each([ + ['open', OPEN, '/register'], + ['disableSignUp: true', SIGN_UP_OFF, null], + ] as const)( + 'Retry reads the config again; once a %s answer lands the form returns with the offer that answer decides', + async (_label, config, signUpHref) => { + const second = deferred(); + const { client, serverReads } = scriptedClient([BAD_GATEWAY, () => second.promise]); + renderLogin(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + // In flight: still the unreachable state, the button busy — no form yet. + expect(screen.getByRole('button', RETRYING)).toBeDisabled(); + expect(screen.queryByLabelText(en.auth.login.emailLabel)).toBeNull(); + expect(serverReads()).toBe(2); + + second.resolve(config); + + await screen.findByLabelText(en.auth.login.emailLabel); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + const link = screen.queryByRole('link', SIGN_UP_LINK); + expect(link?.getAttribute('href') ?? null).toBe(signUpHref); + }, + ); +}); From e7c3cb24f27a00cdf28a2d4f85add2d40eb22b4c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 03:24:08 +0000 Subject: [PATCH 3/7] chore(changeset): patch app-shell and console for objectui#11806 Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/11806-login-server-unreachable.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/11806-login-server-unreachable.md diff --git a/.changeset/11806-login-server-unreachable.md b/.changeset/11806-login-server-unreachable.md new file mode 100644 index 0000000000..1ad46637c8 --- /dev/null +++ b/.changeset/11806-login-server-unreachable.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +--- + +The login page says when the server cannot be reached, and offers "Sign up" only once the server has said whether it accepts sign-ups (objectui#11806). This applies to the console's own `/login` and to `DefaultLoginPage`. + +Both pages read `/api/v1/auth/config` to decide the "Sign up" offer. They held that config as `null` both while the read was pending and after it failed, and `decideSignUpOffer` answers `null` as "offer the link". So with the server down the page drew a normal-looking form and offered "Sign up", and the visitor found out only when they submitted. + +**Behaviour change.** +- While the config read is pending, neither page shows the "Sign up" link. The form's own spinner shows as before. +- When the read fails, the page shows "Cannot connect to server", the hint to check the network or the backend, and a Retry button, in place of the form. The auth client has already retried the read itself by then. The "Sign up" link stays hidden. +- Retry reads the config again. While that read is in flight the button reads "Retrying…" and the form stays hidden. When the server answers, the form returns, and "Sign up" follows that answer exactly as before. +- When the server answers the first read, nothing changes. + +**Clause-②: no.** No export, prop, type member or accepted value changes. `decideSignUpOffer` and its answers are unchanged: the pages consult it only once the read has answered. The text comes from existing `en` keys: `console.error.connectionFailed`, `console.error.checkServer`, `console.actions.retry` and `console.actions.retrying`. From b380b7de9a75b588a7f930ce94c08275074147dc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:24:57 +0000 Subject: [PATCH 4/7] fix(auth): register pages say when the server cannot be reached The console's /register and app-shell's DefaultRegisterPage stored a failed config read as `{ config: null }`, which `decideSignUpOffer` answers as "form": with the server down they drew the full registration form. They now track the read as loading / failed / known like the login pages: nothing is offered until it answers, and a failed read shows the same "Cannot connect to server" panel with Retry. Each side's login page now exports its panel (`ServerUnreachable`, with a `data-testid`) and its register page reuses it. The package entry is unchanged. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- apps/console/src/pages/auth/LoginPage.tsx | 14 +++-- apps/console/src/pages/auth/RegisterPage.tsx | 61 +++++++++++++++---- .../app-shell/src/console/auth/LoginPage.tsx | 53 ++++++++++------ .../src/console/auth/RegisterPage.tsx | 51 +++++++++++++--- 4 files changed, 132 insertions(+), 47 deletions(-) diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index f818204175..904b25eb78 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -371,14 +371,18 @@ export function LoginPage() { } /** - * In place of the form while the server cannot be reached (objectui#11806). - * Built from `@object-ui/auth`'s own form primitives so it sits where - * `` would, at the same width and in the same visual language. + * In place of the form while the server cannot be reached (objectui#11806); + * `RegisterPage` shows the same panel. Built from `@object-ui/auth`'s own form + * primitives so it sits where `` would, at the same width and in + * the same visual language. */ -function ServerUnreachable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) { +export function ServerUnreachable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) { const { t } = useObjectTranslation(); return ( -
+
} diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index 4fb33b3030..7715410b20 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -13,6 +13,11 @@ * BEFORE the form — see `decideSignUpOffer` in `@object-ui/app-shell`, the * one decision this page shares with the package's exported * `DefaultRegisterPage` (objectui#11691, objectui#11705). + * - Offers nothing until the config read ANSWERS (objectui#11806): while it + * is pending the page shows its spinner, and when it fails it shows the + * same "Cannot connect to server" panel with Retry as `/login`, in place + * of the form. The sign-up request goes to the same server, so a + * live-looking form would only have deferred that news to the submit. * - Routes to `/verify-email-prompt` when the server requires email * verification before sign-in, and carries `?redirect=` into the * verification mail's link so it survives the inbox (objectui#10893). @@ -38,6 +43,7 @@ import { followOauthAuthorize } from './followAuthorize'; // Was a second module-private copy of LoginPage's helper; both now share one // implementation — objectui#4181. Behaviour here is unchanged. import { withConsoleBase, withConsoleBaseRootRelative } from '../../utils/consoleBase'; +import { ServerUnreachable } from './LoginPage'; function isSafeRedirect(target: string | null): target is string { return !!target && target.startsWith('/') && !target.startsWith('//'); @@ -66,10 +72,23 @@ export function RegisterPage() { getAuthConfig, } = useAuth(); - // `null` until the public auth config has been read; then `{ config }`, - // whose `config` is `null` when the read failed — answered as "offer the - // form", leaving the server's own gate as the source of truth. - const [configRead, setConfigRead] = useState<{ config: AuthPublicConfig | null } | null>(null); + // The public auth config and where its read stands (objectui#11806), as on + // `/login`: `loading` until `getAuthConfig()` settles, `failed` once it + // rejected — the auth client has already retried by then — and `known` once + // the server answered. `authConfig` stays `null` until `known`, and the + // offer is consulted only once the read is `known`. `configReadAttempt` + // counts Retry presses, and re-runs the read below. + const [authConfig, setAuthConfig] = useState(null); + const [configRead, setConfigRead] = useState<'loading' | 'failed' | 'known'>('loading'); + const [configReadAttempt, setConfigReadAttempt] = useState(0); + const retryConfigRead = () => { + setConfigRead('loading'); + setConfigReadAttempt((n) => n + 1); + }; + // A retry in flight keeps the unreachable state up (its button reads + // "Retrying…") rather than flashing the form before the server has answered. + const serverUnreachable = + configRead === 'failed' || (configRead === 'loading' && configReadAttempt > 0); const [autoSelectingOrg, setAutoSelectingOrg] = useState(false); // Fire the OAuth hand-off fetch at most once (see LoginPage). const ssoHandoffStartedRef = useRef(false); @@ -84,28 +103,32 @@ export function RegisterPage() { }, [isLoading]); // Probe public auth config — what this visitor is offered follows from it. + // Once on mount, and again on each Retry after a failed read. useEffect(() => { let cancelled = false; getAuthConfig() .then((cfg) => { - if (!cancelled) setConfigRead({ config: cfg ?? null }); + if (cancelled) return; + setAuthConfig(cfg ?? null); + setConfigRead('known'); }) .catch(() => { - if (!cancelled) setConfigRead({ config: null }); + // The server did not answer (the auth client retried first): say so + // instead of drawing a form whose submit goes to the same server. + if (!cancelled) setConfigRead('failed'); }); return () => { cancelled = true; }; - }, [getAuthConfig]); + }, [getAuthConfig, configReadAttempt]); // objectui#11691 — the offer reads `disableSignUp` AND the audience posture; // the bootstrap probe runs only when the posture is closed to strangers and // the visitor did not come from an invitation. See app-shell's // `decideSignUpOffer`. - const authConfig = configRead ? configRead.config : null; const invitationRedirect = isInvitationRedirect(redirect); const bootstrap = useBootstrapStatus( - configRead !== null && + configRead === 'known' && hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect), @@ -163,12 +186,24 @@ export function RegisterPage() { switchOrganization, ]); + // The first session check has answered and nobody is signed in: the page is + // talking to a visitor, so a failed config read is theirs to see. + const awaitingSession = (isLoading && !hasBootstrapped) || !!user; + if (!awaitingSession && serverUnreachable) { + return ( + + + + + + ); + } + if ( - configRead === null || + awaitingSession || + configRead !== 'known' || signUpOffer === 'closed' || - signUpOffer === 'pending' || - (isLoading && !hasBootstrapped) || - user + signUpOffer === 'pending' ) { return ( diff --git a/packages/app-shell/src/console/auth/LoginPage.tsx b/packages/app-shell/src/console/auth/LoginPage.tsx index cb7f2af6ed..aa1b2db655 100644 --- a/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/packages/app-shell/src/console/auth/LoginPage.tsx @@ -38,6 +38,39 @@ const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} ); +/** + * In place of the form while the server cannot be reached (objectui#11806); + * `./RegisterPage` shows the same panel. Built from `@object-ui/auth`'s own + * form primitives so it sits where the form would, at the same width. Not + * exported from the package entry. + */ +export function ServerUnreachable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) { + const { t } = useObjectTranslation(); + return ( +
+
+ } + title={t('console.error.connectionFailed')} + description={t('console.error.checkServer')} + /> +
+ +
+ ); +} + export function LoginPage() { const navigate = useNavigate(); const [params] = useSearchParams(); @@ -89,27 +122,9 @@ export function LoginPage() { : '/register'; if (serverUnreachable) { - const retrying = configRead === 'loading'; return ( -
-
- } - title={t('console.error.connectionFailed')} - description={t('console.error.checkServer')} - /> -
- -
+
); } diff --git a/packages/app-shell/src/console/auth/RegisterPage.tsx b/packages/app-shell/src/console/auth/RegisterPage.tsx index 683d570033..96ca820c27 100644 --- a/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -18,6 +18,12 @@ * check answers with a user, the page sends them where it sends a visitor * after a successful sign-up (`/`) — the console's own register page does the * same — and never renders the form, the notice, or an empty layout for them. + * + * Nothing is offered until the config read ANSWERS (objectui#11806): while it + * is pending the page renders an empty layout, and when it fails it shows the + * same "Cannot connect to server" panel with Retry as `./LoginPage`, in place + * of the form. The sign-up request goes to the same server, so a live-looking + * form would only have deferred that news to the submit. */ import { useEffect, useState } from 'react'; @@ -35,6 +41,7 @@ import { AuthPageLayout } from './AuthPageLayout.js'; import { signUpRefusalMessages } from './signUpRefusalMessages.js'; import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; import { useBootstrapStatus } from './bootstrapStatus.js'; +import { ServerUnreachable } from './LoginPage.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -56,11 +63,16 @@ export function RegisterPage() { const [sessionChecked, setSessionChecked] = useState(!isLoading); if (!isLoading && !sessionChecked) setSessionChecked(true); - // `null` until the public auth config has been read; then `{ config }`, - // whose `config` is `null` when the read failed — answered as "offer the - // form", leaving the server's own gate as the source of truth. Nothing is - // rendered before the read, so the form never flashes. - const [configRead, setConfigRead] = useState<{ config: AuthPublicConfig | null } | null>(null); + // The public auth config and where its read stands (objectui#11806), as on + // `./LoginPage`: `loading` until `getAuthConfig()` settles, `failed` once it + // rejected — the auth client has already retried by then — and `known` once + // the server answered. `authConfig` stays `null` until `known`, and the offer + // is consulted only once the read is `known`; nothing is rendered before + // then, so the form never flashes. `configReadAttempt` counts Retry presses, + // and re-runs the read below. + const [authConfig, setAuthConfig] = useState(null); + const [configRead, setConfigRead] = useState<'loading' | 'failed' | 'known'>('loading'); + const [configReadAttempt, setConfigReadAttempt] = useState(0); const [pendingEmail, setPendingEmail] = useState(null); const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [resendError, setResendError] = useState(null); @@ -68,16 +80,27 @@ export function RegisterPage() { useEffect(() => { let cancelled = false; getAuthConfig() - .then(cfg => { if (!cancelled) setConfigRead({ config: cfg ?? null }); }) - .catch(() => { if (!cancelled) setConfigRead({ config: null }); }); + .then(cfg => { + if (cancelled) return; + setAuthConfig(cfg ?? null); + setConfigRead('known'); + }) + .catch(() => { if (!cancelled) setConfigRead('failed'); }); return () => { cancelled = true; }; - }, [getAuthConfig]); + }, [getAuthConfig, configReadAttempt]); + const retryConfigRead = () => { + setConfigRead('loading'); + setConfigReadAttempt(n => n + 1); + }; + // A retry in flight keeps the unreachable state up (its button reads + // "Retrying…") rather than flashing the form before the server has answered. + const serverUnreachable = + configRead === 'failed' || (configRead === 'loading' && configReadAttempt > 0); // objectui#11705 — the offer reads `disableSignUp` AND the audience posture; // the bootstrap probe runs only when the posture is closed to strangers and // the visitor did not come from an invitation. See `./signUpOffer`. It is // asked only for a visitor known to be signed out (objectui#11714). - const authConfig = configRead ? configRead.config : null; const invitationRedirect = isInvitationRedirect(redirect); const signedOut = sessionChecked && !user; const bootstrap = useBootstrapStatus(signedOut && needsBootstrapProbe(authConfig, invitationRedirect)); @@ -95,7 +118,15 @@ export function RegisterPage() { // waiting for the config or the probe. `` renders nothing. if (user) return ; - if (!signedOut || configRead === null || signUpOffer === 'closed' || signUpOffer === 'pending') { + if (signedOut && serverUnreachable) { + return ( + + + + ); + } + + if (!signedOut || configRead !== 'known' || signUpOffer === 'closed' || signUpOffer === 'pending') { // Render nothing until the offer is known — prevents a flash of the form. return {null}; } From 8bf6b7a8af2bfa2cb42cd1266e8136a2998f11b4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:26:40 +0000 Subject: [PATCH 5/7] test(auth): pin the register pages' unreachable-server state Both register pages: a pending config read offers no form; a failed read shows "Cannot connect to server" with Retry in place of the form; Retry is busy while in flight, then an open answer brings the form and a disableSignUp: true answer bounces to /login. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- ...sterPage.server-unreachable-11806.test.tsx | 176 ++++++++++++++++++ ...ltRegisterServerUnreachable-11806.test.tsx | 176 ++++++++++++++++++ 2 files changed, 352 insertions(+) create mode 100644 apps/console/src/pages/auth/__tests__/RegisterPage.server-unreachable-11806.test.tsx create mode 100644 packages/app-shell/src/console/auth/__tests__/defaultRegisterServerUnreachable-11806.test.tsx diff --git a/apps/console/src/pages/auth/__tests__/RegisterPage.server-unreachable-11806.test.tsx b/apps/console/src/pages/auth/__tests__/RegisterPage.server-unreachable-11806.test.tsx new file mode 100644 index 0000000000..3347a8d5fa --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/RegisterPage.server-unreachable-11806.test.tsx @@ -0,0 +1,176 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11806 — the console's `/register` when the server cannot be + * reached. + * + * The page reads `/api/v1/auth/config` to decide what a visitor is offered. It + * stored a failed read as `{ config: null }`, and `decideSignUpOffer` answers + * `null` as "offer the form" — so with the server down the page drew the full + * registration form, and the visitor found out only on submit. `/login` had + * the same defect; its pins are `LoginPage.server-unreachable-11806.test.tsx` + * beside this file. + * + * The auth client is a stub whose `getConfig` is scripted read by read and, + * like `createAuthClient`'s, shares one in-flight read between callers and + * forgets a failed one. A scripted rejection stands for the real client's + * final answer: `createAuthClient` retries the read itself before it rejects. + * Visible text is read from the en locale pack, not copied here. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient, AuthPublicConfig } from '@object-ui/auth'; +import { RegisterPage } from '../RegisterPage'; + +const en = builtInLocales.en; +const CREATE_ACCOUNT = { name: en.auth.register.submitButton } as const; +const UNREACHABLE = en.console.error.connectionFailed; +const RETRY = { name: en.console.actions.retry } as const; +const RETRYING = { name: en.console.actions.retrying } as const; + +/** What the real client rejects with once its own retries ran out on a 502. */ +const BAD_GATEWAY = () => Promise.reject(new Error('Failed to load auth config (status 502)')); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * A signed-out visitor whose config reads are answered, in order, by `reads`. + * `serverReads()` counts the reads that reached the "server". + */ +function scriptedClient(reads: Array<() => Promise>) { + let inFlight: Promise | null = null; + let served = 0; + const client = { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn(() => { + if (!inFlight) { + const read = (reads[served] ?? (() => new Promise(() => {})))(); + served += 1; + inFlight = read; + read.catch(() => { + if (inFlight === read) inFlight = null; + }); + } + return inFlight; + }), + } as unknown as AuthClient; + return { client, serverReads: () => served }; +} + +/** Mount `/register` and `/login` as `App.tsx` routes them; `/login` is a marker. */ +function renderRegister(client: AuthClient) { + window.history.replaceState({}, '', '/register'); + return render( + + + + + } /> + } /> + + + + , + ); +} + +/** Let a settled promise's state update land before a negative assertion. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +const OPEN: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false }, + features: { audiencePosture: 'open' }, +}; +const SIGN_UP_OFF: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: true }, + features: { audiencePosture: 'open' }, +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('console RegisterPage — the server cannot be reached (objectui#11806)', () => { + it('while the config read is pending, offers no form and does not call the server unreachable', async () => { + const { client } = scriptedClient([() => new Promise(() => {})]); + renderRegister(client); + + await waitFor(() => expect(client.getConfig).toHaveBeenCalled()); + await settle(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('when the config read fails, shows "Cannot connect to server" and Retry in place of the form', async () => { + const { client } = scriptedClient([BAD_GATEWAY]); + renderRegister(client); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText(UNREACHABLE)).toBeInTheDocument(); + expect(within(alert).getByText(en.console.error.checkServer)).toBeInTheDocument(); + expect(screen.getByRole('button', RETRY)).toBeEnabled(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(screen.queryByLabelText(en.auth.register.nameLabel)).toBeNull(); + }); + + it('Retry reads the config again; an open answer brings the form', async () => { + const second = deferred(); + const { client, serverReads } = scriptedClient([BAD_GATEWAY, () => second.promise]); + renderRegister(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + // In flight: still the unreachable state, the button busy — no form yet. + expect(screen.getByRole('button', RETRYING)).toBeDisabled(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(serverReads()).toBe(2); + + second.resolve(OPEN); + + expect(await screen.findByRole('button', CREATE_ACCOUNT)).toBeInTheDocument(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('Retry reads the config again; a disableSignUp: true answer bounces to /login, never showing the form', async () => { + const { client } = scriptedClient([BAD_GATEWAY, () => Promise.resolve(SIGN_UP_OFF)]); + renderRegister(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + expect(await screen.findByTestId('login-route')).toBeInTheDocument(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/auth/__tests__/defaultRegisterServerUnreachable-11806.test.tsx b/packages/app-shell/src/console/auth/__tests__/defaultRegisterServerUnreachable-11806.test.tsx new file mode 100644 index 0000000000..a0a153f027 --- /dev/null +++ b/packages/app-shell/src/console/auth/__tests__/defaultRegisterServerUnreachable-11806.test.tsx @@ -0,0 +1,176 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#11806 — `DefaultRegisterPage` when the server cannot be reached. + * + * The page reads `/api/v1/auth/config` to decide what a visitor is offered. It + * stored a failed read as `{ config: null }`, and `decideSignUpOffer` answers + * `null` as "offer the form" — so with the server down the page drew the full + * registration form, and the visitor found out only on submit. + * `DefaultLoginPage` had the same defect; its pins are + * `defaultLoginServerUnreachable-11806.test.tsx` beside this file. + * + * The auth client is a stub whose `getConfig` is scripted read by read and, + * like `createAuthClient`'s, shares one in-flight read between callers and + * forgets a failed one. A scripted rejection stands for the real client's + * final answer: `createAuthClient` retries the read itself before it rejects. + * Visible text is read from the en locale pack, not copied here — the page + * passes no inline defaults, so the text appearing is the proof that its + * `console.*` keys resolve through its own `t`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen, cleanup, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { AuthProvider } from '@object-ui/auth'; +import type { AuthClient, AuthPublicConfig } from '@object-ui/auth'; +import { RegisterPage } from '../RegisterPage'; + +const en = builtInLocales.en; +const CREATE_ACCOUNT = { name: en.auth.register.submitButton } as const; +const UNREACHABLE = en.console.error.connectionFailed; +const RETRY = { name: en.console.actions.retry } as const; +const RETRYING = { name: en.console.actions.retrying } as const; + +/** What the real client rejects with once its own retries ran out on a 502. */ +const BAD_GATEWAY = () => Promise.reject(new Error('Failed to load auth config (status 502)')); + +interface Deferred { + promise: Promise; + resolve: (value: T) => void; +} +function deferred(): Deferred { + let resolve!: (value: T) => void; + const promise = new Promise((res) => { + resolve = res; + }); + return { promise, resolve }; +} + +/** + * A signed-out visitor whose config reads are answered, in order, by `reads`. + * `serverReads()` counts the reads that reached the "server". + */ +function scriptedClient(reads: Array<() => Promise>) { + let inFlight: Promise | null = null; + let served = 0; + const client = { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn(() => { + if (!inFlight) { + const read = (reads[served] ?? (() => new Promise(() => {})))(); + served += 1; + inFlight = read; + read.catch(() => { + if (inFlight === read) inFlight = null; + }); + } + return inFlight; + }), + } as unknown as AuthClient; + return { client, serverReads: () => served }; +} + +/** Mount the default page as `examples/console-starter` routes it; `/login` is a marker. */ +function renderRegister(client: AuthClient) { + return render( + + + + + } /> + } /> + + + + , + ); +} + +/** Let a settled promise's state update land before a negative assertion. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +const OPEN: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false }, + features: { audiencePosture: 'open' }, +}; +const SIGN_UP_OFF: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: true }, + features: { audiencePosture: 'open' }, +}; + +beforeEach(() => { + window.localStorage.clear(); +}); + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe('DefaultRegisterPage — the server cannot be reached (objectui#11806)', () => { + it('while the config read is pending, offers no form and does not call the server unreachable', async () => { + const { client } = scriptedClient([() => new Promise(() => {})]); + renderRegister(client); + + await waitFor(() => expect(client.getConfig).toHaveBeenCalled()); + await settle(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('when the config read fails, shows "Cannot connect to server" and Retry in place of the form', async () => { + const { client } = scriptedClient([BAD_GATEWAY]); + renderRegister(client); + + const alert = await screen.findByRole('alert'); + expect(within(alert).getByText(UNREACHABLE)).toBeInTheDocument(); + expect(within(alert).getByText(en.console.error.checkServer)).toBeInTheDocument(); + expect(screen.getByRole('button', RETRY)).toBeEnabled(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(screen.queryByLabelText(en.auth.register.nameLabel)).toBeNull(); + }); + + it('Retry reads the config again; an open answer brings the form', async () => { + const second = deferred(); + const { client, serverReads } = scriptedClient([BAD_GATEWAY, () => second.promise]); + renderRegister(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + // In flight: still the unreachable state, the button busy — no form yet. + expect(screen.getByRole('button', RETRYING)).toBeDisabled(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(serverReads()).toBe(2); + + second.resolve(OPEN); + + expect(await screen.findByRole('button', CREATE_ACCOUNT)).toBeInTheDocument(); + expect(screen.queryByText(UNREACHABLE)).toBeNull(); + }); + + it('Retry reads the config again; a disableSignUp: true answer bounces to /login, never showing the form', async () => { + const { client } = scriptedClient([BAD_GATEWAY, () => Promise.resolve(SIGN_UP_OFF)]); + renderRegister(client); + + await userEvent.click(await screen.findByRole('button', RETRY)); + + expect(await screen.findByTestId('login-route')).toBeInTheDocument(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + }); +}); From ce91f6708c01fa87d1dee1ebdad98d39f516345a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 04:31:20 +0000 Subject: [PATCH 6/7] docs(app-shell): sign-up offer waits for the config read; changeset names the register pages The README's sign-up table gains the pending and failed-read rows, and its useSignUpOffer host recipe asks decideSignUpOffer only once the read has answered, returning `unreachable` for a failed read. The decision's own answers are unchanged. The changeset now covers both register pages. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- .changeset/11806-login-server-unreachable.md | 14 ++++--- packages/app-shell/README.md | 43 ++++++++++++++++---- 2 files changed, 43 insertions(+), 14 deletions(-) diff --git a/.changeset/11806-login-server-unreachable.md b/.changeset/11806-login-server-unreachable.md index 1ad46637c8..e4fc644725 100644 --- a/.changeset/11806-login-server-unreachable.md +++ b/.changeset/11806-login-server-unreachable.md @@ -3,14 +3,16 @@ '@object-ui/console': patch --- -The login page says when the server cannot be reached, and offers "Sign up" only once the server has said whether it accepts sign-ups (objectui#11806). This applies to the console's own `/login` and to `DefaultLoginPage`. +The login and register pages say when the server cannot be reached, and offer sign-up only once the server has said whether it accepts sign-ups (objectui#11806). This applies to the console's own `/login` and `/register`, and to `DefaultLoginPage` and `DefaultRegisterPage`. -Both pages read `/api/v1/auth/config` to decide the "Sign up" offer. They held that config as `null` both while the read was pending and after it failed, and `decideSignUpOffer` answers `null` as "offer the link". So with the server down the page drew a normal-looking form and offered "Sign up", and the visitor found out only when they submitted. +All four pages read `/api/v1/auth/config` to decide what a visitor is offered. They treated a failed read like a server that sends no config, and `decideSignUpOffer` answers that as "offer it". The login pages also held the config as `null` while the read was pending. So with the server down, `/login` drew a normal-looking form and offered "Sign up", `/register` drew the full registration form, and the visitor found out only when they submitted. **Behaviour change.** -- While the config read is pending, neither page shows the "Sign up" link. The form's own spinner shows as before. -- When the read fails, the page shows "Cannot connect to server", the hint to check the network or the backend, and a Retry button, in place of the form. The auth client has already retried the read itself by then. The "Sign up" link stays hidden. -- Retry reads the config again. While that read is in flight the button reads "Retrying…" and the form stays hidden. When the server answers, the form returns, and "Sign up" follows that answer exactly as before. +- While the config read is pending, the login pages show their form behind its own spinner, with no "Sign up" link. The register pages show no form, as before. +- When the read fails, every page shows "Cannot connect to server", the hint to check the network or the backend, and a Retry button, in place of the form. The auth client has already retried the read itself by then. Nothing is offered. +- Retry reads the config again. While that read is in flight the button reads "Retrying…" and the form stays hidden. When the server answers, the page shows what that answer decides, exactly as before: the form, the "Sign up" link, the by-invitation notice, or, on `/register`, the bounce to `/login`. - When the server answers the first read, nothing changes. -**Clause-②: no.** No export, prop, type member or accepted value changes. `decideSignUpOffer` and its answers are unchanged: the pages consult it only once the read has answered. The text comes from existing `en` keys: `console.error.connectionFailed`, `console.error.checkServer`, `console.actions.retry` and `console.actions.retrying`. +`packages/app-shell/README.md` documents the two new states in its sign-up table. Its `useSignUpOffer` recipe for hosts now asks `decideSignUpOffer` only once the read has answered, and answers `unreachable` for a failed read. + +**Clause-②: no.** No export of the package entry, prop, type member or accepted value changes. `decideSignUpOffer` and its answers are unchanged: the pages consult it only once the read has answered. The text comes from existing `en` keys: `console.error.connectionFailed`, `console.error.checkServer`, `console.actions.retry` and `console.actions.retrying`. diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index c5c91ce185..1588419953 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -203,17 +203,24 @@ server states that rule in two keys of `GET /api/v1/auth/config`: `features.audiencePosture` (who may self-register). Under the default `invite_only` posture the server keeps `disableSignUp` off so that a pending invitee can still register, and refuses anyone else with -`SELF_REGISTRATION_CLOSED`. The pages read both keys (objectui#11705): +`SELF_REGISTRATION_CLOSED`. The pages read both keys (objectui#11705), and +offer nothing until that read has answered (objectui#11806): | the visitor | `/login` | `/register` | | --- | --- | --- | | signed in | as in the rows below | sent on to `/`, where a successful sign-up lands; no row below applies | +| the config read is still pending | the form, behind its own spinner, with no "Sign up" link | nothing yet | +| the config read failed (the auth client retries it first) | "Cannot connect to server" with Retry, in place of the form; no "Sign up" link | the same | | `disableSignUp: true` | no "Sign up" link | bounces to `/login` | | posture `open` or `email_domain`, or no posture sent | "Sign up" link | the form | | `invite_only`, `?redirect=` is an invitation (`/accept-invitation/ID`) | "Sign up" link, carrying the redirect | the form | | `invite_only`, the deployment has no owner yet | "Sign up" link | the form | | `invite_only`, anyone else | no "Sign up" link | "registration is by invitation", before any form | +Retry reads the config again. While that read is in flight the panel stays up +and its button reads "Retrying…"; once the server answers, the row for that +answer applies. + The decision is one exported function, which the console's own login and register pages call too, so a host that builds its own pages can follow the same rule: @@ -230,19 +237,29 @@ import { type SignUpOffer, } from '@object-ui/app-shell'; -/** 'signed-in' | 'form' | 'by-invitation' | 'closed' | 'pending' */ -export function useSignUpOffer(): SignUpOffer | 'signed-in' { +type ConfigRead = + | { status: 'loading' } + | { status: 'failed' } + | { status: 'known'; config: AuthPublicConfig | null }; + +/** 'signed-in' | 'unreachable' | 'form' | 'by-invitation' | 'closed' | 'pending' */ +export function useSignUpOffer(): SignUpOffer | 'signed-in' | 'unreachable' { const [searchParams] = useSearchParams(); const { user, isLoading, getAuthConfig } = useAuth(); // Whether the first session check has answered. Every sign-up in flight // raises `isLoading` again, so latch the first time it clears. const [sessionChecked, setSessionChecked] = useState(!isLoading); if (!isLoading && !sessionChecked) setSessionChecked(true); - // `null` until `/auth/config` has been read (and after a failed read). - const [authConfig, setAuthConfig] = useState(null); + // Where the `/auth/config` read stands. The auth client retries a failed + // read before it rejects, so `failed` means the server did not answer. + const [read, setRead] = useState({ status: 'loading' }); useEffect(() => { - getAuthConfig().then(setAuthConfig, () => undefined); + getAuthConfig().then( + (config) => setRead({ status: 'known', config: config ?? null }), + () => setRead({ status: 'failed' }), + ); }, [getAuthConfig]); + const authConfig = read.status === 'known' ? read.config : null; const invitationRedirect = isInvitationRedirect(searchParams.get('redirect')); // Probes GET /api/v1/auth/bootstrap-status only for a visitor known to be @@ -251,6 +268,9 @@ export function useSignUpOffer(): SignUpOffer | 'signed-in' { const bootstrap = useBootstrapStatus(signedOut && needsBootstrapProbe(authConfig, invitationRedirect)); if (user) return 'signed-in'; if (!signedOut) return 'pending'; + // Ask the decision only once the read has answered. + if (read.status === 'failed') return 'unreachable'; + if (read.status === 'loading') return 'pending'; return decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); } ``` @@ -258,8 +278,15 @@ export function useSignUpOffer(): SignUpOffer | 'signed-in' { A signed-in visitor is not who the offer is about, so the hook answers `signed-in` before anything else, without the probe: move that visitor on. `DefaultRegisterPage` sends them to `/`, where it sends a visitor after a -successful sign-up (objectui#11714). A `null` config answers `form`, leaving -the server's own gate as the source of truth. An invitation redirect is an +successful sign-up (objectui#11714). The hook asks `decideSignUpOffer` only +once the config read has answered (objectui#11806): while the read is pending +it answers `pending`, and when the read failed it answers `unreachable`, which +the default pages render as "Cannot connect to server" with a Retry. To offer +Retry, keep an attempt counter in the effect's dependencies and bump it, as the +default pages do. `decideSignUpOffer` itself is unchanged: it still answers a +`null` config with `form`, leaving the server's own gate as the source of +truth, which is why a read that has not answered must not reach it. An +invitation redirect is an affordance, not an authorization: the server still refuses a non-invitee's sign-up. From ba3624a46c4e5546d2b2477487e5166636c50cd2 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 8 Oct 2026 05:27:14 +0000 Subject: [PATCH 7/7] test(e2e): the smoke boot-state set names the unreachable sign-in panel With no backend the console's /login now ends on the "Cannot connect to server" panel (objectui#11806) instead of the password form. The closed set of recognised boot states matched it only while the auth-config spinner was still up; it now names the panel by its data-testid, so the assertion stays terminal-stable. Claude-Session: https://claude.ai/code/session_01MgfduSkFrfM3eorB3UGfAU Co-authored-by: Claude --- e2e/smoke.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts index 53d6f976c9..1b7b37e898 100644 --- a/e2e/smoke.spec.ts +++ b/e2e/smoke.spec.ts @@ -98,7 +98,10 @@ test.describe('Console App – Smoke', () => { * The set below stays CLOSED on purpose: a blank page, a crashed render or an * error boundary matches none of these three and still fails the test. What * changes is that every state the app is legitimately allowed to be in is now - * terminal-stable, so passing no longer depends on winning a race. + * terminal-stable, including the sign-in screen's final state with no backend: + * the "Cannot connect to server" panel that replaces the form once the + * auth-config read fails (objectui#11806), so passing no longer depends on + * winning a race. */ test('should settle into a recognised boot state (app shell, loading screen or sign-in)', async ({ page, @@ -110,9 +113,12 @@ test.describe('Console App – Smoke', () => { // The signed-out sign-in screen, matched structurally rather than by its // copy so a locale change cannot silently stop matching it: the auth-config // spinner it opens with, then the identifier field of whichever sign-in - // mode the server reports (`LoginForm.tsx`). + // mode the server reports (`LoginForm.tsx`), or the unreachable panel that + // replaces the form when that read fails (objectui#11806). const signInScreen = page - .locator('[data-testid="login-config-loading"], #login-email, #login-phone') + .locator( + '[data-testid="login-config-loading"], #login-email, #login-phone, [data-testid="auth-server-unreachable"]', + ) .first(); await expect(