Skip to content

Commit 0561a98

Browse files
fix(console-auth): a dead invitation link reads "no longer valid", and a refused invitee is told the invited address (#27655)
domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC <!-- claude-projects-attribution: {"github_login":"os-bill"} --> _Requested by **Bill** · [project thread](https://claude.ai/code/project/chan_016iMXWSZDzqWRWvWzdNo8wg?thread=cmsg_016iMXWSZDzqWRWvWzdNo8wgP6XkfpjCfyzzG2KwQPUZfy)_ Fixes #27509 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; the new hook, notice and optional argument are on `@object-ui/app-shell`, a private package) Metadata-protocol: none ## 维护者速读 - **改了什么**:未登录访客打开一个已失效(过期、已接受、已拒绝、已撤销或不存在)的邀请链接时,控制台两套登录页和注册页不再显示「Accept your invitation … Create an account and join」和注册表单,而是显示「This invitation link is no longer valid」并给出去普通登录页的链接;邀请仍有效但用别的邮箱注册被拒时,提示改为「This invitation was sent to i***@example.com. Register with that address to accept it.」。 - **为什么改**:邀请预览接口按 ADR-0138 D2 对五种失效情况统一回 404,前端把这个 404 当成「没有预览」照常画接受与注册流程,最后用「Self-registration is not open … Ask an administrator for an invitation」拒绝手握邀请链接的人。分诊裁定只改 `ui/`,不改服务端、不加错误码、文案不说「canceled」。 - **风险与回滚**:只改前端;服务端零改动。`getInvitationPreview` 只把带 `RESOURCE_NOT_FOUND` 正文的 404 读作「失效」,没有该路由的旧服务端回的空正文 404 现在按读取失败处理,页面保留原有通用文案,不会把有效链接说成失效。回滚即 revert 本 PR。 - **先看**:`ui/packages/auth/src/createAuthClient.ts` 的 `getInvitationPreview`(404 判别那几行);`ui/packages/app-shell/src/console/auth/invitationArrival.tsx` 的 `useInvitationArrival` 与 `InvitationGoneNotice`;两个 RegisterPage 里传给 `RegisterForm` 的 `onError` 与 `signUpRefusalMessages` 第二参数。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx`,预期 10 个用例全绿。 - **需要你做的**:无,席位落地。 ## What changes The signed-out preview door answers one 404 body (`RESOURCE_NOT_FOUND`, "Invitation not found. It may have expired, been used, or been withdrawn.") for an unknown, expired, accepted, rejected or cancelled id (ADR-0138 D2). The client read every 404 as "no preview", so all four pages kept drawing the accept-and-register flow for a dead link, and the sign-up ended in the generic `SELF_REGISTRATION_CLOSED` text. Per the triage answer on the card, the fix lives wholly in `ui/`: no server file, no new error code, no ADR change, and the copy never says "canceled". - **`@object-ui/auth` `getInvitationPreview`.** Resolves `null` only for a 404 whose body carries the door's `error.code` `RESOURCE_NOT_FOUND`. A server without the route answers 404 from better-auth's router with an EMPTY body (read from better-call 1.4.4's router, which returns a null body with status 404 for an unmatched path, and from plugin-auth's `${basePath}/*` mount, which hands better-auth's 404 back verbatim when nothing downstream answers). That 404 now rejects like any other failed read, so a page never tells a valid invitation's holder their link is dead. The exported signature and the type's JSDoc contract are unchanged; the JSDoc now names that case. - **One rule, in `invitationArrival.tsx` (app-shell).** `useInvitationArrival(redirect)` returns `{ preview, gone, recheck }`; `gone` is true only after the door's not-found answer for the current id, never for a failed read. `useInvitationPreview` is now its `preview` and behaves as before. `InvitationGoneNotice` is the one card all four pages draw for a gone invitation: "This invitation link is no longer valid", "It may have expired, been used, or been withdrawn. Ask the person who invited you to send a new invitation.", and a "Go to sign in" link to the plain `/login` (no `redirect`). - **The four pages.** The console's `/login` and `/register` and app-shell's `DefaultLoginPage` and `DefaultRegisterPage` draw the notice in place of the invitation header, the "Create an account and join" prompt, and the form (and, on the console login, the dev-admin row). - **A refused invitation sign-up.** Both register pages pass `RegisterForm` an `onError` that, for `SELF_REGISTRATION_CLOSED` on an invitation redirect, re-reads the preview. The door's 404 turns the page into the notice; an answer keeps the form. `signUpRefusalMessages(t, invitation?)` takes the invitation and maps `SELF_REGISTRATION_CLOSED` to "This invitation was sent to {{email}}. Register with that address to accept it." with the masked address, or, while no preview is known, "This invitation can only be accepted with the address it was sent to. Register with that address." The same one map, not a second one. - **i18n.** Five keys in all ten packs: `auth.login.invitationGoneTitle`, `auth.login.invitationGoneDescription`, `auth.login.invitationGoneSignIn`, `auth.register.errors.invitationSentTo`, `auth.register.errors.invitationAddressOnly`. - `AcceptInvitationPage` is untouched: its signed-out branch only bounces to `/login?redirect=…`, and its signed-in branch already reads the invitation with the session and shows its own not-found text. Changeset: `@object-ui/auth`, `@object-ui/app-shell`, `@object-ui/console`, `@object-ui/i18n`, all patch. ## Tests New, one per host (the console's pages and app-shell's default pages), real `AuthProvider` over a real `createAuthClient`, only `fetch` stubbed: - `ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx` - `ui/packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx` Each pins: the door's 404 on login shows the notice with no sign-up prompt, no email field and a `/login` link; the door's 404 on register shows the notice and no form; a live invitation keeps the prompt and the form (control); a refused sign-up on a live invitation re-reads the preview (two reads, one sign-up) and the banner reads the masked-address text, not the generic one; a refused sign-up whose re-read answers 404 turns the page into the notice. Updated: the two `invitationPreview-26473` page files replace their "404 keeps the generic copy" case (it pinned this defect) with "a server without the route (empty-body 404) keeps the generic copy and the form"; the door's 404 moved to the 27509 files. `ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts` adds: an empty-body 404 rejects with `status: 404`; a 404 with another code rejects with that code. All runs below were re-taken on the merged head `4a728231` (origin/main merged once before this PR), except the ablation, which ran before the merge on the same implementation. <details><summary>Targeted runs</summary> - The five touched files (inside the run below; alone before the merge): `Test Files 5 passed (5)`, `Tests 33 passed (33)`. - `pnpm --dir ui exec vitest run apps/console/src/pages/auth/ packages/app-shell/src/console/auth/ packages/auth/ packages/i18n/ packages/app-shell/src/__tests__/product-neutral-renderer.ratchet.test.ts`: `Test Files 163 passed (163)`, `Tests 1958 passed | 13 skipped (1971)` (includes the i18n pack parity tests; 1957 of 1970 before the merge). - `type-check` for `@object-ui/auth`, `@object-ui/i18n`, `@object-ui/app-shell`, `@object-ui/console`: each `VERDICT command-exit 0`. </details> <details><summary>Ablation (scripts/ablation-replace.mjs, anchor 1 to 0, each restored to the HEAD blob with git diff HEAD empty)</summary> ui vitest resolves `@object-ui/*` to source, so no rebuild sits between mutation and run. - A1, the client's body check replaced by a bare `status === 404`: 6 of 33 red (both client 404 cases, and the four "server without the route" page cases). - A2, `gone` forced false: 6 of 33 red (the door's-404 cases on both hosts' login and register, and both "gone meanwhile" cases). - A3, `recheck` made a no-op: 4 of 33 red (both re-read cases on both hosts). - A4, the invitation arm of `signUpRefusalMessages` bypassed: 2 of 10 red (the masked-address banner on both hosts). </details> <details><summary>Derived gates — 34 of 34 exit 0 on `4a728231`</summary> `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 34 commands (identical list before and after the merge); each ran from the worktree with its exit code captured before any pipe, and `--ran` reconciled: "34 derived famil(ies) accounted for — 34 run, 0 NOT-MEASURED". Among them `node scripts/check-sdui-manifest.mjs` (and `--self-test`), `pnpm --dir ui exec vitest run scripts/`, `pnpm check:nul-bytes`, `pnpm check:published-files`, `pnpm check:type-check-coverage`, `pnpm check:type-check-debt`, `node scripts/check-empty-changeset.mjs --base origin/main` and `node scripts/check-adr-0087-registration.mjs --base origin/main`. Also `pnpm --dir ui run check:new-line-citations`: 0 new citations. The type-check lanes, the artifact-roster families and the whole-tree families are CI's; the PR's check-runs are the reading for them. </details> ## Acceptance notes - Between the page opening and the preview answering, an invitation visitor sees today's generic invitation card and form, which then turn into the notice for a gone id. Holding the whole page until the read settles would make a hanging read block sign-in, so the swap stays. - The re-read covers `SELF_REGISTRATION_CLOSED` only. Under an `email_domain` posture a mismatched address can be refused as `EMAIL_DOMAIN_NOT_ALLOWED`, which keeps its own text. - No assignee or reviewer is set on this PR: the maintainer's routing rule asks nobody before the seat's ACCEPT and review, so the harness's post-PR assign and review step was skipped on purpose. --- _Generated by [Claude Code](https://claude.ai/code/session_01Utp6CaTkEVPn2dBYY2piPZ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3de484d commit 0561a98

25 files changed

Lines changed: 758 additions & 86 deletions
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
'@object-ui/auth': patch
3+
'@object-ui/app-shell': patch
4+
'@object-ui/console': patch
5+
'@object-ui/i18n': patch
6+
---
7+
8+
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
9+
10+
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)
11+
12+
Metadata-protocol: none
13+
14+
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.
15+
16+
- **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.
17+
- **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.
18+
- **`@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.
19+
- **`@object-ui/app-shell`.** New `useInvitationArrival` (`preview`, `gone`, `recheck`) and `InvitationGoneNotice`; `useInvitationPreview` is unchanged. `signUpRefusalMessages` takes an optional invitation argument.
20+
- **`@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`.

‎ui/apps/console/src/pages/auth/LoginPage.tsx‎

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,9 @@
3333
* link — the same prompt `DefaultLoginPage` shows (#25886). The greeting
3434
* names the organization and the inviter when the signed-out invitation
3535
* preview answers, with the masked invited address as the one to use (#26473,
36-
* app-shell's `useInvitationPreview`); otherwise it keeps the generic copy.
36+
* app-shell's `useInvitationArrival`); otherwise it keeps the generic copy.
37+
* For an invitation the server no longer knows, the card holds app-shell's
38+
* `InvitationGoneNotice` alone: no prompt, no form (#27509).
3739
*
3840
* Layout (objectui#12600): one 400 px column — brand mark, the card, a footer.
3941
* The card carries this page's own two-line header, so `<LoginForm>` renders
@@ -62,9 +64,10 @@ import {
6264
isInvitationRedirect,
6365
needsBootstrapProbe,
6466
useBootstrapStatus,
67+
InvitationGoneNotice,
6568
InvitationSignUpPrompt,
6669
invitationArrivalCopy,
67-
useInvitationPreview,
70+
useInvitationArrival,
6871
} from '@object-ui/app-shell';
6972
import { AuthLayout } from './AuthLayout';
7073
import { AuthFooter, readLegalLinks } from './AuthFooter';
@@ -158,10 +161,12 @@ export function LoginPage() {
158161
hasBootstrapped && !user && needsBootstrapProbe(authConfig, invitationRedirect),
159162
);
160163
const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
161-
// #26473 — who invited this visitor, and to what; `null` keeps the generic copy.
162-
const invitationPreview = useInvitationPreview(redirect);
164+
// #26473 — who invited this visitor, and to what; `null` keeps the generic
165+
// copy. #27509 — `gone` when the server no longer knows the invitation.
166+
const invitationArrival = useInvitationArrival(redirect);
167+
const invitationGone = invitationRedirect && invitationArrival.gone;
163168
const invitationCopy = invitationRedirect
164-
? invitationArrivalCopy('login', invitationPreview, t)
169+
? invitationArrivalCopy('login', invitationArrival.preview, t)
165170
: null;
166171

167172
// Detect SSO hand-off so we can surface the relying-party host.
@@ -333,6 +338,8 @@ export function LoginPage() {
333338
retrying={configRead === 'loading'}
334339
onRetry={retryConfigRead}
335340
/>
341+
) : invitationGone ? (
342+
<InvitationGoneNotice />
336343
) : (
337344
<>
338345
<div className="mb-5">

‎ui/apps/console/src/pages/auth/RegisterPage.tsx‎

Lines changed: 34 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,11 @@
2626
* - Draws `/login`'s footer under the form — the locale switch and the
2727
* privacy / terms links the server's auth config serves, from the same
2828
* reader (#25885): sign-up is where a visitor accepts the terms.
29+
* - Opened from an invitation the server no longer knows, draws app-shell's
30+
* `InvitationGoneNotice` in place of the form; a sign-up refused with
31+
* `SELF_REGISTRATION_CLOSED` re-reads the preview, so an invitation that
32+
* went away meanwhile ends the same way and a live one names the address
33+
* it was sent to (#27509).
2934
*/
3035

3136
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
@@ -40,8 +45,9 @@ import {
4045
isInvitationRedirect,
4146
needsBootstrapProbe,
4247
useBootstrapStatus,
48+
InvitationGoneNotice,
4349
invitationArrivalCopy,
44-
useInvitationPreview,
50+
useInvitationArrival,
4551
} from '@object-ui/app-shell';
4652
import { AuthLayout } from './AuthLayout';
4753
import { followOauthAuthorize } from './followAuthorize';
@@ -140,10 +146,11 @@ export function RegisterPage() {
140146
needsBootstrapProbe(authConfig, invitationRedirect),
141147
);
142148
const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap });
143-
// #26473 — who invited this visitor, and to what; `null` keeps the generic copy.
144-
const invitationPreview = useInvitationPreview(redirect);
149+
// #26473 — who invited this visitor, and to what; `null` keeps the generic
150+
// copy. #27509 — `gone` when the server no longer knows the invitation.
151+
const invitationArrival = useInvitationArrival(redirect);
145152
const invitationCopy = invitationRedirect
146-
? invitationArrivalCopy('register', invitationPreview, t)
153+
? invitationArrivalCopy('register', invitationArrival.preview, t)
147154
: null;
148155

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

245+
// #27509 — the server no longer knows this invitation: no form to fill.
246+
if (invitationRedirect && invitationArrival.gone) {
247+
return (
248+
<AuthLayout formWidth="md">
249+
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
250+
<InvitationGoneNotice />
251+
</Card>
252+
<AuthFooter privacyUrl={legalLinks.privacyUrl} termsUrl={legalLinks.termsUrl} />
253+
</AuthLayout>
254+
);
255+
}
256+
238257
// objectui#11691 — registration here is by invitation only. Say so BEFORE
239258
// the form instead of refusing the finished form with
240259
// `SELF_REGISTRATION_CLOSED`; the sentence is that refusal's own copy.
@@ -290,7 +309,17 @@ export function RegisterPage() {
290309
linkComponent={RouterLink}
291310
// Server refusal `code` → localized end-user text: the one map both
292311
// register pages pass, owned by `@object-ui/app-shell` (objectui#11030).
293-
errorMessages={signUpRefusalMessages(t)}
312+
errorMessages={signUpRefusalMessages(
313+
t,
314+
invitationRedirect ? { preview: invitationArrival.preview } : undefined,
315+
)}
316+
// #27509 — a refused invitee re-reads the preview: the invitation may
317+
// have gone away since the page opened.
318+
onError={(error) => {
319+
if (invitationRedirect && (error as Error & { code?: string }).code === 'SELF_REGISTRATION_CLOSED') {
320+
invitationArrival.recheck();
321+
}
322+
}}
294323
onVerificationRequired={(email) => {
295324
const sp = new URLSearchParams();
296325
sp.set('email', email);
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/**
2+
* #27509 — an invitation link the server no longer knows, on the console's
3+
* own `/login` and `/register`. The package's default pages carry the same
4+
* pins in
5+
* `packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx`.
6+
*
7+
* The signed-out preview door answers ONE 404 body
8+
* (`RESOURCE_NOT_FOUND`) for an unknown, expired, accepted, rejected or
9+
* cancelled id (ADR-0138 D2). Both pages used to read it as "no preview" and
10+
* still offer the accept-and-register flow, whose refusal then told the link
11+
* holder to ask for an invitation. Pinned here:
12+
*
13+
* - the door's 404: the "no longer valid" notice, with no sign-up prompt and
14+
* no form, on both pages;
15+
* - a sign-up refused with `SELF_REGISTRATION_CLOSED` from an invitation
16+
* link re-reads the preview: an answer keeps the form and the refusal names
17+
* the masked address the invitation was sent to; the door's 404 turns the
18+
* page into the notice.
19+
*
20+
* A failed read and a server without the route keep today's generic copy;
21+
* those pins live in `invitationPreview-26473.test.tsx` beside this file.
22+
*
23+
* Rendered with a real `AuthProvider` over a real `createAuthClient` and a
24+
* real `I18nProvider`; only `fetch` is a stub. Copy is read from the en pack.
25+
*/
26+
27+
import '@testing-library/jest-dom/vitest';
28+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
29+
import { render, screen, cleanup, waitFor } from '@testing-library/react';
30+
import userEvent from '@testing-library/user-event';
31+
import { MemoryRouter, Routes, Route } from 'react-router-dom';
32+
import { I18nProvider } from '@object-ui/i18n';
33+
import { builtInLocales } from '@object-ui/i18n/locales';
34+
import { AuthProvider, createAuthClient } from '@object-ui/auth';
35+
import type { AuthPublicConfig } from '@object-ui/auth';
36+
import { LoginPage } from '../LoginPage';
37+
import { RegisterPage } from '../RegisterPage';
38+
39+
const AUTH_URL = 'http://localhost/api/v1/auth';
40+
const en = builtInLocales.en.auth;
41+
const INVITATION_ID = 'inv_27509';
42+
const INVITE_QUERY = `?redirect=${encodeURIComponent(`/accept-invitation/${INVITATION_ID}`)}`;
43+
const ORG = 'Default Organization';
44+
const MASKED = 'i***@example.com';
45+
46+
const CONFIG: AuthPublicConfig = {
47+
emailPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false },
48+
features: { audiencePosture: 'invite_only' },
49+
};
50+
51+
/** What the preview door answers, one entry per read; the last repeats. */
52+
type PreviewAnswer = 'found' | 'gone';
53+
54+
let previewRequests: string[];
55+
let signUpRequests: number;
56+
57+
function stubServer(answers: PreviewAnswer[]): typeof fetch {
58+
previewRequests = [];
59+
signUpRequests = 0;
60+
const json = (body: unknown, status = 200) =>
61+
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
62+
return (async (input: string | URL | Request) => {
63+
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
64+
if (url.includes('/organization/invitation-preview')) {
65+
const answer = answers[Math.min(previewRequests.length, answers.length - 1)];
66+
previewRequests.push(url);
67+
return answer === 'found'
68+
? json({ success: true, data: { organizationName: ORG, inviterName: 'Dev Admin', email: MASKED } })
69+
: json(
70+
{
71+
success: false,
72+
error: {
73+
code: 'RESOURCE_NOT_FOUND',
74+
message: 'Invitation not found. It may have expired, been used, or been withdrawn.',
75+
},
76+
},
77+
404,
78+
);
79+
}
80+
if (url.includes('/sign-up/email')) {
81+
signUpRequests += 1;
82+
return json(
83+
{ code: 'SELF_REGISTRATION_CLOSED', message: 'Self-registration is closed on this environment.' },
84+
403,
85+
);
86+
}
87+
if (url.endsWith('/bootstrap-status')) return json({ hasOwner: true });
88+
if (url.endsWith('/config')) return json({ success: true, data: CONFIG });
89+
return json(null);
90+
}) as typeof fetch;
91+
}
92+
93+
function renderAt(path: string, answers: PreviewAnswer[]) {
94+
const fetchFn = stubServer(answers);
95+
vi.stubGlobal('fetch', fetchFn);
96+
window.history.replaceState({}, '', path);
97+
const client = createAuthClient({ baseURL: AUTH_URL, fetchFn });
98+
return render(
99+
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
100+
<AuthProvider authUrl={AUTH_URL} client={client}>
101+
<MemoryRouter initialEntries={[path]}>
102+
<Routes>
103+
<Route path="/login" element={<LoginPage />} />
104+
<Route path="/register" element={<RegisterPage />} />
105+
</Routes>
106+
</MemoryRouter>
107+
</AuthProvider>
108+
</I18nProvider>,
109+
);
110+
}
111+
112+
async function submitRegistration(email: string) {
113+
const user = userEvent.setup();
114+
await user.type(screen.getByLabelText(en.register.nameLabel), 'Invitee');
115+
await user.type(screen.getByLabelText(en.register.emailLabel), email);
116+
await user.type(screen.getByLabelText(en.register.passwordLabel), 'correct-horse-1');
117+
await user.type(screen.getByLabelText(en.register.confirmPasswordLabel), 'correct-horse-1');
118+
await user.click(screen.getByRole('button', { name: en.register.submitButton }));
119+
}
120+
121+
beforeEach(() => {
122+
window.localStorage.clear();
123+
vi.spyOn(console, 'warn').mockImplementation(() => {});
124+
});
125+
126+
afterEach(() => {
127+
cleanup();
128+
vi.unstubAllGlobals();
129+
vi.restoreAllMocks();
130+
window.history.replaceState({}, '', '/');
131+
});
132+
133+
describe('the door\'s 404: the invitation link is no longer valid (#27509)', () => {
134+
it('console /login: the notice, with no sign-up prompt and no sign-in form', async () => {
135+
renderAt(`/login${INVITE_QUERY}`, ['gone']);
136+
137+
expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
138+
expect(screen.getByRole('heading', { name: en.login.invitationGoneTitle })).toBeInTheDocument();
139+
expect(screen.queryByText(en.login.invitationSignUpButton)).toBeNull();
140+
expect(screen.queryByLabelText(en.login.emailLabel)).toBeNull();
141+
expect(screen.queryByRole('heading', { name: en.login.invitationTitle })).toBeNull();
142+
// The way on is the plain sign-in, not back into the invitation.
143+
expect(screen.getByRole('link', { name: en.login.invitationGoneSignIn })).toHaveAttribute('href', '/login');
144+
});
145+
146+
it('console /register: the notice, with no registration form', async () => {
147+
renderAt(`/register${INVITE_QUERY}`, ['gone']);
148+
149+
expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
150+
expect(screen.getByRole('heading', { name: en.login.invitationGoneTitle })).toBeInTheDocument();
151+
expect(screen.queryByLabelText(en.register.emailLabel)).toBeNull();
152+
expect(screen.queryByRole('heading', { name: en.register.invitationTitle })).toBeNull();
153+
});
154+
155+
it('a live invitation keeps the prompt and the form (the control)', async () => {
156+
renderAt(`/login${INVITE_QUERY}`, ['found']);
157+
158+
expect(await screen.findByRole('heading', { name: new RegExp(ORG) })).toBeInTheDocument();
159+
expect(await screen.findByText(en.login.invitationSignUpButton)).toBeInTheDocument();
160+
expect(screen.getByLabelText(en.login.emailLabel)).toBeInTheDocument();
161+
expect(screen.queryByTestId('invitation-gone')).toBeNull();
162+
});
163+
});
164+
165+
describe('console /register — a refused invitation sign-up re-reads the preview (#27509)', () => {
166+
it('still pending: keeps the form, and the refusal names the masked invited address', async () => {
167+
renderAt(`/register${INVITE_QUERY}`, ['found', 'found']);
168+
await screen.findByRole('heading', { name: new RegExp(ORG) });
169+
170+
await submitRegistration('someone@example.com');
171+
172+
const sentTo = en.register.errors.invitationSentTo.replace('{{email}}', MASKED);
173+
expect(await screen.findByText(sentTo)).toBeInTheDocument();
174+
expect(screen.queryByText(en.register.errors.selfRegistrationClosed)).toBeNull();
175+
expect(signUpRequests).toBe(1);
176+
await waitFor(() => expect(previewRequests).toHaveLength(2));
177+
expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument();
178+
});
179+
180+
it('gone meanwhile: the page becomes the notice', async () => {
181+
renderAt(`/register${INVITE_QUERY}`, ['found', 'gone']);
182+
await screen.findByRole('heading', { name: new RegExp(ORG) });
183+
184+
await submitRegistration('invitee2@example.com');
185+
186+
expect(await screen.findByTestId('invitation-gone')).toBeInTheDocument();
187+
expect(previewRequests).toHaveLength(2);
188+
expect(screen.queryByLabelText(en.register.emailLabel)).toBeNull();
189+
});
190+
});

0 commit comments

Comments
 (0)