Skip to content
18 changes: 18 additions & 0 deletions .changeset/11806-login-server-unreachable.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/app-shell': patch
'@object-ui/console': patch
---

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`.

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, 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.

`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`.
104 changes: 91 additions & 13 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<AuthPublicConfig | null>(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.
Expand Down Expand Up @@ -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(
Expand All @@ -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(() => {
Expand Down Expand Up @@ -319,16 +351,62 @@ export function LoginPage() {
</div>
) : null}
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<LoginFormCard
registerUrl={signUpOffer === 'form' ? registerUrl : undefined}
redirect={redirect}
/>
{serverUnreachable ? (
<ServerUnreachable
retrying={configRead === 'loading'}
onRetry={retryConfigRead}
/>
) : (
<LoginFormCard
registerUrl={
configRead === 'known' && signUpOffer === 'form' ? registerUrl : undefined
}
redirect={redirect}
/>
)}
</Card>
</div>
</AuthLayout>
);
}

/**
* 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 `<LoginForm>` would, at the same width and in
* the same visual language.
*/
export function ServerUnreachable({ retrying, onRetry }: { retrying: boolean; onRetry: () => void }) {
const { t } = useObjectTranslation();
return (
<div
data-testid="auth-server-unreachable"
className="mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]"
>
<div role="alert">
<AuthFormHeader
icon={<AuthAlertIcon className="h-6 w-6 text-destructive" />}
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.',
})}
/>
</div>
<button
type="button"
onClick={onRetry}
disabled={retrying}
className={AUTH_PRIMARY_BUTTON_CLASS}
>
{retrying ? <AuthSpinner /> : null}
{retrying
? t('console.actions.retrying', { defaultValue: 'Retrying…' })
: t('console.actions.retry', { defaultValue: 'Retry' })}
</button>
</div>
);
}

function LoginFormCard({
registerUrl,
redirect,
Expand Down
61 changes: 48 additions & 13 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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('//');
Expand Down Expand Up @@ -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<AuthPublicConfig | null>(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);
Expand All @@ -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),
Expand Down Expand Up @@ -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 (
<AuthLayout formWidth="md">
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<ServerUnreachable retrying={configRead === 'loading'} onRetry={retryConfigRead} />
</Card>
</AuthLayout>
);
}

if (
configRead === null ||
awaitingSession ||
configRead !== 'known' ||
signUpOffer === 'closed' ||
signUpOffer === 'pending' ||
(isLoading && !hasBootstrapped) ||
user
signUpOffer === 'pending'
) {
return (
<AuthLayout>
Expand Down
Loading
Loading