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
11 changes: 11 additions & 0 deletions .changeset/11705-default-auth-pages-posture.md
Original file line number Diff line number Diff line change
@@ -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.
53 changes: 12 additions & 41 deletions apps/console/src/components/setupEntry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -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<BootstrapStatus>('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);
Expand Down
16 changes: 11 additions & 5 deletions apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
Expand Down Expand Up @@ -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),
Expand Down
17 changes: 12 additions & 5 deletions apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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';
Expand Down Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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'));
Expand Down
58 changes: 58 additions & 0 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuthPublicConfig | null>(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
Expand Down
Loading
Loading