From df140b8f6d2ef748d2de89791aab3b400c741a74 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:46:10 +0000 Subject: [PATCH 1/4] fix(console,auth): the login and register pages offer sign-up only where the audience posture admits it (objectui#11691) The console read only `emailPassword.disableSignUp`, which the server deliberately does not force from the audience posture. Under the default `invite_only` posture the login page offered "Sign up" and the register page refused the finished form with 403 SELF_REGISTRATION_CLOSED. Both pages now read `features.audiencePosture` beside `disableSignUp` through one decision (`pages/auth/signUpOffer.ts`): the generic sign-up is offered when the posture admits strangers (`open`, `email_domain`), when the visitor came from an invitation redirect, or when the deployment has no owner yet; otherwise `/register` explains that registration is by invitation before the form. A server that sends no posture is answered as before. `AuthPublicConfig.features` declares `audiencePosture` with the spec's `AudiencePosture`, and `@object-ui/auth` raises its `@objectstack/spec` floor to the first release that carries that type. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- apps/console/src/pages/auth/LoginPage.tsx | 30 +++- apps/console/src/pages/auth/RegisterPage.tsx | 86 ++++++++++-- apps/console/src/pages/auth/signUpOffer.ts | 138 +++++++++++++++++++ packages/auth/README.md | 15 ++ packages/auth/package.json | 2 +- packages/auth/src/types.ts | 17 +++ pnpm-lock.yaml | 2 +- 7 files changed, 269 insertions(+), 21 deletions(-) create mode 100644 apps/console/src/pages/auth/signUpOffer.ts diff --git a/apps/console/src/pages/auth/LoginPage.tsx b/apps/console/src/pages/auth/LoginPage.tsx index 819bf65002..a446cf0232 100644 --- a/apps/console/src/pages/auth/LoginPage.tsx +++ b/apps/console/src/pages/auth/LoginPage.tsx @@ -12,18 +12,24 @@ * - Post-login orchestration: replay the original `/oauth2/authorize` * request, auto-select the user's single organization, or honour a * safe `?redirect=` target. - * - Hides the "Sign up" link when the server reports - * `emailPassword.disableSignUp === true`. + * - 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`) only + * for an invitation redirect or a deployment with no owner yet — see + * `./signUpOffer` (objectui#11691). */ 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 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 { 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. @@ -58,7 +64,10 @@ export function LoginPage() { getAuthConfig, } = useAuth(); - const [signUpDisabled, setSignUpDisabled] = useState(false); + // 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); // 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. @@ -100,6 +109,15 @@ export function LoginPage() { if (!isLoading) setHasBootstrapped(true); }, [isLoading]); + // 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`. + const invitationRedirect = isInvitationRedirect(redirect); + const bootstrap = useBootstrapStatus( + hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect), + ); + const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); + // Detect SSO hand-off so we can surface the relying-party host. const ssoTarget = useMemo(() => { if (typeof window === 'undefined') return null; @@ -127,14 +145,14 @@ export function LoginPage() { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Read public auth config once to know whether sign-up is gated off and + // Read public auth config once to know whether sign-up is offered and // whether the dev-seeded admin credentials should be surfaced. useEffect(() => { let cancelled = false; getAuthConfig() .then((cfg) => { if (cancelled) return; - setSignUpDisabled(cfg?.emailPassword?.disableSignUp === true); + setAuthConfig(cfg ?? null); const seed = (cfg as { devSeedAdmin?: { email?: unknown; password?: unknown } } | null) ?.devSeedAdmin; setDevSeedAdmin( @@ -296,7 +314,7 @@ export function LoginPage() { ) : null} diff --git a/apps/console/src/pages/auth/RegisterPage.tsx b/apps/console/src/pages/auth/RegisterPage.tsx index 8ca70e83c6..ea87d9bad4 100644 --- a/apps/console/src/pages/auth/RegisterPage.tsx +++ b/apps/console/src/pages/auth/RegisterPage.tsx @@ -7,6 +7,10 @@ * * - Bounces to `/login` if `emailPassword.disableSignUp === true` * (defense-in-depth; the server-side gate is the source of truth). + * - 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). * - 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). @@ -16,12 +20,15 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; import { Link, useNavigate, useSearchParams } from 'react-router-dom'; -import { useAuth, RegisterForm } from '@object-ui/auth'; +import { useAuth, RegisterForm, AuthFormHeader, AUTH_LINK_CLASS } from '@object-ui/auth'; +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 { 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'; @@ -53,7 +60,10 @@ export function RegisterPage() { getAuthConfig, } = useAuth(); - const [signUpDisabled, setSignUpDisabled] = useState(null); + // `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); const [autoSelectingOrg, setAutoSelectingOrg] = useState(false); // Fire the OAuth hand-off fetch at most once (see LoginPage). const ssoHandoffStartedRef = useRef(false); @@ -67,26 +77,40 @@ export function RegisterPage() { if (!isLoading) setHasBootstrapped(true); }, [isLoading]); - // Probe public auth config — bounce to /login if sign-up is gated off. + // Probe public auth config — what this visitor is offered follows from it. useEffect(() => { let cancelled = false; getAuthConfig() .then((cfg) => { - if (cancelled) return; - const disabled = cfg?.emailPassword?.disableSignUp === true; - setSignUpDisabled(disabled); - if (disabled) { - const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; - navigate(`/login${search}`, { replace: true }); - } + if (!cancelled) setConfigRead({ config: cfg ?? null }); }) .catch(() => { - if (!cancelled) setSignUpDisabled(false); + if (!cancelled) setConfigRead({ config: null }); }); return () => { cancelled = true; }; - }, [getAuthConfig, navigate, redirect]); + }, [getAuthConfig]); + + // 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`. + const authConfig = configRead ? configRead.config : null; + const invitationRedirect = isInvitationRedirect(redirect); + const bootstrap = useBootstrapStatus( + configRead !== null && + hasBootstrapped && + !user && + needsBootstrapProbe(authConfig, invitationRedirect), + ); + const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); + + // Sign-up switched off — bounce to /login. + useEffect(() => { + if (signUpOffer !== 'closed') return; + const search = redirect ? `?redirect=${encodeURIComponent(redirect)}` : ''; + navigate(`/login${search}`, { replace: true }); + }, [signUpOffer, navigate, redirect]); // Post-signup orchestration mirrors LoginPage exactly. useEffect(() => { @@ -132,7 +156,13 @@ export function RegisterPage() { switchOrganization, ]); - if (signUpDisabled === null || (isLoading && !hasBootstrapped) || user) { + if ( + configRead === null || + signUpOffer === 'closed' || + signUpOffer === 'pending' || + (isLoading && !hasBootstrapped) || + user + ) { return (
@@ -151,6 +181,36 @@ export function RegisterPage() { ? withConsoleBaseRootRelative(redirect) : undefined; + // objectui#11691 — registration here is by invitation only. Say so BEFORE + // the form instead of refusing the finished form with + // `SELF_REGISTRATION_CLOSED`; the sentence is that refusal's own copy. + if (signUpOffer === 'by-invitation') { + return ( + + +
+ +

+ {t('auth.register.hasAccountText', { defaultValue: 'Already have an account?' })}{' '} + + {t('auth.register.signInText', { defaultValue: 'Sign in' })} + +

+
+
+
+ ); + } + return ( diff --git a/apps/console/src/pages/auth/signUpOffer.ts b/apps/console/src/pages/auth/signUpOffer.ts new file mode 100644 index 0000000000..bc046cd40a --- /dev/null +++ b/apps/console/src/pages/auth/signUpOffer.ts @@ -0,0 +1,138 @@ +/** + * signUpOffer — what the console's login and register pages offer a visitor + * who has no account yet (objectui#11691). + * + * The server publishes the sign-up rule as TWO keys of `/api/v1/auth/config`: + * + * - `emailPassword.disableSignUp` — the hard off switch (environment + * variable, config, or SSO-only mode). When it is `true` nothing is + * offered, invitees included; that is objectui#11634's gate and it is + * unchanged here. + * - `features.audiencePosture` — who may self-register (`invite_only`, + * `email_domain`, `open`). The server deliberately does NOT force + * `disableSignUp` from it: under `invite_only` the sign-up route still + * 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 + * 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 + * `/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". + * + * Otherwise the login page offers no sign-up link, and the register page + * explains that registration is by invitation BEFORE the form instead of + * after it. + * + * 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 + * 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'; + +/** + * 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. + * + * `unknown` on purpose: the value arrives off the wire, and anything outside + * the spec's vocabulary must read as `false`. + */ +export function audienceAdmitsUninvitedSignUp(posture: unknown): boolean { + return posture === 'open' || posture === 'email_domain'; +} + +/** + * The console route an invitation link opens (`App.tsx`, + * `/accept-invitation/:invitationId`), as a basename-stripped prefix — the + * shape `?redirect=` carries by contract. + */ +const INVITATION_ROUTE_PREFIX = '/accept-invitation/'; + +/** + * Whether a `?redirect=` target is an invitation-acceptance page, i.e. the + * visitor was bounced here by `DefaultAcceptInvitationPage` while signed out. + * + * This is an affordance, never an authorization: a hand-typed URL can claim + * it, and the server still refuses a non-invitee's sign-up with + * `SELF_REGISTRATION_CLOSED` — which `RegisterForm` renders as a localized + * refusal. All it decides is that this visitor is shown the form. + */ +export function isInvitationRedirect(redirect: string | null | undefined): boolean { + if (!redirect || !redirect.startsWith(INVITATION_ROUTE_PREFIX)) return false; + const id = redirect.slice(INVITATION_ROUTE_PREFIX.length).split(/[/?#]/, 1)[0]; + return id.length > 0; +} + +/** + * What the page offers: + * - `form` — the generic sign-up (link on `/login`, the form on `/register`); + * - `by-invitation` — no generic sign-up; `/register` explains why; + * - `closed` — sign-up is switched off (`disableSignUp: true`); + * - `pending` — the posture is closed and the bootstrap probe has not + * answered yet; offer nothing until it does. + */ +export type SignUpOffer = 'form' | 'by-invitation' | 'closed' | 'pending'; + +export interface SignUpOfferContext { + /** {@link isInvitationRedirect} of the page's `?redirect=`. */ + invitationRedirect: boolean; + /** `useBootstrapStatus` — consulted only when the posture is closed. */ + bootstrap: BootstrapStatus; +} + +/** + * Whether the decision needs the bootstrap probe at all — only when the + * posture is closed to strangers and nothing else already admits the visitor. + * Gates `useBootstrapStatus`, so a deployment on `open` (or an older server) + * makes no extra request. + */ +export function needsBootstrapProbe( + config: AuthPublicConfig | null, + invitationRedirect: boolean, +): boolean { + if (!config || config.emailPassword?.disableSignUp === true) return false; + const posture = config.features?.audiencePosture; + if (posture === undefined) return false; + return !audienceAdmitsUninvitedSignUp(posture) && !invitationRedirect; +} + +/** + * The decision. `config` is `null` while it has not been read (or the read + * failed) — then nothing is known and the page behaves as it did before the + * posture existed, leaving the server's own gate as the source of truth. + */ +export function decideSignUpOffer( + config: AuthPublicConfig | null, + context: SignUpOfferContext, +): SignUpOffer { + if (config?.emailPassword?.disableSignUp === true) return 'closed'; + const posture = config?.features?.audiencePosture; + // An older server: no posture key ⇒ `disableSignUp` alone decided, above. + if (posture === undefined) return 'form'; + if (audienceAdmitsUninvitedSignUp(posture)) return 'form'; + if (context.invitationRedirect) return 'form'; + if (context.bootstrap === 'fresh') return 'form'; + if (context.bootstrap === 'unknown') return 'pending'; + return 'by-invitation'; +} diff --git a/packages/auth/README.md b/packages/auth/README.md index 47950e252e..f63bbee8b7 100644 --- a/packages/auth/README.md +++ b/packages/auth/README.md @@ -371,6 +371,21 @@ gates the "Sign in with SSO" button, `features.phoneNumberOtp` gates the verification-code mode, `features.deviceAuthorization` gates the device-approval page, and so on. +### `features.audiencePosture` — read it beside `emailPassword.disableSignUp` + +`features.audiencePosture` reports which audience posture is in force: `invite_only` (the +default when the deployment declares none), `email_domain` or `open`. It is a value, not a +flag, and the server does **not** force `emailPassword.disableSignUp` from it: under +`invite_only` the sign-up route still admits a pending invitee, and a fresh deployment's +first owner. So `disableSignUp: false` on its own does not mean a stranger may register. + +A surface that offers a generic sign-up reads both keys. `disableSignUp: true` hides +sign-up outright. Otherwise offer it when the posture is `open` or `email_domain`, or when +the visitor came from an invitation; under `invite_only` say that registration is by +invitation instead of rendering a form the server will refuse. A server that sends no +`audiencePosture` predates the key, and `disableSignUp` alone decides there. The +console's login and register pages are that reader (objectui#11691). + ### Reserved flags — advertised by the server, consumed by nothing Two members of that map are **declared but deliberately not consumed** by this package: diff --git a/packages/auth/package.json b/packages/auth/package.json index 0e3334fd14..ebd629546d 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -37,7 +37,7 @@ "react": "^18.0.0 || ^19.0.0" }, "dependencies": { - "@objectstack/spec": "^17.0.0", + "@objectstack/spec": "^17.3.0", "better-auth": "^1.7.2" }, "devDependencies": { diff --git a/packages/auth/src/types.ts b/packages/auth/src/types.ts index 5c7c36360d..17bf145ce5 100644 --- a/packages/auth/src/types.ts +++ b/packages/auth/src/types.ts @@ -13,6 +13,7 @@ import type { DelegableAdminScope, } from '@objectstack/spec/contracts'; import type { TenancyPosture } from '@objectstack/spec/security'; +import type { AudiencePosture } from '@objectstack/spec/system'; import type { AuthInvitationStatus } from './invitation-status.js'; /** @@ -295,6 +296,22 @@ export interface AuthPublicConfig { * Absent (older server / config not yet fetched) ⇒ no group affordances. */ tenancyPosture?: TenancyPosture; + /** + * Which audience posture is in force — the deployment's declared answer + * to "who may self-register here" (`invite_only`, the undeclared default; + * `email_domain`; `open`). A value, not a flag: the spec lists it in + * `PUBLIC_AUTH_CONFIG_NON_FLAG_KEYS` beside `tenancyPosture`. + * + * It does NOT replace `emailPassword.disableSignUp`, and the server does + * not force that flag from it: under `invite_only` the sign-up route still + * 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) ⇒ + * `disableSignUp` alone decides, as it did before the key existed. + */ + audiencePosture?: AudiencePosture; }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d85ca5048b..6a365539d9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -871,7 +871,7 @@ importers: packages/auth: dependencies: '@objectstack/spec': - specifier: ^17.0.0 + specifier: ^17.3.0 version: 17.6.0(ai@7.0.65(zod@4.6.5)) better-auth: specifier: ^1.7.2 From 387c36bc5c548ff8380546feecad6ddd8991e8c1 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 06:50:51 +0000 Subject: [PATCH 2/4] test(console): pin the sign-up offer against the audience posture (objectui#11691) A real AuthProvider over a real auth client against a stub server: under invite_only /login offers no generic "Sign up" and /register explains before the form; an invitation redirect still reaches a submitted registration; a fresh deployment keeps sign-up for its first owner; open, email_domain and a server without the posture key are unchanged; and disableSignUp: true still hides everything. The restated posture predicate is checked against the spec's own for every declared posture. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../signUpFollowsPosture-11691.test.tsx | 319 ++++++++++++++++++ 1 file changed, 319 insertions(+) create mode 100644 apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx diff --git a/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx b/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx new file mode 100644 index 0000000000..c03cffe35d --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx @@ -0,0 +1,319 @@ +/** + * 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 console offers 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). The server does not + * force the first from the second — under `invite_only` its sign-up route + * still admits a pending invitee — so a page reading only `disableSignUp` + * offered "Sign up" under the DEFAULT posture and refused the finished form + * with `403 SELF_REGISTRATION_CLOSED`. + * + * Nothing in `@object-ui/auth` is replaced: a real `AuthProvider` over a real + * `createAuthClient` runs against a stub server that answers `/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. + * + * 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. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { 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 { AUDIENCE_POSTURES, audiencePermitsSelfRegistration } 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; +const INVITATION = '/accept-invitation/inv_1'; +const INVITE_QUERY = `?redirect=${encodeURIComponent(INVITATION)}`; +const SELF_REGISTRATION_CLOSED_TEXT = builtInLocales.en.auth.register.errors.selfRegistrationClosed; + +/** The dev-seeded admin hint is set by the SAME config read as the sign-up offer. */ +const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } }; + +const OPEN_SIGN_UP = { enabled: true, disableSignUp: false, requireEmailVerification: false }; + +function configFor( + posture: string | undefined, + emailPassword: Record = OPEN_SIGN_UP, +): AuthPublicConfig { + return { + ...DEV_SEED, + emailPassword, + features: posture === undefined ? {} : { audiencePosture: posture }, + } as AuthPublicConfig; +} + +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 `/login` and `/register` as `App.tsx` routes them. The bootstrap + * probe uses the global `fetch` (see `components/setupEntry`), so the same + * stub answers it. + */ +function renderAt(path: string, config: AuthPublicConfig, { hasOwner = true } = {}) { + const fetchFn = stubServer(config, hasOwner); + vi.stubGlobal('fetch', fetchFn); + window.history.replaceState({}, '', path); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + return render( + + + + + + } /> + } /> + + + + , + ); +} + +/** Wait until the config read has been applied to the login page. */ +async function loginConfigApplied() { + await screen.findByTestId('dev-admin-hint'); + await screen.findByLabelText('Email'); +} + +beforeEach(() => { + seen.length = 0; + window.localStorage.clear(); + vi.spyOn(window.location, 'assign').mockImplementation(() => undefined); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + 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')); + await loginConfigApplied(); + await waitFor(() => expect(wire.bootstrapProbes).toBe(1)); + + 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', { enabled: true, disableSignUp: true })); + await loginConfigApplied(); + + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + }); +}); + +describe('RegisterPage — explains invitation-only registration before the form (objectui#11691)', () => { + 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(SELF_REGISTRATION_CLOSED_TEXT); + expect(screen.getByRole('link', { name: 'Sign in' }).getAttribute('href')).toBe('/login'); + expect(screen.queryByLabelText('Email')).toBeNull(); + expect(screen.queryByRole('button', { name: 'Create Account' })).toBeNull(); + 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('Name'), 'Ada'); + await userEvent.type(screen.getByLabelText('Email'), 'ada@example.com'); + await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2'); + await userEvent.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2'); + await userEvent.click(screen.getByRole('button', { name: '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', { name: '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', { name: 'Create Account' })).toBeInTheDocument(); + expect(wire.bootstrapProbes).toBe(0); + }); + + it('with disableSignUp: true, bounces an invitation redirect to /login as before', async () => { + renderAt(`/register${INVITE_QUERY}`, configFor('invite_only', { enabled: true, disableSignUp: true })); + + await waitFor(() => expect(seen.at(-1)).toBe(`/login${INVITE_QUERY}`)); + expect(screen.queryByRole('button', { name: 'Create Account' })).toBeNull(); + expect(screen.queryByTestId('register-by-invitation')).toBeNull(); + }); +}); From 8e12c74b4041e78263ee307d8bfe3ca572172830 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:01:04 +0000 Subject: [PATCH 3/4] test(console): type the 11691 fixtures against AuthPublicConfig (objectui#11691) The console type-check covers test files: the posture fixture is typed as the wire config (one cast, for the off-vocabulary values a newer server could send), and the last-route read avoids `Array.prototype.at`, which the console's ES2020 lib does not declare. Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .../signUpFollowsPosture-11691.test.tsx | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) 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 c03cffe35d..c77b2b6583 100644 --- a/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx +++ b/apps/console/src/pages/auth/__tests__/signUpFollowsPosture-11691.test.tsx @@ -41,6 +41,7 @@ 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 { @@ -59,17 +60,23 @@ const SELF_REGISTRATION_CLOSED_TEXT = builtInLocales.en.auth.register.errors.sel /** The dev-seeded admin hint is set by the SAME config read as the sign-up offer. */ const DEV_SEED = { devSeedAdmin: { email: 'admin@objectos.ai', password: 'admin123' } }; -const OPEN_SIGN_UP = { enabled: true, disableSignUp: false, requireEmailVerification: false }; +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: Record = OPEN_SIGN_UP, -): AuthPublicConfig { + emailPassword: EmailPassword = OPEN_SIGN_UP, +): AuthPublicConfig & typeof DEV_SEED { return { ...DEV_SEED, emailPassword, - features: posture === undefined ? {} : { audiencePosture: posture }, - } as AuthPublicConfig; + features: posture === undefined ? {} : { audiencePosture: posture as AudiencePosture }, + }; } interface Wire { @@ -312,7 +319,7 @@ describe('RegisterPage — explains invitation-only registration before the form it('with disableSignUp: true, bounces an invitation redirect to /login as before', async () => { renderAt(`/register${INVITE_QUERY}`, configFor('invite_only', { enabled: true, disableSignUp: true })); - await waitFor(() => expect(seen.at(-1)).toBe(`/login${INVITE_QUERY}`)); + await waitFor(() => expect(seen[seen.length - 1]).toBe(`/login${INVITE_QUERY}`)); expect(screen.queryByRole('button', { name: 'Create Account' })).toBeNull(); expect(screen.queryByTestId('register-by-invitation')).toBeNull(); }); From efcf1ea03e2c7d9831db80958b453d1b550cb96c Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 6 Oct 2026 07:03:15 +0000 Subject: [PATCH 4/4] chore(changeset): declare the posture-aware sign-up offer (objectui#11691) Claude-Session: https://claude.ai/code/session_01FngvPpdrnhHMdHHq6vwwju Co-authored-by: Claude --- .changeset/11691-signup-follows-posture.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .changeset/11691-signup-follows-posture.md diff --git a/.changeset/11691-signup-follows-posture.md b/.changeset/11691-signup-follows-posture.md new file mode 100644 index 0000000000..323e49cfd7 --- /dev/null +++ b/.changeset/11691-signup-follows-posture.md @@ -0,0 +1,16 @@ +--- +'@object-ui/console': minor +'@object-ui/auth': minor +--- + +The console's login and register pages offer a generic sign-up only where the server would accept one (objectui#11691). `/api/v1/auth/config` states the sign-up rule as two keys, `emailPassword.disableSignUp` and `features.audiencePosture`, and the server deliberately does not force the first from the second: under `invite_only` its sign-up route still admits a pending invitee. The pages read only `disableSignUp`, so under the default `invite_only` posture `/login` offered "Sign up" and `/register` refused the finished form with `SELF_REGISTRATION_CLOSED`. + +Both pages now read both keys: + +- `disableSignUp: true` still hides sign-up outright, invitation links included. +- Under `open` or `email_domain`, nothing changes. +- Under `invite_only`, `/login` shows no "Sign up" link. A visitor who arrived from an invitation (`?redirect=/accept-invitation/ID`, the signed-out bounce of the invitation page) still gets the link, and `/register` still renders the form for them. A deployment with no owner yet (`GET /api/v1/auth/bootstrap-status` answers `hasOwner: false`) keeps the link for its first owner, because the server admits the first account under every posture. +- Otherwise `/register` says that self-registration is not open and points back to sign-in, before any field is filled in, instead of refusing the submitted form. +- A server that sends no `audiencePosture` is answered as before, by `disableSignUp` alone. A posture value the console does not recognise reads as closed. + +**Clause-②: yes.** `@object-ui/auth`'s published `AuthPublicConfig.features` gains an optional `audiencePosture` member, typed as `@objectstack/spec`'s `AudiencePosture`. The package's `@objectstack/spec` range moves from `^17.0.0` to `^17.3.0`, the first release that declares that type. No export, prop or i18n key is added or removed: the register page's explanation reuses the existing `auth.register.errors.selfRegistrationClosed` sentence.