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
20 changes: 20 additions & 0 deletions .changeset/27509-invitation-link-no-longer-valid.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
---
'@object-ui/auth': patch
'@object-ui/app-shell': patch
'@object-ui/console': patch
'@object-ui/i18n': patch
---

fix(console-auth): an invitation link the server no longer knows reads "This invitation link is no longer valid" instead of offering a sign-up it will refuse, and a refused invitee is told which address the invitation was sent to

Clause-②: no (`getInvitationPreview`'s exported signature is unchanged; it now resolves `null` only for the preview door's own not-found body, which is what its documented contract already said)

Metadata-protocol: none

The signed-out invitation preview answers one 404 for an unknown, expired, accepted, rejected or cancelled invitation. The login and register pages read that 404 as "no preview", kept the "Accept your invitation … Create an account and join" flow, and the sign-up then failed with "Self-registration is not open on this system. Ask an administrator for an invitation." — telling a person holding an invitation link to go and get one.

- **A dead link.** All four pages (the console's `/login` and `/register`, and app-shell's `DefaultLoginPage` and `DefaultRegisterPage`) now show "This invitation link is no longer valid" with a link to the plain sign-in, and no sign-up prompt or form. The copy does not say which of the five cases it is, because the server does not say.
- **A refused invitation sign-up.** A `SELF_REGISTRATION_CLOSED` refusal on a register page opened from an invitation link reads the preview again. If the invitation is gone, the page shows the same notice. If it is still pending, the refusal reads "This invitation was sent to i***@example.com. Register with that address to accept it.", using the masked address the preview already returns.
- **`@object-ui/auth`.** `getInvitationPreview` resolves `null` only for a 404 that carries the door's `RESOURCE_NOT_FOUND` body. A 404 without it (a server that does not have the route) now rejects like any other failed read, so a page keeps its generic invitation copy instead of calling a valid link dead.
- **`@object-ui/app-shell`.** New `useInvitationArrival` (`preview`, `gone`, `recheck`) and `InvitationGoneNotice`; `useInvitationPreview` is unchanged. `signUpRefusalMessages` takes an optional invitation argument.
- **`@object-ui/i18n`.** Five new keys in all ten packs: `auth.login.invitationGoneTitle`, `auth.login.invitationGoneDescription`, `auth.login.invitationGoneSignIn`, `auth.register.errors.invitationSentTo`, `auth.register.errors.invitationAddressOnly`.
17 changes: 12 additions & 5 deletions ui/apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@
* link — the same prompt `DefaultLoginPage` shows (#25886). The greeting
* names the organization and the inviter when the signed-out invitation
* preview answers, with the masked invited address as the one to use (#26473,
* app-shell's `useInvitationPreview`); otherwise it keeps the generic copy.
* app-shell's `useInvitationArrival`); otherwise it keeps the generic copy.
* For an invitation the server no longer knows, the card holds app-shell's
* `InvitationGoneNotice` alone: no prompt, no form (#27509).
*
* Layout (objectui#12600): one 400 px column — brand mark, the card, a footer.
* The card carries this page's own two-line header, so `<LoginForm>` renders
Expand Down Expand Up @@ -62,9 +64,10 @@ import {
isInvitationRedirect,
needsBootstrapProbe,
useBootstrapStatus,
InvitationGoneNotice,
InvitationSignUpPrompt,
invitationArrivalCopy,
useInvitationPreview,
useInvitationArrival,
} from '@object-ui/app-shell';
import { AuthLayout } from './AuthLayout';
import { AuthFooter, readLegalLinks } from './AuthFooter';
Expand Down Expand Up @@ -158,10 +161,12 @@ export function LoginPage() {
hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect),
);
const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
// #26473 — who invited this visitor, and to what; `null` keeps the generic copy.
const invitationPreview = useInvitationPreview(redirect);
// #26473 — who invited this visitor, and to what; `null` keeps the generic
// copy. #27509 — `gone` when the server no longer knows the invitation.
const invitationArrival = useInvitationArrival(redirect);
const invitationGone = invitationRedirect && invitationArrival.gone;
const invitationCopy = invitationRedirect
? invitationArrivalCopy('login', invitationPreview, t)
? invitationArrivalCopy('login', invitationArrival.preview, t)
: null;

// Detect SSO hand-off so we can surface the relying-party host.
Expand Down Expand Up @@ -333,6 +338,8 @@ export function LoginPage() {
retrying={configRead === 'loading'}
onRetry={retryConfigRead}
/>
) : invitationGone ? (
<InvitationGoneNotice />
) : (
<>
<div className="mb-5">
Expand Down
39 changes: 34 additions & 5 deletions ui/apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
* - Draws `/login`'s footer under the form — the locale switch and the
* privacy / terms links the server's auth config serves, from the same
* reader (#25885): sign-up is where a visitor accepts the terms.
* - Opened from an invitation the server no longer knows, draws app-shell's
* `InvitationGoneNotice` in place of the form; a sign-up refused with
* `SELF_REGISTRATION_CLOSED` re-reads the preview, so an invitation that
* went away meanwhile ends the same way and a live one names the address
* it was sent to (#27509).
*/

import { useEffect, useLayoutEffect, useRef, useState } from 'react';
Expand All @@ -40,8 +45,9 @@ import {
isInvitationRedirect,
needsBootstrapProbe,
useBootstrapStatus,
InvitationGoneNotice,
invitationArrivalCopy,
useInvitationPreview,
useInvitationArrival,
} from '@object-ui/app-shell';
import { AuthLayout } from './AuthLayout';
import { followOauthAuthorize } from './followAuthorize';
Expand Down Expand Up @@ -140,10 +146,11 @@ export function RegisterPage() {
needsBootstrapProbe(authConfig, invitationRedirect),
);
const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
// #26473 — who invited this visitor, and to what; `null` keeps the generic copy.
const invitationPreview = useInvitationPreview(redirect);
// #26473 — who invited this visitor, and to what; `null` keeps the generic
// copy. #27509 — `gone` when the server no longer knows the invitation.
const invitationArrival = useInvitationArrival(redirect);
const invitationCopy = invitationRedirect
? invitationArrivalCopy('register', invitationPreview, t)
? invitationArrivalCopy('register', invitationArrival.preview, t)
: null;

// Sign-up switched off — bounce to /login.
Expand Down Expand Up @@ -235,6 +242,18 @@ export function RegisterPage() {
? withConsoleBaseRootRelative(redirect)
: undefined;

// #27509 — the server no longer knows this invitation: no form to fill.
if (invitationRedirect && invitationArrival.gone) {
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">
<InvitationGoneNotice />
</Card>
<AuthFooter privacyUrl={legalLinks.privacyUrl} termsUrl={legalLinks.termsUrl} />
</AuthLayout>
);
}

// 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.
Expand Down Expand Up @@ -290,7 +309,17 @@ export function RegisterPage() {
linkComponent={RouterLink}
// Server refusal `code` → localized end-user text: the one map both
// register pages pass, owned by `@object-ui/app-shell` (objectui#11030).
errorMessages={signUpRefusalMessages(t)}
errorMessages={signUpRefusalMessages(
t,
invitationRedirect ? { preview: invitationArrival.preview } : undefined,
)}
// #27509 — a refused invitee re-reads the preview: the invitation may
// have gone away since the page opened.
onError={(error) => {
if (invitationRedirect && (error as Error & { code?: string }).code === 'SELF_REGISTRATION_CLOSED') {
invitationArrival.recheck();
}
}}
onVerificationRequired={(email) => {
const sp = new URLSearchParams();
sp.set('email', email);
Expand Down
190 changes: 190 additions & 0 deletions ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
/**
* #27509 — an invitation link the server no longer knows, on the console's
* own `/login` and `/register`. The package's default pages carry the same
* pins in
* `packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx`.
*
* The signed-out preview door answers ONE 404 body
* (`RESOURCE_NOT_FOUND`) for an unknown, expired, accepted, rejected or
* cancelled id (ADR-0138 D2). Both pages used to read it as "no preview" and
* still offer the accept-and-register flow, whose refusal then told the link
* holder to ask for an invitation. Pinned here:
*
* - the door's 404: the "no longer valid" notice, with no sign-up prompt and
* no form, on both pages;
* - a sign-up refused with `SELF_REGISTRATION_CLOSED` from an invitation
* link re-reads the preview: an answer keeps the form and the refusal names
* the masked address the invitation was sent to; the door's 404 turns the
* page into the notice.
*
* A failed read and a server without the route keep today's generic copy;
* those pins live in `invitationPreview-26473.test.tsx` beside this file.
*
* Rendered with a real `AuthProvider` over a real `createAuthClient` and a
* real `I18nProvider`; only `fetch` is a stub. Copy is read from the en pack.
*/

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 } 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 { LoginPage } from '../LoginPage';
import { RegisterPage } from '../RegisterPage';

const AUTH_URL = 'http://localhost/api/v1/auth';
const en = builtInLocales.en.auth;
const INVITATION_ID = 'inv_27509';
const INVITE_QUERY = `?redirect=${encodeURIComponent(`/accept-invitation/${INVITATION_ID}`)}`;
const ORG = 'Default Organization';
const MASKED = 'i***@example.com';

const CONFIG: AuthPublicConfig = {
emailPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false },
features: { audiencePosture: 'invite_only' },
};

/** What the preview door answers, one entry per read; the last repeats. */
type PreviewAnswer = 'found' | 'gone';

let previewRequests: string[];
let signUpRequests: number;

function stubServer(answers: PreviewAnswer[]): typeof fetch {
previewRequests = [];
signUpRequests = 0;
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
return (async (input: string | URL | Request) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('/organization/invitation-preview')) {
const answer = answers[Math.min(previewRequests.length, answers.length - 1)];
previewRequests.push(url);
return answer === 'found'
? json({ success: true, data: { organizationName: ORG, inviterName: 'Dev Admin', email: MASKED } })
: json(
{
success: false,
error: {
code: 'RESOURCE_NOT_FOUND',
message: 'Invitation not found. It may have expired, been used, or been withdrawn.',
},
},
404,
);
}
if (url.includes('/sign-up/email')) {
signUpRequests += 1;
return json(
{ code: 'SELF_REGISTRATION_CLOSED', message: 'Self-registration is closed on this environment.' },
403,
);
}
if (url.endsWith('/bootstrap-status')) return json({ hasOwner: true });
if (url.endsWith('/config')) return json({ success: true, data: CONFIG });
return json(null);
}) as typeof fetch;
}

function renderAt(path: string, answers: PreviewAnswer[]) {
const fetchFn = stubServer(answers);
vi.stubGlobal('fetch', fetchFn);
window.history.replaceState({}, '', path);
const client = createAuthClient({ baseURL: AUTH_URL, fetchFn });
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<AuthProvider authUrl={AUTH_URL} client={client}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Routes>
</MemoryRouter>
</AuthProvider>
</I18nProvider>,
);
}

async function submitRegistration(email: string) {
const user = userEvent.setup();
await user.type(screen.getByLabelText(en.register.nameLabel), 'Invitee');
await user.type(screen.getByLabelText(en.register.emailLabel), email);
await user.type(screen.getByLabelText(en.register.passwordLabel), 'correct-horse-1');
await user.type(screen.getByLabelText(en.register.confirmPasswordLabel), 'correct-horse-1');
await user.click(screen.getByRole('button', { name: en.register.submitButton }));
}

beforeEach(() => {
window.localStorage.clear();
vi.spyOn(console, 'warn').mockImplementation(() => {});
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
vi.restoreAllMocks();
window.history.replaceState({}, '', '/');
});

describe('the door\'s 404: the invitation link is no longer valid (#27509)', () => {
it('console /login: the notice, with no sign-up prompt and no sign-in form', async () => {
renderAt(`/login${INVITE_QUERY}`, ['gone']);

expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: en.login.invitationGoneTitle })).toBeInTheDocument();
expect(screen.queryByText(en.login.invitationSignUpButton)).toBeNull();
expect(screen.queryByLabelText(en.login.emailLabel)).toBeNull();
expect(screen.queryByRole('heading', { name: en.login.invitationTitle })).toBeNull();
// The way on is the plain sign-in, not back into the invitation.
expect(screen.getByRole('link', { name: en.login.invitationGoneSignIn })).toHaveAttribute('href', '/login');
});

it('console /register: the notice, with no registration form', async () => {
renderAt(`/register${INVITE_QUERY}`, ['gone']);

expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
expect(screen.getByRole('heading', { name: en.login.invitationGoneTitle })).toBeInTheDocument();
expect(screen.queryByLabelText(en.register.emailLabel)).toBeNull();
expect(screen.queryByRole('heading', { name: en.register.invitationTitle })).toBeNull();
});

it('a live invitation keeps the prompt and the form (the control)', async () => {
renderAt(`/login${INVITE_QUERY}`, ['found']);

expect(await screen.findByRole('heading', { name: new RegExp(ORG) })).toBeInTheDocument();
expect(await screen.findByText(en.login.invitationSignUpButton)).toBeInTheDocument();
expect(screen.getByLabelText(en.login.emailLabel)).toBeInTheDocument();
expect(screen.queryByTestId('invitation-gone')).toBeNull();
});
});

describe('console /register — a refused invitation sign-up re-reads the preview (#27509)', () => {
it('still pending: keeps the form, and the refusal names the masked invited address', async () => {
renderAt(`/register${INVITE_QUERY}`, ['found', 'found']);
await screen.findByRole('heading', { name: new RegExp(ORG) });

await submitRegistration('someone@example.com');

const sentTo = en.register.errors.invitationSentTo.replace('{{email}}', MASKED);
expect(await screen.findByText(sentTo)).toBeInTheDocument();
expect(screen.queryByText(en.register.errors.selfRegistrationClosed)).toBeNull();
expect(signUpRequests).toBe(1);
await waitFor(() => expect(previewRequests).toHaveLength(2));
expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument();
});

it('gone meanwhile: the page becomes the notice', async () => {
renderAt(`/register${INVITE_QUERY}`, ['found', 'gone']);
await screen.findByRole('heading', { name: new RegExp(ORG) });

await submitRegistration('invitee2@example.com');

expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
expect(previewRequests).toHaveLength(2);
expect(screen.queryByLabelText(en.register.emailLabel)).toBeNull();
});
});
Loading
Loading