From f133f3c5335cf622ddce979bbe5cf0cfdaaa55a3 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 09:43:50 +0000 Subject: [PATCH 1/2] fix(app-shell): the exported default auth pages follow the audience posture (objectui#11705) DefaultLoginPage and DefaultRegisterPage read emailPassword.disableSignUp alone, so under the default invite_only posture they offered a generic sign-up the server refuses with SELF_REGISTRATION_CLOSED. They now call decideSignUpOffer, the decision the console's own pages have used since objectui#11691. The decision and the bootstrap-status probe it reads move out of the private apps/console into @object-ui/app-shell, unchanged, and the console's pages and /setup entry import them from there; the console's copy is deleted, not shimmed. objectui#11691's 16 pins stay green: the 5 decision cases move beside the decision, byte-identical, and the 11 rendered console-page pins stay where they were. 11 new rendered pins cover the default pages. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../11705-default-auth-pages-posture.md | 11 + apps/console/src/components/setupEntry.ts | 53 +--- apps/console/src/pages/auth/LoginPage.tsx | 16 +- apps/console/src/pages/auth/RegisterPage.tsx | 17 +- .../signUpFollowsPosture-11691.test.tsx | 84 +----- .../app-shell/src/console/auth/LoginPage.tsx | 48 +++- .../src/console/auth/RegisterPage.tsx | 103 +++++-- .../defaultPagesFollowPosture-11705.test.tsx | 270 ++++++++++++++++++ .../auth/__tests__/signUpOffer-11691.test.ts | 122 ++++++++ .../src/console/auth/bootstrapStatus.ts | 66 +++++ .../src/console}/auth/signUpOffer.ts | 59 ++-- packages/app-shell/src/index.ts | 11 + packages/auth/src/types.ts | 5 +- 13 files changed, 681 insertions(+), 184 deletions(-) create mode 100644 .changeset/11705-default-auth-pages-posture.md create mode 100644 packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx create mode 100644 packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts create mode 100644 packages/app-shell/src/console/auth/bootstrapStatus.ts rename {apps/console/src/pages => packages/app-shell/src/console}/auth/signUpOffer.ts (70%) diff --git a/.changeset/11705-default-auth-pages-posture.md b/.changeset/11705-default-auth-pages-posture.md new file mode 100644 index 0000000000..b397a5f552 --- /dev/null +++ b/.changeset/11705-default-auth-pages-posture.md @@ -0,0 +1,11 @@ +--- +'@object-ui/app-shell': minor +--- + +`DefaultLoginPage` and `DefaultRegisterPage` offer a generic sign-up only where the server would accept one (objectui#11705). Under the default `invite_only` audience posture the server keeps `emailPassword.disableSignUp` off, so that a pending invitee can still register, and refuses anyone else with `403 SELF_REGISTRATION_CLOSED`. These two pages read `disableSignUp` alone, so every host that mounts them (`examples/console-starter` does) offered "Sign up" and the full form to every visitor, and the server refused the finished form. They now read `features.audiencePosture` too, through the same decision the console's own pages have used since objectui#11691, moved into this package so both call one rule. + +**Behaviour change.** Under `invite_only` with an owner, `DefaultLoginPage` shows no "Sign up" link, and `DefaultRegisterPage` says that registration is by invitation (the `auth.register.errors.selfRegistrationClosed` text) instead of rendering the form. A visitor whose `?redirect=` is an invitation-acceptance page (`/accept-invitation/ID`, where `DefaultAcceptInvitationPage` bounces a signed-out visitor) still gets the link and the form, and so does every visitor while the deployment has no owner yet, which `GET /api/v1/auth/bootstrap-status` answers. Only that case makes the extra request. `open` and `email_domain` are unchanged, as is a server that sends no `audiencePosture`. `disableSignUp: true` still hides everything, invitees included. Both pages now carry `?redirect=` between `/login` and `/register`, which is how the register page knows the visitor came from an invitation. After a successful sign-in or sign-up they still navigate to `/`. + +**Clause-②: yes (widening)** — the package entry gains the decision and the probe it reads: `decideSignUpOffer`, `needsBootstrapProbe`, `isInvitationRedirect` and `useBootstrapStatus`, and the types `SignUpOffer`, `SignUpOfferContext` and `BootstrapStatus`. They moved here from the private `apps/console` unchanged, and the console's pages and `/setup` entry now import them from this package. No existing export is removed or changes type. + +Not published: `audienceAdmitsUninvitedSignUp`, the posture predicate the decision restates from the spec's `audiencePermitsSelfRegistration`. It ships inside `dist/` but is not exported from the package entry. Take the spec's own predicate instead. diff --git a/apps/console/src/components/setupEntry.ts b/apps/console/src/components/setupEntry.ts index 52ac7aca32..09e0d6d99e 100644 --- a/apps/console/src/components/setupEntry.ts +++ b/apps/console/src/components/setupEntry.ts @@ -45,19 +45,12 @@ * {@link decideSetupEntry}. */ -import { useEffect, useState } from 'react'; import { useAuth } from '@object-ui/auth'; - -const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; - -/** - * Deployment bootstrap state. `fresh` is also what a FAILED probe reports — - * same fall-open as `SetupPage`'s own `catch`: showing the wizard on an - * already-bootstrapped deployment is recoverable (the wizard re-probes and - * bounces to login), whereas hiding it on a genuinely fresh one is a dead end, - * because no account exists to log in with. - */ -export type BootstrapStatus = 'unknown' | 'fresh' | 'bootstrapped'; +// The `hasOwner` probe and its state type live in `@object-ui/app-shell` +// (objectui#11705): the sign-up decision there reads the same probe, for the +// console's login and register pages and the package's exported default ones. +// `BootstrapStatus` documents why a FAILED probe reads `fresh`. +import { useBootstrapStatus, type BootstrapStatus } from '@object-ui/app-shell'; /** Which surface `/setup` resolves to. */ export type SetupEntryMode = @@ -101,36 +94,14 @@ export function decideSetupEntry( } /** - * Probe `hasOwner` once. `enabled` is load-bearing twice over: an - * already-authenticated visitor never pays for a request whose answer - * {@link decideSetupEntry} would ignore, AND it is what confines a `fresh` - * verdict to sessions-less visits, which is what makes that verdict durable. - * The answer is kept once received — losing `enabled` (as `signUp()` does) - * cancels the in-flight probe, it does not reset the state. + * The verdict for this mount. `useBootstrapStatus` probes `hasOwner` once; + * its `enabled` is load-bearing twice over here: an already-authenticated + * visitor never pays for a request whose answer {@link decideSetupEntry} would + * ignore, AND it is what confines a `fresh` verdict to session-less visits, + * which is what makes that verdict durable. The answer is kept once received — + * losing `enabled` (as `signUp()` does) cancels the in-flight probe, it does + * not reset the state. */ -export function useBootstrapStatus(enabled: boolean): BootstrapStatus { - const [status, setStatus] = useState('unknown'); - useEffect(() => { - if (!enabled) return; - let cancelled = false; - void (async () => { - try { - const res = await fetch(`${AUTH_BASE}/bootstrap-status`, { credentials: 'include' }); - const data: { hasOwner?: boolean } = res.ok ? await res.json().catch(() => ({})) : {}; - if (!cancelled) setStatus(data.hasOwner === true ? 'bootstrapped' : 'fresh'); - } catch { - // Fall open to the wizard — see BootstrapStatus. - if (!cancelled) setStatus('fresh'); - } - })(); - return () => { - cancelled = true; - }; - }, [enabled]); - return status; -} - -/** The verdict for this mount. */ export function useSetupEntryMode(): SetupEntryMode { const { isAuthenticated, isLoading } = useAuth(); const status = useBootstrapStatus(!isLoading && !isAuthenticated); diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index a446cf0232..1f3f12c50a 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -16,7 +16,9 @@ * from this visitor: never under `emailPassword.disableSignUp === true`, * and under an audience posture closed to strangers (`invite_only`) only * for an invitation redirect or a deployment with no owner yet — see - * `./signUpOffer` (objectui#11691). + * `decideSignUpOffer` in `@object-ui/app-shell`, the one decision this page + * shares with the package's exported `DefaultLoginPage` (objectui#11691, + * objectui#11705). */ import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; @@ -25,11 +27,15 @@ import { useAuth, LoginForm, AuthErrorBanner } from '@object-ui/auth'; import type { AuthPublicConfig } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { Card } from '@object-ui/components'; -import { signInRefusalMessages } from '@object-ui/app-shell'; +import { + signInRefusalMessages, + decideSignUpOffer, + isInvitationRedirect, + needsBootstrapProbe, + useBootstrapStatus, +} from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; -import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer'; -import { useBootstrapStatus } from '../../components/setupEntry'; // Was module-private here; lifted to a shared module so `SetupPage` (whose // first-run exits went without it) and `RegisterPage` (which had copied it) // share ONE implementation — objectui#4181. Behaviour here is unchanged. @@ -111,7 +117,7 @@ export function LoginPage() { // objectui#11691 — whether this visitor is offered "Sign up". The bootstrap // probe runs only when the posture is closed to strangers and the visitor - // did not come from an invitation; see `./signUpOffer`. + // did not come from an invitation; see app-shell's `decideSignUpOffer`. const invitationRedirect = isInvitationRedirect(redirect); const bootstrap = useBootstrapStatus( hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect), diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index ea87d9bad4..4fb33b3030 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -10,7 +10,9 @@ * - Under an audience posture closed to strangers (`invite_only`), shows * the form only to an invitation redirect or on a deployment with no * owner yet, and otherwise explains that registration is by invitation - * BEFORE the form — see `./signUpOffer` (objectui#11691). + * 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). * - 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). @@ -24,11 +26,15 @@ import { useAuth, RegisterForm, AuthFormHeader, AUTH_LINK_CLASS } from '@object- import type { AuthPublicConfig } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { Card } from '@object-ui/components'; -import { signUpRefusalMessages } from '@object-ui/app-shell'; +import { + signUpRefusalMessages, + decideSignUpOffer, + isInvitationRedirect, + needsBootstrapProbe, + useBootstrapStatus, +} from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; -import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer'; -import { useBootstrapStatus } from '../../components/setupEntry'; // 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'; @@ -94,7 +100,8 @@ export function RegisterPage() { // 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 `./signUpOffer`. + // 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( diff --git a/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx b/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx index c77b2b6583..2287ae68b8 100644 --- a/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx +++ b/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx @@ -25,10 +25,13 @@ * "reaches a working registration" is read off the request the server would * receive, not off a mocked hook. * - * The posture predicate is restated in `../signUpOffer` (the pages sit in the - * console's eager closure); the parity case below imports the spec's own - * predicate and vocabulary, so a posture added or reclassified upstream turns - * this file red instead of silently mis-offering the form. + * The decision these pages call, `decideSignUpOffer`, lives in + * `@object-ui/app-shell` since objectui#11705, which moved it there so the + * package's exported default pages call the same rule. Its unit cases — + * including the parity case against the spec's own posture predicate and + * vocabulary — moved with it, unchanged, to + * `packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts`. + * The console pages' rendered pins stay here, unchanged. */ import '@testing-library/jest-dom/vitest'; @@ -40,16 +43,9 @@ import { I18nProvider } from '@object-ui/i18n'; import { builtInLocales } from '@object-ui/i18n/locales'; import { AuthProvider, createAuthClient } from '@object-ui/auth'; import type { AuthPublicConfig } from '@object-ui/auth'; -import { AUDIENCE_POSTURES, audiencePermitsSelfRegistration } from '@objectstack/spec/system'; import type { AudiencePosture } from '@objectstack/spec/system'; import { LoginPage } from '../LoginPage'; import { RegisterPage } from '../RegisterPage'; -import { - audienceAdmitsUninvitedSignUp, - decideSignUpOffer, - isInvitationRedirect, - needsBootstrapProbe, -} from '../signUpOffer'; const AUTH_URL = 'http://localhost/api/v1/auth'; const SIGN_UP_LINK = { name: 'Sign up' } as const; @@ -114,8 +110,8 @@ function Recorder() { /** * Mount `/login` and `/register` as `App.tsx` routes them. The bootstrap - * probe uses the global `fetch` (see `components/setupEntry`), so the same - * stub answers it. + * probe uses the global `fetch` (`useBootstrapStatus`, `@object-ui/app-shell`), + * so the same stub answers it. */ function renderAt(path: string, config: AuthPublicConfig, { hasOwner = true } = {}) { const fetchFn = stubServer(config, hasOwner); @@ -157,68 +153,6 @@ afterEach(() => { window.history.replaceState({}, '', '/'); }); -describe('signUpOffer — the decision both pages share (objectui#11691)', () => { - it('agrees with the spec on every audience posture the spec declares', () => { - expect(AUDIENCE_POSTURES.length).toBeGreaterThan(0); - for (const posture of AUDIENCE_POSTURES) { - expect(audienceAdmitsUninvitedSignUp(posture), posture).toBe( - audiencePermitsSelfRegistration(posture), - ); - } - }); - - it('reads a posture outside the spec vocabulary as not admitting', () => { - for (const value of ['invite-only', 'OPEN', 'emailDomain', '', null, undefined, 1]) { - expect(audienceAdmitsUninvitedSignUp(value), String(value)).toBe(false); - } - }); - - it('recognises the invitation-acceptance route as the redirect target', () => { - expect(isInvitationRedirect(INVITATION)).toBe(true); - expect(isInvitationRedirect(`${INVITATION}?from=mail`)).toBe(true); - for (const value of ['/accept-invitation/', '/accept-invitationx/inv_1', '/home', '//accept-invitation/inv_1', '', null]) { - expect(isInvitationRedirect(value), String(value)).toBe(false); - } - }); - - it('decides the offer from disableSignUp, the posture, the invitation and the owner state', () => { - const none = { invitationRedirect: false, bootstrap: 'bootstrapped' } as const; - const invited = { invitationRedirect: true, bootstrap: 'bootstrapped' } as const; - const fresh = { invitationRedirect: false, bootstrap: 'fresh' } as const; - const probing = { invitationRedirect: false, bootstrap: 'unknown' } as const; - const closed = configFor('open', { enabled: true, disableSignUp: true }); - - // disableSignUp: true hides everything — invitees and a fresh deployment included. - expect(decideSignUpOffer(closed, none)).toBe('closed'); - expect(decideSignUpOffer(closed, invited)).toBe('closed'); - expect(decideSignUpOffer(configFor('invite_only', { enabled: true, disableSignUp: true }), fresh)).toBe('closed'); - - // Nothing read yet, or an older server that sends no posture: as before. - expect(decideSignUpOffer(null, none)).toBe('form'); - expect(decideSignUpOffer(configFor(undefined), none)).toBe('form'); - - expect(decideSignUpOffer(configFor('open'), none)).toBe('form'); - expect(decideSignUpOffer(configFor('email_domain'), none)).toBe('form'); - - expect(decideSignUpOffer(configFor('invite_only'), invited)).toBe('form'); - expect(decideSignUpOffer(configFor('invite_only'), fresh)).toBe('form'); - expect(decideSignUpOffer(configFor('invite_only'), probing)).toBe('pending'); - expect(decideSignUpOffer(configFor('invite_only'), none)).toBe('by-invitation'); - expect(decideSignUpOffer(configFor('a_future_posture'), none)).toBe('by-invitation'); - }); - - it('asks for the bootstrap probe only when the posture is closed and nothing else admits', () => { - expect(needsBootstrapProbe(configFor('invite_only'), false)).toBe(true); - expect(needsBootstrapProbe(configFor('a_future_posture'), false)).toBe(true); - expect(needsBootstrapProbe(configFor('invite_only'), true)).toBe(false); - expect(needsBootstrapProbe(configFor('open'), false)).toBe(false); - expect(needsBootstrapProbe(configFor('email_domain'), false)).toBe(false); - expect(needsBootstrapProbe(configFor(undefined), false)).toBe(false); - expect(needsBootstrapProbe(configFor('invite_only', { enabled: true, disableSignUp: true }), false)).toBe(false); - expect(needsBootstrapProbe(null, false)).toBe(false); - }); -}); - describe('LoginPage — the "Sign up" link follows the audience posture (objectui#11691)', () => { it('under invite_only on a deployment with an owner, offers no generic "Sign up"', async () => { renderAt('/login', configFor('invite_only')); diff --git a/packages/app-shell/src/console/auth/LoginPage.tsx b/packages/app-shell/src/console/auth/LoginPage.tsx index 5e9e381ab8..54a750ff38 100644 --- a/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/packages/app-shell/src/console/auth/LoginPage.tsx @@ -1,13 +1,22 @@ /** - * Login Page for ObjectStack Console + * Login Page for ObjectStack Console — exported as `DefaultLoginPage`. + * + * Offers the "Sign up" link only when the server would accept a sign-up from + * this visitor: never under `emailPassword.disableSignUp === true`, and under + * 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). */ import { useEffect, useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import { LoginForm, useAuth, type AuthLinkComponentProps } from '@object-ui/auth'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { LoginForm, useAuth, type AuthLinkComponentProps, type AuthPublicConfig } from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { AuthPageLayout } from './AuthPageLayout.js'; import { signInRefusalMessages } from './signInRefusalMessages.js'; +import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; +import { useBootstrapStatus } from './bootstrapStatus.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -15,28 +24,41 @@ const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( export function LoginPage() { const navigate = useNavigate(); + const [params] = useSearchParams(); + const redirect = params.get('redirect'); const { t } = useObjectTranslation(); - const { getAuthConfig } = useAuth(); + const { user, getAuthConfig } = useAuth(); - // Hide the "Sign up" link when the deployment has disabled - // self-service registration (env `OS_DISABLE_SIGNUP=true` or - // `emailAndPassword.disableSignUp` in objectstack.config.ts). We start - // undefined so we don't flicker the link on first paint, and pass - // `undefined` (LoginForm hides the link) once we know signup is off. - const [signUpDisabled, setSignUpDisabled] = useState(undefined); + // 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. + const [authConfig, setAuthConfig] = useState(null); useEffect(() => { let cancelled = false; getAuthConfig() - .then(cfg => { if (!cancelled) setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true); }) - .catch(() => { if (!cancelled) setSignUpDisabled(false); }); + .then(cfg => { if (!cancelled) setAuthConfig(cfg ?? null); }) + .catch(() => { /* leave `null` — the server-side gate is the source of truth */ }); return () => { cancelled = true; }; }, [getAuthConfig]); + // objectui#11705 — whether this visitor is offered "Sign up". The bootstrap + // probe runs only when the posture is closed to strangers and the visitor + // did not come from an invitation; see `./signUpOffer`. + const invitationRedirect = isInvitationRedirect(redirect); + const bootstrap = useBootstrapStatus(!user && needsBootstrapProbe(authConfig, invitationRedirect)); + const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); + + // Carry `?redirect=` into the sign-up link: it is how `/register` knows the + // visitor came from an invitation. + const registerUrl = redirect + ? `/register?redirect=${encodeURIComponent(redirect)}` + : '/register'; + return ( navigate('/')} - registerUrl={signUpDisabled ? undefined : '/register'} + registerUrl={signUpOffer === 'form' ? registerUrl : undefined} forgotPasswordUrl="/forgot-password" title={t('auth.login.title')} description={t('auth.login.description')} diff --git a/packages/app-shell/src/console/auth/RegisterPage.tsx b/packages/app-shell/src/console/auth/RegisterPage.tsx index a03ba581ab..c13fa1b082 100644 --- a/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -1,13 +1,34 @@ /** - * Register Page for ObjectStack Console + * Register Page for ObjectStack Console — exported as `DefaultRegisterPage`. + * + * What this visitor is offered is `decideSignUpOffer` (`./signUpOffer`), the + * same decision the console's own register page calls (objectui#11691, + * objectui#11705): + * + * - `emailPassword.disableSignUp === true` bounces to `/login` + * (defense-in-depth; the server-side gate is the source of truth); + * - under an audience posture closed to strangers (`invite_only`, the + * default) the form is shown only to an invitation redirect or on a + * deployment with no owner yet; anyone else is told that registration is + * by invitation BEFORE the form, instead of having the finished form + * refused with `SELF_REGISTRATION_CLOSED`. */ import { useEffect, useState } from 'react'; -import { useNavigate, Link } from 'react-router-dom'; -import { RegisterForm, useAuth, type AuthLinkComponentProps } from '@object-ui/auth'; +import { useNavigate, useSearchParams, Link } from 'react-router-dom'; +import { + RegisterForm, + AuthFormHeader, + AUTH_LINK_CLASS, + useAuth, + type AuthLinkComponentProps, + type AuthPublicConfig, +} from '@object-ui/auth'; import { useObjectTranslation } from '@object-ui/i18n'; import { AuthPageLayout } from './AuthPageLayout.js'; import { signUpRefusalMessages } from './signUpRefusalMessages.js'; +import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; +import { useBootstrapStatus } from './bootstrapStatus.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -15,15 +36,16 @@ const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( export function RegisterPage() { const navigate = useNavigate(); + const [params] = useSearchParams(); + const redirect = params.get('redirect'); const { t } = useObjectTranslation(); - const { getAuthConfig, sendVerificationEmail } = useAuth(); + const { user, getAuthConfig, sendVerificationEmail } = useAuth(); - // Defense-in-depth: even if a user lands on /register directly when - // signup is disabled, bounce them to /login. The server-side - // `disableSignUp` (set by env `OS_DISABLE_SIGNUP=true` or the - // `emailAndPassword.disableSignUp` config option) will still 403 any - // submission, but redirecting here avoids a confusing form. - const [allowed, setAllowed] = useState(undefined); + // `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); const [pendingEmail, setPendingEmail] = useState(null); const [resendState, setResendState] = useState<'idle' | 'sending' | 'sent' | 'error'>('idle'); const [resendError, setResendError] = useState(null); @@ -31,23 +53,58 @@ export function RegisterPage() { useEffect(() => { let cancelled = false; getAuthConfig() - .then(cfg => { - if (cancelled) return; - if (cfg?.emailPassword?.disableSignUp === true) { - navigate('/login', { replace: true }); - } else { - setAllowed(true); - } - }) - .catch(() => { if (!cancelled) setAllowed(true); }); + .then(cfg => { if (!cancelled) setConfigRead({ config: cfg ?? null }); }) + .catch(() => { if (!cancelled) setConfigRead({ config: null }); }); return () => { cancelled = true; }; - }, [getAuthConfig, navigate]); + }, [getAuthConfig]); - if (allowed !== true) { - // Render nothing until we know the flag — prevents a flash of the form. + // 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`. + const authConfig = configRead ? configRead.config : null; + const invitationRedirect = isInvitationRedirect(redirect); + const bootstrap = useBootstrapStatus(!user && needsBootstrapProbe(authConfig, invitationRedirect)); + const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); + + // Sign-up switched off — bounce to /login, keeping `?redirect=`. + useEffect(() => { + if (signUpOffer !== 'closed') return; + const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; + navigate(`/login${search}`, { replace: true }); + }, [signUpOffer, navigate, redirect]); + + if (configRead === null || signUpOffer === 'closed' || signUpOffer === 'pending') { + // Render nothing until the offer is known — prevents a flash of the form. return {null}; } + const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login'; + + // Registration here is by invitation only. Say so BEFORE the form; the + // sentence is the `SELF_REGISTRATION_CLOSED` refusal's own copy, the same + // key the console's register page shows. + if (signUpOffer === 'by-invitation') { + return ( + +
+ +

+ {t('auth.register.hasAccountText')}{' '} + + {t('auth.register.signInText')} + +

+
+
+ ); + } + if (pendingEmail) { const handleResend = async () => { setResendState('sending'); @@ -114,7 +171,7 @@ export function RegisterPage() { navigate('/')} onVerificationRequired={(email) => setPendingEmail(email)} - loginUrl="/login" + loginUrl={loginUrl} title={t('auth.register.title')} description={t('auth.register.description')} linkComponent={RouterLink} diff --git a/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx b/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx new file mode 100644 index 0000000000..9a758689f9 --- /dev/null +++ b/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx @@ -0,0 +1,270 @@ +/** + * 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#11705 — `DefaultLoginPage` and `DefaultRegisterPage`, the auth + * pages this package publishes for hosts (`examples/console-starter` mounts + * them at `/login` and `/register`), offer a generic sign-up only where the + * server would accept one. + * + * `/api/v1/auth/config` states the sign-up rule as two keys: + * `emailPassword.disableSignUp` (the hard off switch) and + * `features.audiencePosture` (who may self-register). Under the default + * `invite_only` posture the server keeps `disableSignUp: false` so a pending + * invitee can still register, and refuses anyone else with + * `403 SELF_REGISTRATION_CLOSED`. These pages read `disableSignUp` alone, so + * they offered "Sign up" — and the full form — to every visitor under the + * default posture. objectui#11691 fixed the console's own pages; this card + * moved that decision (`../signUpOffer`) into this package and the default + * pages now call it. Its unit cases are `signUpOffer-11691.test.ts` beside + * this file; the console pages' rendered pins stay in + * `apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx`. + * + * Rendered as shipped: the exported pages, `@object-ui/auth`'s real forms, a + * real `AuthProvider` over a real `createAuthClient`, and a real + * `I18nProvider`. Only `fetch` is a stub, answering `/config` the way the + * server wraps it (`{ success, data }`), `/bootstrap-status` the way the + * first-run probe reads it, and `/sign-up/email` by recording the body — so + * "reaches a working registration" is read off the request the server would + * receive, not off a mocked hook. 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 } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { AuthProvider, createAuthClient } from '@object-ui/auth'; +import type { AuthPublicConfig } from '@object-ui/auth'; +import type { AudiencePosture } from '@objectstack/spec/system'; +import { LoginPage } from '../LoginPage'; +import { RegisterPage } from '../RegisterPage'; + +const AUTH_URL = 'http://localhost/api/v1/auth'; +const en = builtInLocales.en.auth; +const SIGN_UP_LINK = { name: en.login.signUpText } as const; +const CREATE_ACCOUNT = { name: en.register.submitButton } as const; +const INVITATION = '/accept-invitation/inv_1'; +const INVITE_QUERY = `?redirect=${encodeURIComponent(INVITATION)}`; + +type EmailPassword = NonNullable; +const OPEN_SIGN_UP: EmailPassword = { enabled: true, disableSignUp: false, requireEmailVerification: false }; +const SIGN_UP_OFF: EmailPassword = { enabled: true, disableSignUp: true }; + +/** + * The config as the server sends it. `posture` is a plain string because the + * server's wire value is what is being modelled, so the one cast below is the + * wire, not a shortcut. + */ +function configFor(posture: string | undefined, emailPassword: EmailPassword = OPEN_SIGN_UP): AuthPublicConfig { + return { + emailPassword, + features: posture === undefined ? {} : { audiencePosture: posture as AudiencePosture }, + }; +} + +interface Wire { + bootstrapProbes: number; + signUps: Array>; +} +let wire: Wire; + +/** A signed-out visitor on a server with the given config and owner state. */ +function stubServer(config: AuthPublicConfig, hasOwner: boolean): typeof fetch { + wire = { bootstrapProbes: 0, signUps: [] }; + const json = (body: unknown) => + new Response(JSON.stringify(body), { status: 200, headers: { 'Content-Type': 'application/json' } }); + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.endsWith('/bootstrap-status')) { + wire.bootstrapProbes += 1; + return json({ hasOwner }); + } + if (url.includes('/sign-up/email')) { + wire.signUps.push(JSON.parse(String(init?.body))); + return json({ user: { id: 'u_new', name: 'Ada', email: 'ada@example.com' }, token: null }); + } + if (url.endsWith('/config')) return json({ success: true, data: config }); + return json(null); + }) as typeof fetch; +} + +const seen: string[] = []; +function Recorder() { + const location = useLocation(); + seen.push(location.pathname + location.search); + return null; +} + +/** + * Mount the default pages as `examples/console-starter/src/App.tsx` routes + * them. The bootstrap probe uses the global `fetch` (`../bootstrapStatus`), + * so the same stub answers it. + */ +function renderAt(path: string, config: AuthPublicConfig, { hasOwner = true } = {}) { + const fetchFn = stubServer(config, hasOwner); + vi.stubGlobal('fetch', fetchFn); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + return render( + + + + + + } /> + } /> + + + + , + ); +} + +/** + * Wait until the config read has been applied to the login page. `LoginForm` + * shows its email field only once ITS read of the same cached `/config` + * promise settles, which is after the page's own `.then` on that promise has + * stored the config — so the field's presence means the page has decided. + */ +async function loginConfigApplied() { + await screen.findByLabelText(en.login.emailLabel); +} + +/** Let an answered probe's state update land before a negative assertion. */ +async function settle() { + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +beforeEach(() => { + seen.length = 0; + window.localStorage.clear(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('DefaultLoginPage — the "Sign up" link follows the audience posture (objectui#11705)', () => { + it('under invite_only on a deployment with an owner, offers no generic "Sign up"', async () => { + renderAt('/login', configFor('invite_only')); + await loginConfigApplied(); + await waitFor(() => expect(wire.bootstrapProbes).toBe(1)); + await settle(); + + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); + + it('under invite_only, still offers "Sign up" to an invitation redirect, carrying the redirect', async () => { + renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); + await loginConfigApplied(); + + expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe(`/register${INVITE_QUERY}`); + expect(wire.bootstrapProbes).toBe(0); + }); + + it('under invite_only on a deployment with no owner yet, keeps "Sign up" for the first owner', async () => { + renderAt('/login', configFor('invite_only'), { hasOwner: false }); + await loginConfigApplied(); + await waitFor(() => expect(wire.bootstrapProbes).toBe(1)); + + expect((await screen.findByRole('link', SIGN_UP_LINK)).getAttribute('href')).toBe('/register'); + }); + + it('under open and email_domain, is unchanged and makes no bootstrap probe', async () => { + for (const posture of ['open', 'email_domain']) { + renderAt('/login', configFor(posture)); + await loginConfigApplied(); + + expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe('/register'); + expect(wire.bootstrapProbes).toBe(0); + cleanup(); + } + }); + + it('for a server that sends no audiencePosture, lets disableSignUp alone decide', async () => { + renderAt('/login', configFor(undefined)); + await loginConfigApplied(); + + expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe('/register'); + expect(wire.bootstrapProbes).toBe(0); + }); + + it('with disableSignUp: true, hides "Sign up" even from an invitation redirect', async () => { + renderAt(`/login${INVITE_QUERY}`, configFor('invite_only', SIGN_UP_OFF)); + await loginConfigApplied(); + + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + expect(wire.bootstrapProbes).toBe(0); + }); +}); + +describe('DefaultRegisterPage — explains invitation-only registration before the form (objectui#11705)', () => { + it('under invite_only without an invitation, explains instead of rendering the form', async () => { + renderAt('/register', configFor('invite_only')); + + const notice = await screen.findByTestId('register-by-invitation'); + expect(notice).toHaveTextContent(en.register.errors.selfRegistrationClosed); + expect(screen.getByRole('link', { name: en.register.signInText }).getAttribute('href')).toBe('/login'); + expect(screen.queryByLabelText(en.register.emailLabel)).toBeNull(); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(wire.bootstrapProbes).toBe(1); + expect(wire.signUps).toHaveLength(0); + }); + + it('an invitation redirect reaches a working registration under invite_only, from /login on', async () => { + renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); + await loginConfigApplied(); + await userEvent.click(screen.getByRole('link', SIGN_UP_LINK)); + + await userEvent.type(await screen.findByLabelText(en.register.nameLabel), 'Ada'); + await userEvent.type(screen.getByLabelText(en.register.emailLabel), 'ada@example.com'); + await userEvent.type(screen.getByLabelText(en.register.passwordLabel), 'hunter2hunter2'); + await userEvent.type(screen.getByLabelText(en.register.confirmPasswordLabel), 'hunter2hunter2'); + expect(screen.getByRole('link', { name: en.register.signInText }).getAttribute('href')).toBe( + `/login${INVITE_QUERY}`, + ); + await userEvent.click(screen.getByRole('button', CREATE_ACCOUNT)); + + await waitFor(() => expect(wire.signUps).toHaveLength(1)); + expect(wire.signUps[0]).toMatchObject({ name: 'Ada', email: 'ada@example.com' }); + expect(seen).toContain(`/register${INVITE_QUERY}`); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + expect(wire.bootstrapProbes).toBe(0); + }); + + it('under invite_only on a deployment with no owner yet, renders the form', async () => { + renderAt('/register', configFor('invite_only'), { hasOwner: false }); + + expect(await screen.findByRole('button', CREATE_ACCOUNT)).toBeInTheDocument(); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + expect(wire.bootstrapProbes).toBe(1); + }); + + it('under open, renders the form as before and makes no bootstrap probe', async () => { + renderAt('/register', configFor('open')); + + expect(await screen.findByRole('button', CREATE_ACCOUNT)).toBeInTheDocument(); + expect(wire.bootstrapProbes).toBe(0); + }); + + it('with disableSignUp: true, bounces an invitation redirect to /login, keeping the redirect', async () => { + renderAt(`/register${INVITE_QUERY}`, configFor('invite_only', SIGN_UP_OFF)); + + await waitFor(() => expect(seen[seen.length - 1]).toBe(`/login${INVITE_QUERY}`)); + expect(screen.queryByRole('button', CREATE_ACCOUNT)).toBeNull(); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + }); +}); diff --git a/packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts b/packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts new file mode 100644 index 0000000000..0075f9e3ba --- /dev/null +++ b/packages/app-shell/src/console/auth/__tests__/signUpOffer-11691.test.ts @@ -0,0 +1,122 @@ +/** + * 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#11691 — the sign-up decision, pinned where it lives. + * + * `decideSignUpOffer` was written for the console's own login and register + * pages and moved into this package by objectui#11705, so that the exported + * `DefaultLoginPage` / `DefaultRegisterPage` call the same rule. These cases + * moved with it, unchanged, out of the console's + * `apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx` + * (which keeps the console pages' rendered pins); the default pages' rendered + * pins are `defaultPagesFollowPosture-11705.test.tsx` beside this file. + * + * The posture predicate is restated in `../signUpOffer` (the console's pages + * that call it sit in the console's eager closure); the parity case below + * imports the spec's own predicate and vocabulary, so a posture added or + * reclassified upstream turns this file red instead of silently mis-offering + * the form. + */ + +import { describe, it, expect } from 'vitest'; +import type { AuthPublicConfig } from '@object-ui/auth'; +import { AUDIENCE_POSTURES, audiencePermitsSelfRegistration } from '@objectstack/spec/system'; +import type { AudiencePosture } from '@objectstack/spec/system'; +import { + audienceAdmitsUninvitedSignUp, + decideSignUpOffer, + isInvitationRedirect, + needsBootstrapProbe, +} from '../signUpOffer'; + +const INVITATION = '/accept-invitation/inv_1'; + +/** The dev-seeded admin hint the console reads off the SAME config as the sign-up offer. */ +const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } }; + +type EmailPassword = NonNullable; +const OPEN_SIGN_UP: EmailPassword = { enabled: true, disableSignUp: false, requireEmailVerification: false }; + +/** + * The config as the server sends it. `posture` is a plain string because the + * cases include values OUTSIDE the spec vocabulary — what a newer server could + * send — so the one cast below is the wire, not a shortcut. + */ +function configFor( + posture: string | undefined, + emailPassword: EmailPassword = OPEN_SIGN_UP, +): AuthPublicConfig & typeof DEV_SEED { + return { + ...DEV_SEED, + emailPassword, + features: posture === undefined ? {} : { audiencePosture: posture as AudiencePosture }, + }; +} + +describe('signUpOffer — the decision both pages share (objectui#11691)', () => { + it('agrees with the spec on every audience posture the spec declares', () => { + expect(AUDIENCE_POSTURES.length).toBeGreaterThan(0); + for (const posture of AUDIENCE_POSTURES) { + expect(audienceAdmitsUninvitedSignUp(posture), posture).toBe( + audiencePermitsSelfRegistration(posture), + ); + } + }); + + it('reads a posture outside the spec vocabulary as not admitting', () => { + for (const value of ['invite-only', 'OPEN', 'emailDomain', '', null, undefined, 1]) { + expect(audienceAdmitsUninvitedSignUp(value), String(value)).toBe(false); + } + }); + + it('recognises the invitation-acceptance route as the redirect target', () => { + expect(isInvitationRedirect(INVITATION)).toBe(true); + expect(isInvitationRedirect(`${INVITATION}?from=mail`)).toBe(true); + for (const value of ['/accept-invitation/', '/accept-invitationx/inv_1', '/home', '//accept-invitation/inv_1', '', null]) { + expect(isInvitationRedirect(value), String(value)).toBe(false); + } + }); + + it('decides the offer from disableSignUp, the posture, the invitation and the owner state', () => { + const none = { invitationRedirect: false, bootstrap: 'bootstrapped' } as const; + const invited = { invitationRedirect: true, bootstrap: 'bootstrapped' } as const; + const fresh = { invitationRedirect: false, bootstrap: 'fresh' } as const; + const probing = { invitationRedirect: false, bootstrap: 'unknown' } as const; + const closed = configFor('open', { enabled: true, disableSignUp: true }); + + // disableSignUp: true hides everything — invitees and a fresh deployment included. + expect(decideSignUpOffer(closed, none)).toBe('closed'); + expect(decideSignUpOffer(closed, invited)).toBe('closed'); + expect(decideSignUpOffer(configFor('invite_only', { enabled: true, disableSignUp: true }), fresh)).toBe('closed'); + + // Nothing read yet, or an older server that sends no posture: as before. + expect(decideSignUpOffer(null, none)).toBe('form'); + expect(decideSignUpOffer(configFor(undefined), none)).toBe('form'); + + expect(decideSignUpOffer(configFor('open'), none)).toBe('form'); + expect(decideSignUpOffer(configFor('email_domain'), none)).toBe('form'); + + expect(decideSignUpOffer(configFor('invite_only'), invited)).toBe('form'); + expect(decideSignUpOffer(configFor('invite_only'), fresh)).toBe('form'); + expect(decideSignUpOffer(configFor('invite_only'), probing)).toBe('pending'); + expect(decideSignUpOffer(configFor('invite_only'), none)).toBe('by-invitation'); + expect(decideSignUpOffer(configFor('a_future_posture'), none)).toBe('by-invitation'); + }); + + it('asks for the bootstrap probe only when the posture is closed and nothing else admits', () => { + expect(needsBootstrapProbe(configFor('invite_only'), false)).toBe(true); + expect(needsBootstrapProbe(configFor('a_future_posture'), false)).toBe(true); + expect(needsBootstrapProbe(configFor('invite_only'), true)).toBe(false); + expect(needsBootstrapProbe(configFor('open'), false)).toBe(false); + expect(needsBootstrapProbe(configFor('email_domain'), false)).toBe(false); + expect(needsBootstrapProbe(configFor(undefined), false)).toBe(false); + expect(needsBootstrapProbe(configFor('invite_only', { enabled: true, disableSignUp: true }), false)).toBe(false); + expect(needsBootstrapProbe(null, false)).toBe(false); + }); +}); diff --git a/packages/app-shell/src/console/auth/bootstrapStatus.ts b/packages/app-shell/src/console/auth/bootstrapStatus.ts new file mode 100644 index 0000000000..b9d7611d8e --- /dev/null +++ b/packages/app-shell/src/console/auth/bootstrapStatus.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +/** + * `useBootstrapStatus` — whether this deployment has an owner yet + * (`GET /api/v1/auth/bootstrap-status` → `hasOwner`). + * + * Two readers, which is why it lives in this package (objectui#11705): the + * console's `/setup` entry policy (`apps/console/src/components/setupEntry.ts`, + * `decideSetupEntry`), and the sign-up decision `decideSignUpOffer` + * (`./signUpOffer`), which the console's login and register pages and this + * package's exported `DefaultLoginPage` / `DefaultRegisterPage` all call. Moved + * here from `setupEntry.ts` unchanged. + * + * The probe base follows the convention the rest of this package uses for + * same-origin REST calls: `VITE_SERVER_URL` (empty in same-origin production) + * plus `/api/v1/auth`. + */ + +import { useEffect, useState } from 'react'; + +const AUTH_BASE = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; + +/** + * Deployment bootstrap state. `fresh` is also what a FAILED probe reports — + * same fall-open as the console's `SetupPage` `catch`: showing the first-run + * wizard on an already-bootstrapped deployment is recoverable (the wizard + * re-probes and bounces to login), whereas hiding it on a genuinely fresh one + * is a dead end, because no account exists to log in with. + */ +export type BootstrapStatus = 'unknown' | 'fresh' | 'bootstrapped'; + +/** + * Probe `hasOwner` once. `enabled` is load-bearing twice over for the + * console's `/setup` policy: an already-authenticated visitor never pays for a + * request whose answer that policy would ignore, AND it is what confines a + * `fresh` verdict to session-less visits, which is what makes that verdict + * durable. The answer is kept once received — losing `enabled` (as `signUp()` + * does) cancels the in-flight probe, it does not reset the state. + */ +export function useBootstrapStatus(enabled: boolean): BootstrapStatus { + const [status, setStatus] = useState('unknown'); + useEffect(() => { + if (!enabled) return; + let cancelled = false; + void (async () => { + try { + const res = await fetch(`${AUTH_BASE}/bootstrap-status`, { credentials: 'include' }); + const data: { hasOwner?: boolean } = res.ok ? await res.json().catch(() => ({})) : {}; + if (!cancelled) setStatus(data.hasOwner === true ? 'bootstrapped' : 'fresh'); + } catch { + // Fall open — see BootstrapStatus. + if (!cancelled) setStatus('fresh'); + } + })(); + return () => { + cancelled = true; + }; + }, [enabled]); + return status; +} diff --git a/apps/console/src/pages/auth/signUpOffer.ts b/packages/app-shell/src/console/auth/signUpOffer.ts similarity index 70% rename from apps/console/src/pages/auth/signUpOffer.ts rename to packages/app-shell/src/console/auth/signUpOffer.ts index bc046cd40a..0d6ffcbb77 100644 --- a/apps/console/src/pages/auth/signUpOffer.ts +++ b/packages/app-shell/src/console/auth/signUpOffer.ts @@ -1,6 +1,22 @@ /** - * signUpOffer — what the console's login and register pages offer a visitor - * who has no account yet (objectui#11691). + * 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. + */ + +/** + * signUpOffer — what a login or register page offers a visitor who has no + * account yet (objectui#11691). + * + * ONE decision for every such page (objectui#11705): the console's own + * `/login` and `/register` (`apps/console/src/pages/auth/`) and this package's + * exported `DefaultLoginPage` / `DefaultRegisterPage` (`./LoginPage`, + * `./RegisterPage`, which `examples/console-starter` mounts) all call + * {@link decideSignUpOffer}. It was written for the console's pages and moved + * here unchanged, so that a package page can call it too; there is no second + * copy of the rule. * * The server publishes the sign-up rule as TWO keys of `/api/v1/auth/config`: * @@ -14,20 +30,21 @@ * admits a pending invitee, so hiding the form outright would dead-end the * people the posture exists to let in. * - * Reading only the first key is how the console used to offer "Sign up" under + * Reading only the first key is how the pages used to offer "Sign up" under * the default `invite_only` posture and then refuse the finished form with * `403 SELF_REGISTRATION_CLOSED`. Reading both, the pages offer the generic * form only when the server would accept it from this visitor: * * 1. the posture admits uninvited self-registration (`open`, `email_domain`); * 2. the visitor came from an invitation — the signed-out bounce of - * `DefaultAcceptInvitationPage` (app-shell) lands on + * `DefaultAcceptInvitationPage` (this package) lands on * `/login?redirect=/accept-invitation/ID`, and the login page forwards * that `redirect` to `/register`; * 3. the deployment has no owner yet (`GET /auth/bootstrap-status` answers - * `hasOwner: false`): the server admits the first account under every - * posture, so a fresh install never locks its operator out, and the - * self-hosting guide's first-run step is "open the root URL and sign up". + * `hasOwner: false`, read by `useBootstrapStatus` in `./bootstrapStatus`): + * the server admits the first account under every posture, so a fresh + * install never locks its operator out, and the self-hosting guide's + * first-run step is "open the root URL and sign up". * * Otherwise the login page offers no sign-up link, and the register page * explains that registration is by invitation BEFORE the form instead of @@ -35,25 +52,26 @@ * * A server that does not send `features.audiencePosture` (one that predates * the key) is answered exactly as before: `disableSignUp` alone decides. A - * posture value this console does not recognise reads as "not admitting", so + * posture value this package does not recognise reads as "not admitting", so * an unknown future value never brings back a form the server refuses. */ import type { AuthPublicConfig } from '@object-ui/auth'; -import type { BootstrapStatus } from '../../components/setupEntry'; +import type { BootstrapStatus } from './bootstrapStatus.js'; /** * Whether an audience posture admits a stranger's own sign-up — the spec's * `audiencePermitsSelfRegistration` (`@objectstack/spec/system`), restated. * - * Restated rather than imported for the reason `postureHasOrgWall` in - * app-shell's `useTenancyPosture.ts` records: the login and register pages - * are in the console's EAGER closure, and a runtime import of a spec subpath - * there pays for the subpath's schema modules on every page load to spell a - * three-value predicate. The drift that import would have prevented is caught - * at test time instead — `__tests__/signUpFollowsPosture-11691.test.tsx` - * imports the spec's real predicate and vocabulary and asserts this function - * agrees for every posture the spec declares. + * Restated rather than imported for the reason `postureHasOrgWall` in this + * package's `hooks/useTenancyPosture.ts` records: the console's login and + * register pages, which call this, are in the console's EAGER closure, and a + * runtime import of a spec subpath there pays for the subpath's schema modules + * on every page load to spell a three-value predicate. The drift that import + * would have prevented is caught at test time instead — + * `__tests__/signUpOffer-11691.test.ts` imports the spec's real predicate and + * vocabulary and asserts this function agrees for every posture the spec + * declares. * * `unknown` on purpose: the value arrives off the wire, and anything outside * the spec's vocabulary must read as `false`. @@ -63,9 +81,10 @@ export function audienceAdmitsUninvitedSignUp(posture: unknown): boolean { } /** - * The console route an invitation link opens (`App.tsx`, - * `/accept-invitation/:invitationId`), as a basename-stripped prefix — the - * shape `?redirect=` carries by contract. + * The route an invitation link opens (the console's `App.tsx` mounts + * `/accept-invitation/:invitationId`, and `DefaultAcceptInvitationPage` bounces + * a signed-out visitor from it), as a basename-stripped prefix — the shape + * `?redirect=` carries by contract. */ const INVITATION_ROUTE_PREFIX = '/accept-invitation/'; diff --git a/packages/app-shell/src/index.ts b/packages/app-shell/src/index.ts index 6246d77840..b98ed749a7 100644 --- a/packages/app-shell/src/index.ts +++ b/packages/app-shell/src/index.ts @@ -268,6 +268,17 @@ export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage.js'; export { signInRefusalMessages } from './console/auth/signInRefusalMessages.js'; export { RegisterPage as DefaultRegisterPage } from './console/auth/RegisterPage.js'; export { signUpRefusalMessages } from './console/auth/signUpRefusalMessages.js'; +// The sign-up decision the default login/register pages above and the +// console's own pages share — one rule, read off `disableSignUp` AND +// `features.audiencePosture` (objectui#11691, objectui#11705). +export { + decideSignUpOffer, + needsBootstrapProbe, + isInvitationRedirect, + type SignUpOffer, + type SignUpOfferContext, +} from './console/auth/signUpOffer.js'; +export { useBootstrapStatus, type BootstrapStatus } from './console/auth/bootstrapStatus.js'; export { ForgotPasswordPage as DefaultForgotPasswordPage } from './console/auth/ForgotPasswordPage.js'; export { HomeLayout as DefaultHomeLayout, HomeLayout } from './console/home/HomeLayout.js'; export { HomePage as DefaultHomePage, HomePage } from './console/home/HomePage.js'; diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 17bf145ce5..4ed8068ca8 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -307,8 +307,9 @@ export interface AuthPublicConfig { * admits a pending invitee (and a fresh deployment's first owner), so the * two keys together read "sign-up is open to invitees only". A surface * that offers a generic sign-up therefore reads both — the console's - * login and register pages do, through `pages/auth/signUpOffer.ts` - * (objectui#11691). Absent (older server / config not yet fetched) ⇒ + * login and register pages and `@object-ui/app-shell`'s exported default + * ones do, through app-shell's `decideSignUpOffer` (objectui#11691, + * objectui#11705). Absent (older server / config not yet fetched) ⇒ * `disableSignUp` alone decides, as it did before the key existed. */ audiencePosture?: AudiencePosture; From 54cb036b9634402f444d083afa66ac80a67be544 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 10:25:29 +0000 Subject: [PATCH 2/2] docs(app-shell): document the default auth pages' sign-up offer (objectui#11705) A README section for DefaultLoginPage / DefaultRegisterPage: what each page offers per disableSignUp and audience posture, and the exported decision a host building its own pages can call. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- packages/app-shell/README.md | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/packages/app-shell/README.md b/packages/app-shell/README.md index 80240f3ed1..281ba7edc4 100644 --- a/packages/app-shell/README.md +++ b/packages/app-shell/README.md @@ -193,6 +193,64 @@ exactly when it mounts the endpoint; every other runtime sends no key, which reads as off, so a self-hosted admin's page load issues no request and logs no 404. Only the literal `true` turns it on. +## Default auth pages and the sign-up offer + +`DefaultLoginPage` and `DefaultRegisterPage` are the sign-in and sign-up pages +a host mounts at `/login` and `/register` (`examples/console-starter` does). +They offer a generic sign-up only where the server would accept one. The +server states that rule in two keys of `GET /api/v1/auth/config`: +`emailPassword.disableSignUp` (the hard off switch) and +`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): + +| the visitor | `/login` | `/register` | +| --- | --- | --- | +| `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 | + +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: + +```tsx +import { useEffect, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useAuth, type AuthPublicConfig } from '@object-ui/auth'; +import { + decideSignUpOffer, + isInvitationRedirect, + needsBootstrapProbe, + useBootstrapStatus, + type SignUpOffer, +} from '@object-ui/app-shell'; + +/** 'form' | 'by-invitation' | 'closed' | 'pending' */ +export function useSignUpOffer(): SignUpOffer { + const [searchParams] = useSearchParams(); + const { user, getAuthConfig } = useAuth(); + // `null` until `/auth/config` has been read (and after a failed read). + const [authConfig, setAuthConfig] = useState(null); + useEffect(() => { + getAuthConfig().then(setAuthConfig, () => undefined); + }, [getAuthConfig]); + + const invitationRedirect = isInvitationRedirect(searchParams.get('redirect')); + // Probes GET /api/v1/auth/bootstrap-status only when the posture is closed + // and nothing else admits the visitor. + const bootstrap = useBootstrapStatus(!user && needsBootstrapProbe(authConfig, invitationRedirect)); + return decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); +} +``` + +A `null` config answers `form`, leaving the server's own gate as the source of +truth. An invitation redirect is an affordance, not an authorization: the +server still refuses a non-invitee's sign-up. + ## Components ### AppShell