Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/11691-signup-follows-posture.md
Original file line number Diff line number Diff line change
@@ -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.
30 changes: 24 additions & 6 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<AuthPublicConfig | null>(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.
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -296,7 +314,7 @@ export function LoginPage() {
) : null}
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<LoginFormCard
registerUrl={signUpDisabled ? undefined : registerUrl}
registerUrl={signUpOffer === 'form' ? registerUrl : undefined}
redirect={redirect}
/>
</Card>
Expand Down
86 changes: 73 additions & 13 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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';
Expand Down Expand Up @@ -53,7 +60,10 @@ export function RegisterPage() {
getAuthConfig,
} = useAuth();

const [signUpDisabled, setSignUpDisabled] = useState<boolean | null>(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);
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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 (
<AuthLayout>
<div className="flex flex-col items-center gap-3 py-10 text-sm text-muted-foreground">
Expand All @@ -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 (
<AuthLayout formWidth="md">
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<div
data-testid="register-by-invitation"
className="mx-auto flex w-full flex-col justify-center space-y-7 sm:w-[400px]"
>
<AuthFormHeader
title={t('auth.register.title', { defaultValue: 'Create an account' })}
description={t('auth.register.errors.selfRegistrationClosed', {
defaultValue:
'Self-registration is not open on this environment. Ask an administrator for an invitation.',
})}
/>
<p className="px-8 text-center text-sm text-muted-foreground">
{t('auth.register.hasAccountText', { defaultValue: 'Already have an account?' })}{' '}
<Link to={loginUrl} className={AUTH_LINK_CLASS}>
{t('auth.register.signInText', { defaultValue: 'Sign in' })}
</Link>
</p>
</div>
</Card>
</AuthLayout>
);
}

return (
<AuthLayout formWidth="md">
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
Expand Down
Loading
Loading