diff --git a/.changeset/27509-invitation-link-no-longer-valid.md b/.changeset/27509-invitation-link-no-longer-valid.md new file mode 100644 index 00000000000..8480723836c --- /dev/null +++ b/.changeset/27509-invitation-link-no-longer-valid.md @@ -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`. diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index 75b52a22a6b..3847f5e1074 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -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 `` renders @@ -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'; @@ -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. @@ -333,6 +338,8 @@ export function LoginPage() { retrying={configRead === 'loading'} onRetry={retryConfigRead} /> + ) : invitationGone ? ( + ) : ( <>
diff --git a/ui/apps/console/src/pages/auth/RegisterPage.tsx b/ui/apps/console/src/pages/auth/RegisterPage.tsx index cbec172ee1c..c2931038fe5 100644 --- a/ui/apps/console/src/pages/auth/RegisterPage.tsx +++ b/ui/apps/console/src/pages/auth/RegisterPage.tsx @@ -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'; @@ -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'; @@ -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. @@ -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 ( + + + + + + + ); + } + // 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. @@ -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); diff --git a/ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx b/ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx new file mode 100644 index 00000000000..3e4fdf6925a --- /dev/null +++ b/ui/apps/console/src/pages/auth/__tests__/invitationGone-27509.test.tsx @@ -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( + + + + + } /> + } /> + + + + , + ); +} + +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(); + }); +}); diff --git a/ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx b/ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx index d98a27595ce..3cbb830f2b6 100644 --- a/ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx +++ b/ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx @@ -10,8 +10,11 @@ * * - found: the card names the inviter and the organization and shows the * MASKED address as the one to use; the email field stays empty; - * - 404: today's generic invitation copy; - * - a failed read: today's generic copy too, and the form still renders. + * - a failed read, or a server without the route (a 404 that is not the + * door's): today's generic copy, and the form still renders. + * + * The door's own 404 — the invitation is gone — is #27509's, pinned in + * `invitationGone-27509.test.tsx` beside this file. * * Rendered with a real `AuthProvider` over a real `createAuthClient`; only * `fetch` is a stub. Generic copy is read from the en pack. @@ -42,7 +45,7 @@ const CONFIG: AuthPublicConfig = { features: { audiencePosture: 'invite_only' }, }; -type PreviewAnswer = 'found' | 404 | 500; +type PreviewAnswer = 'found' | 'no-route' | 500; let previewRequests: string[]; function stubServer(answer: PreviewAnswer): typeof fetch { @@ -56,14 +59,10 @@ function stubServer(answer: PreviewAnswer): typeof fetch { if (answer === 'found') { return json({ success: true, data: { organizationName: ORG, inviterName: INVITER, email: MASKED } }); } + // better-auth's router for a path nothing serves: 404, empty body. + if (answer === 'no-route') return new Response(null, { status: 404, statusText: 'Not Found' }); return json( - { - success: false, - error: - answer === 404 - ? { code: 'RESOURCE_NOT_FOUND', message: 'Invitation not found.' } - : { code: 'INTERNAL_ERROR', message: 'The invitation preview is unavailable.' }, - }, + { success: false, error: { code: 'INTERNAL_ERROR', message: 'The invitation preview is unavailable.' } }, answer, ); } @@ -123,12 +122,13 @@ describe('console /login — the invitation landing reads the signed-out preview expect(await screen.findByLabelText(en.login.emailLabel)).toHaveValue(''); }); - it('404: keeps the generic invitation copy', async () => { - renderAt(`/login${INVITE_QUERY}`, 404); + it('a server without the route: keeps the generic invitation copy and the sign-in form', async () => { + renderAt(`/login${INVITE_QUERY}`, 'no-route'); await previewAnswered(); expect(await screen.findByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); expect(screen.queryByText(new RegExp(ORG))).toBeNull(); + expect(await screen.findByLabelText(en.login.emailLabel)).toBeInTheDocument(); }); it('a failed read: keeps the generic copy and the sign-in form', async () => { @@ -150,11 +150,12 @@ describe('console /register — the sign-up step reads the same preview (#26473) expect(screen.getByLabelText(en.register.emailLabel)).toHaveValue(''); }); - it('404: keeps the generic invitation copy', async () => { - renderAt(`/register${INVITE_QUERY}`, 404); + it('a server without the route: keeps the generic invitation copy and the form', async () => { + renderAt(`/register${INVITE_QUERY}`, 'no-route'); await previewAnswered(); expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument(); }); it('a failed read: keeps the generic copy and the form', async () => { diff --git a/ui/packages/app-shell/src/console/auth/LoginPage.tsx b/ui/packages/app-shell/src/console/auth/LoginPage.tsx index 386fd30f781..925eecbe0a7 100644 --- a/ui/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/ui/packages/app-shell/src/console/auth/LoginPage.tsx @@ -21,7 +21,9 @@ * above the form in place of the small "Sign up" link (#25886). The console's * own login page does the same. The greeting names the organization and the * inviter when the signed-out invitation preview answers (#26473, - * `useInvitationPreview`), and keeps the generic copy when it does not. + * `useInvitationArrival`), and keeps the generic copy when it does not. For an + * invitation the server no longer knows it draws `InvitationGoneNotice` alone: + * no prompt and no form (#27509). */ import { useEffect, useState } from 'react'; @@ -42,9 +44,10 @@ import { signInRefusalMessages } from './signInRefusalMessages.js'; import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; import { useBootstrapStatus } from './bootstrapStatus.js'; import { + InvitationGoneNotice, InvitationSignUpPrompt, invitationArrivalCopy, - useInvitationPreview, + useInvitationArrival, } from './invitationArrival.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( @@ -126,8 +129,9 @@ export function LoginPage() { // did not come from an invitation; see `./signUpOffer`. const invitationRedirect = isInvitationRedirect(redirect); const bootstrap = useBootstrapStatus(!user && needsBootstrapProbe(authConfig, invitationRedirect)); - // #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 signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); // Carry `?redirect=` into the sign-up link: it is how `/register` knows the @@ -203,7 +207,15 @@ export function LoginPage() { if (!invitationRedirect) return {form}; - const invitationCopy = invitationArrivalCopy('login', invitationPreview, t); + if (invitationArrival.gone) { + return ( + + + + ); + } + + const invitationCopy = invitationArrivalCopy('login', invitationArrival.preview, t); return ( diff --git a/ui/packages/app-shell/src/console/auth/RegisterPage.tsx b/ui/packages/app-shell/src/console/auth/RegisterPage.tsx index 492545092bc..69af18a3544 100644 --- a/ui/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/ui/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -24,6 +24,12 @@ * same "Cannot connect to server" panel with Retry as `./LoginPage`, in place * of the form. The sign-up request goes to the same server, so a live-looking * form would only have deferred that news to the submit. + * + * Opened from an invitation the server no longer knows, it draws + * `InvitationGoneNotice` in place of the form; a sign-up the server refuses + * 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, `./invitationArrival`). */ import { useEffect, useState } from 'react'; @@ -42,7 +48,7 @@ import { signUpRefusalMessages } from './signUpRefusalMessages.js'; import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; import { useBootstrapStatus } from './bootstrapStatus.js'; import { ServerUnreachable } from './LoginPage.js'; -import { invitationArrivalCopy, useInvitationPreview } from './invitationArrival.js'; +import { InvitationGoneNotice, invitationArrivalCopy, useInvitationArrival } from './invitationArrival.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -106,8 +112,9 @@ export function RegisterPage() { const signedOut = sessionChecked && !user; const bootstrap = useBootstrapStatus(signedOut && 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); // Sign-up switched off — bounce a signed-out visitor to /login, keeping // `?redirect=`. A signed-in one is moved on below instead, never to /login. @@ -134,8 +141,18 @@ export function RegisterPage() { return {null}; } + if (invitationRedirect && invitationArrival.gone) { + return ( + + + + ); + } + const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login'; - const invitationCopy = invitationRedirect ? invitationArrivalCopy('register', invitationPreview, t) : null; + const invitationCopy = invitationRedirect + ? invitationArrivalCopy('register', invitationArrival.preview, t) + : null; // Registration here is by invitation only. Say so BEFORE the form; the // sentence is the `SELF_REGISTRATION_CLOSED` refusal's own copy, the same @@ -236,7 +253,17 @@ export function RegisterPage() { title={invitationCopy ? invitationCopy.title : t('auth.register.title')} description={invitationCopy ? invitationCopy.description : t('auth.register.description')} linkComponent={RouterLink} - 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(); + } + }} labels={{ nameLabel: t('auth.register.nameLabel'), namePlaceholder: t('auth.register.namePlaceholder'), diff --git a/ui/packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx new file mode 100644 index 00000000000..672b744d99e --- /dev/null +++ b/ui/packages/app-shell/src/console/auth/__tests__/invitationGone-27509.test.tsx @@ -0,0 +1,195 @@ +/** + * 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. + */ + +/** + * #27509 — an invitation link the server no longer knows, on + * `DefaultLoginPage` and `DefaultRegisterPage`. + * + * 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 as shipped: the exported pages, `@object-ui/auth`'s real forms, 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); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + return render( + + + + + } /> + } /> + + + + , + ); +} + +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(); +}); + +describe('the door\'s 404: the invitation link is no longer valid (#27509)', () => { + it('DefaultLoginPage: 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('DefaultRegisterPage: 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('DefaultRegisterPage — 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(); + }); +}); diff --git a/ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx index c4c2229ba3a..031413762e3 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx @@ -20,9 +20,12 @@ * - found: the card names the inviter and the organization, and shows the * masked address as the one to use, while the email field stays EMPTY — a * masked value is not an address and is never prefilled; - * - 404: today's generic invitation copy; - * - a failed read (5xx, a network failure, a body of the wrong shape): - * today's generic copy too, and the page keeps working. + * - a failed read (5xx, a network failure, a body of the wrong shape, or a + * server without the route, whose 404 is not the door's): today's generic + * copy, and the page keeps working. + * + * The door's own 404 — the invitation is gone — is #27509's, pinned in + * `invitationGone-27509.test.tsx` beside this file. * * Rendered as shipped, like `defaultPagesFollowPosture-11705.test.tsx` beside * this file: the exported pages, `@object-ui/auth`'s real forms, a real @@ -54,7 +57,7 @@ const CONFIG: AuthPublicConfig = { features: { audiencePosture: 'invite_only' }, }; -type PreviewAnswer = 'found' | 'found-no-inviter' | 404 | 500 | 'network' | 'wrong-shape'; +type PreviewAnswer = 'found' | 'found-no-inviter' | 'no-route' | 500 | 'network' | 'wrong-shape'; let previewRequests: string[]; @@ -71,11 +74,9 @@ function stubServer(answer: PreviewAnswer): typeof fetch { return json({ success: true, data: { organizationName: ORG, inviterName: INVITER, email: MASKED } }); case 'found-no-inviter': return json({ success: true, data: { organizationName: ORG, inviterName: null, email: MASKED } }); - case 404: - return json( - { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'Invitation not found.' } }, - 404, - ); + case 'no-route': + // better-auth's router for a path nothing serves: 404, empty body. + return new Response(null, { status: 404, statusText: 'Not Found' }); case 500: return json( { success: false, error: { code: 'INTERNAL_ERROR', message: 'The invitation preview is unavailable.' } }, @@ -154,16 +155,7 @@ describe('DefaultLoginPage — the invitation landing reads the signed-out previ expect(screen.queryByRole('heading', { name: en.login.invitationTitle })).toBeNull(); }); - it('404: keeps the generic invitation copy', async () => { - renderAt(`/login${INVITE_QUERY}`, 404); - await previewAnswered(); - - expect(await screen.findByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); - expect(screen.getByText(en.login.invitationDescription)).toBeInTheDocument(); - expect(screen.queryByText(new RegExp(ORG))).toBeNull(); - }); - - it.each([500, 'network', 'wrong-shape'] as const)( + it.each(['no-route', 500, 'network', 'wrong-shape'] as const)( 'a failed read (%s): keeps the generic copy and the sign-in form', async (answer) => { renderAt(`/login${INVITE_QUERY}`, answer); @@ -197,12 +189,13 @@ describe('DefaultRegisterPage — the sign-up step reads the same preview (#2647 expect(screen.getByLabelText(en.register.emailLabel)).toHaveValue(''); }); - it('404: keeps the generic invitation copy', async () => { - renderAt(`/register${INVITE_QUERY}`, 404); + it('a server without the route: keeps the generic invitation copy and the form', async () => { + renderAt(`/register${INVITE_QUERY}`, 'no-route'); await previewAnswered(); expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); expect(screen.getByText(en.register.invitationDescription)).toBeInTheDocument(); + expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument(); }); it('a failed read: keeps the generic copy and the form', async () => { diff --git a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx index e2088bfed06..9cfcab92050 100644 --- a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx +++ b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx @@ -25,9 +25,19 @@ * (`useInvitationPreview`, `GET {authUrl}/organization/invitation-preview`): * "Lin invited you to join Acme". The invited address comes back MASKED * (`z***@example.com`), so the description shows it as the address to use and - * nothing prefills it: a masked value is not an address. An id the server does - * not know (404), a failed read, or a server without the route keeps the - * generic copy (`invitationArrivalCopy`) — the preview only ever adds names. + * nothing prefills it: a masked value is not an address. A failed read, or a + * server without the route, keeps the generic copy (`invitationArrivalCopy`). + * + * The server's one not-found answer is different (#27509): ADR-0138 D2 has the + * door answer the same 404 for an unknown, expired, accepted, rejected or + * cancelled id, so the page cannot say which of those it is, but it does know + * the link no longer leads anywhere. All four pages then draw + * `InvitationGoneNotice` in place of the invitation card, the sign-up prompt + * and the form (`useInvitationArrival`'s `gone`); its copy never names one of + * the five cases. A sign-up the server refuses with `SELF_REGISTRATION_CLOSED` + * re-reads the preview (`recheck`): a 404 turns the page into the same + * notice, and an answer keeps the form with the refusal naming the masked + * address the invitation was sent to (`signUpRefusalMessages`). * * The sign-up prompt is rendered only when the page's sign-up offer is `form`, * so a server with sign-up switched off is never offered a registration it @@ -36,7 +46,7 @@ import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useAuth, type AuthInvitationPreview } from '@object-ui/auth'; +import { AuthFormHeader, AUTH_LINK_CLASS, useAuth, type AuthInvitationPreview } from '@object-ui/auth'; import { useObjectTranslation, type TranslateFn } from '@object-ui/i18n'; /** A full-width outlined link with the auth primary button's box. */ @@ -89,19 +99,41 @@ export function invitationIdOf(redirect: string | null | undefined): string | nu } } +/** What the signed-out invitation preview has said about the page's invitation. */ +export interface InvitationArrival { + /** + * The preview, or `null`: until the read answers, for a redirect that is not + * an invitation, after a failed read, and when the invitation is `gone`. + */ + preview: AuthInvitationPreview | null; + /** + * `true` once the server gave its one not-found answer for the CURRENT id + * (#27509): the link no longer leads to an invitation. Never `true` for a + * failed read or a server without the route. + */ + gone: boolean; + /** + * Read the preview again — after a sign-up the server refused, so the page + * learns whether the invitation went away meanwhile. A failed re-read keeps + * what the page already knew. + */ + recheck: () => void; +} + /** - * The signed-out invitation preview for the page's `?redirect=`, or `null`. + * The signed-out invitation preview for the page's `?redirect=`. * - * `null` until the read answers, for a redirect that is not an invitation, for - * the server's not-found answer, and after a failed read: each of those keeps - * the page's generic invitation copy, and none of them throws into the page. * Only an answer for the CURRENT id is returned, so a page whose redirect - * changes never shows the previous invitation's names. + * changes never shows the previous invitation's names or calls it gone. A + * failed read is not news for the page and never throws into it: the generic + * copy stays, and the sign-in or sign-up request reports the server for + * itself. */ -export function useInvitationPreview(redirect: string | null | undefined): AuthInvitationPreview | null { +export function useInvitationArrival(redirect: string | null | undefined): InvitationArrival { const { getInvitationPreview } = useAuth(); const invitationId = invitationIdOf(redirect); - const [answer, setAnswer] = useState<{ id: string; preview: AuthInvitationPreview } | null>(null); + const [answer, setAnswer] = useState<{ id: string; preview: AuthInvitationPreview | null } | null>(null); + const [readCount, setReadCount] = useState(0); useEffect(() => { if (!invitationId) return; let cancelled = false; @@ -109,19 +141,59 @@ export function useInvitationPreview(redirect: string | null | undefined): AuthI Promise.resolve() .then(() => getInvitationPreview(invitationId)) .then( + // `null` is the door's not-found answer: the invitation is gone. (preview) => { - if (!cancelled && preview) setAnswer({ id: invitationId, preview }); - }, - () => { - // A failed read is not news for this page: the generic copy stays, - // and the sign-in or sign-up request reports the server for itself. + if (!cancelled) setAnswer({ id: invitationId, preview }); }, + () => {}, ); return () => { cancelled = true; }; - }, [invitationId, getInvitationPreview]); - return answer && answer.id === invitationId ? answer.preview : null; + }, [invitationId, getInvitationPreview, readCount]); + const recheck = () => setReadCount((n) => n + 1); + const current = answer && answer.id === invitationId ? answer : null; + return { preview: current?.preview ?? null, gone: current !== null && current.preview === null, recheck }; +} + +/** + * The signed-out invitation preview for the page's `?redirect=`, or `null` — + * {@link useInvitationArrival}'s `preview`, for a caller that only greets. + */ +export function useInvitationPreview(redirect: string | null | undefined): AuthInvitationPreview | null { + return useInvitationArrival(redirect).preview; +} + +/** + * What every login and register page draws for an invitation the server no + * longer knows (#27509), in place of the invitation card, the sign-up prompt + * and the form: the link is no longer valid, ask for a new one, and a way on + * to the plain sign-in. The copy never says which of the door's five + * not-found cases it is, because the server does not say (ADR-0138 D2). + */ +export function InvitationGoneNotice() { + const { t } = useObjectTranslation(); + return ( +
+ +

+ + {t('auth.login.invitationGoneSignIn', { defaultValue: 'Go to sign in' })} + +

+
+ ); } /** diff --git a/ui/packages/app-shell/src/console/auth/signUpRefusalMessages.ts b/ui/packages/app-shell/src/console/auth/signUpRefusalMessages.ts index cbad25d734a..da64a083ee9 100644 --- a/ui/packages/app-shell/src/console/auth/signUpRefusalMessages.ts +++ b/ui/packages/app-shell/src/console/auth/signUpRefusalMessages.ts @@ -6,8 +6,15 @@ * LICENSE file in the root directory of this source tree. */ +import type { AuthInvitationPreview } from '@object-ui/auth'; import type { TranslateFn } from '@object-ui/i18n'; +/** The register page's invitation, when its `?redirect=` is one (#27509). */ +export interface SignUpRefusalInvitation { + /** The signed-out preview (`useInvitationArrival`), or `null` while unknown. */ + preview: AuthInvitationPreview | null; +} + /** * Sign-up refusal `code` → localized text, for `RegisterForm`'s `errorMessages`. * @@ -17,8 +24,20 @@ import type { TranslateFn } from '@object-ui/i18n'; * (objectui#10998). It is the one map both register pages pass — the console * app's and `DefaultRegisterPage` (objectui#11030). `AUTH_CONFIG_ERROR` is left * out on purpose: its message is written for the operator. + * + * `invitation` is passed by a register page opened from an invitation link + * (#27509). Under a posture closed to strangers the server admits that + * visitor only with the invited address, so its `SELF_REGISTRATION_CLOSED` + * means "not the address this invitation was sent to" (or the invitation went + * away, which the page learns by re-reading the preview), never "ask for an + * invitation": the visitor is holding one. The refusal then names the masked + * address the preview returned, or, without a preview, says that only the + * invited address can accept it. */ -export function signUpRefusalMessages(t: TranslateFn): Record { +export function signUpRefusalMessages( + t: TranslateFn, + invitation?: SignUpRefusalInvitation, +): Record { const userExists = t('auth.register.errors.userExists', { defaultValue: 'An account with this email already exists. Try signing in instead.', }); @@ -27,10 +46,21 @@ export function signUpRefusalMessages(t: TranslateFn): Record { USER_ALREADY_EXISTS: userExists, // The server's audience gate: the environment admits new accounts by // invitation only, or only from allowlisted email domains. - SELF_REGISTRATION_CLOSED: t('auth.register.errors.selfRegistrationClosed', { - defaultValue: - 'Self-registration is not open on this system. Ask an administrator for an invitation.', - }), + SELF_REGISTRATION_CLOSED: !invitation + ? t('auth.register.errors.selfRegistrationClosed', { + defaultValue: + 'Self-registration is not open on this system. Ask an administrator for an invitation.', + }) + : invitation.preview + ? t('auth.register.errors.invitationSentTo', { + defaultValue: + 'This invitation was sent to {{email}}. Register with that address to accept it.', + email: invitation.preview.email, + }) + : t('auth.register.errors.invitationAddressOnly', { + defaultValue: + 'This invitation can only be accepted with the address it was sent to. Register with that address.', + }), EMAIL_DOMAIN_NOT_ALLOWED: t('auth.register.errors.emailDomainNotAllowed', { defaultValue: "This email's domain is not allowed to register here. Use your organization email, or ask an administrator for an invitation.", diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 124b25bd7e1..1833866986c 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -264,7 +264,10 @@ export { MarketplacePackagePage } from './console/marketplace/MarketplacePackage export { LoginPage as DefaultLoginPage } from './console/auth/LoginPage.js'; export { signInRefusalMessages } from './console/auth/signInRefusalMessages.js'; export { RegisterPage as DefaultRegisterPage } from './console/auth/RegisterPage.js'; -export { signUpRefusalMessages } from './console/auth/signUpRefusalMessages.js'; +export { + signUpRefusalMessages, + type SignUpRefusalInvitation, +} from './console/auth/signUpRefusalMessages.js'; // The sign-up decision the default login/register pages above and the // console's own pages share — one rule, read off `disableSignUp` AND // `features.audiencePosture` (objectui#11691, objectui#11705). @@ -277,11 +280,16 @@ export { export { useBootstrapStatus, type BootstrapStatus } from './console/auth/bootstrapStatus.js'; // The register-first prompt both login pages show a signed-out visitor who // arrived from an invitation link (#25886), and the signed-out invitation -// preview both login and both register pages greet that visitor with (#26473). +// preview both login and both register pages greet that visitor with (#26473), +// or answer with the "no longer valid" notice when the server no longer knows +// the invitation (#27509). export { InvitationSignUpPrompt, + InvitationGoneNotice, invitationArrivalCopy, + useInvitationArrival, useInvitationPreview, + type InvitationArrival, } from './console/auth/invitationArrival.js'; export { ForgotPasswordPage as DefaultForgotPasswordPage } from './console/auth/ForgotPasswordPage.js'; export { HomeLayout as DefaultHomeLayout, HomeLayout } from './console/home/HomeLayout.js'; diff --git a/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts b/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts index 0a7049821d3..4ed71742931 100644 --- a/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts +++ b/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts @@ -8,7 +8,8 @@ * uniform 404 for every other id. The client maps those to the preview and to * `null`; every other answer is a failure and rejects, carrying the server's * `code` and the HTTP `status`, so a caller can tell "no such invitation" from - * "the read did not happen". + * "the read did not happen". A 404 without the door's `RESOURCE_NOT_FOUND` + * body — a server without the route — is the second kind (#27509). */ import { describe, it, expect, vi } from 'vitest'; @@ -56,7 +57,7 @@ describe('getInvitationPreview (#26473)', () => { }); }); - it('404: resolves null — the server does not know the id, which is an answer', async () => { + it('the door\'s 404: resolves null — the invitation is no longer valid, which is an answer', async () => { const { client } = clientAnswering(404, { success: false, error: { code: 'RESOURCE_NOT_FOUND', message: 'Invitation not found.' }, @@ -65,6 +66,22 @@ describe('getInvitationPreview (#26473)', () => { await expect(client.getInvitationPreview('inv_gone')).resolves.toBeNull(); }); + it('a 404 without the door\'s body (a server without the route) rejects instead of resolving null (#27509)', async () => { + const fetchFn = vi.fn(async () => new Response(null, { status: 404, statusText: 'Not Found' })); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + + await expect(client.getInvitationPreview('inv_1')).rejects.toMatchObject({ status: 404 }); + }); + + it('a 404 carrying another code is not the door\'s answer either (#27509)', async () => { + const { client } = clientAnswering(404, { + success: false, + error: { code: 'NOT_FOUND', message: 'Not found' }, + }); + + await expect(client.getInvitationPreview('inv_1')).rejects.toMatchObject({ code: 'NOT_FOUND', status: 404 }); + }); + it('a failed read rejects with the server code and the status', async () => { const { client } = clientAnswering(429, { success: false, diff --git a/ui/packages/auth/src/createAuthClient.ts b/ui/packages/auth/src/createAuthClient.ts index e8e424101c0..ac788c1dca9 100644 --- a/ui/packages/auth/src/createAuthClient.ts +++ b/ui/packages/auth/src/createAuthClient.ts @@ -163,6 +163,13 @@ function asAuthInvitation(raw: unknown, method: string): AuthInvitation { return raw as AuthInvitation; } +/** + * The `error.code` of the invitation-preview door's one not-found body + * (`INVITATION_PREVIEW_NOT_FOUND` in objectstack `plugin-auth` + * `invitation-preview.ts`). A 404 without it did not come from the door. + */ +const INVITATION_PREVIEW_NOT_FOUND_CODE = 'RESOURCE_NOT_FOUND'; + /** * The boundary for the signed-out invitation preview's success body, * `{ success: true, data: { organizationName, inviterName, email } }` @@ -1088,16 +1095,24 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { // objectstack's own signed-out read (#26473): better-auth's // `get-invitation` above answers 401 without a session. The server // gives one uniform 404 for an unknown, expired, accepted, rejected or - // cancelled id, which is an answer ("no preview"), not a failure. + // cancelled id (ADR-0138 D2), which is an answer — "this invitation is + // no longer valid" — not a failure. const url = `${origin}${basePath}/organization/invitation-preview?id=${encodeURIComponent(invitationId)}`; const response = await bearerFetch(url, { method: 'GET', headers: { 'Content-Type': 'application/json' }, }); - if (response.status === 404) return null; const body = (await response.json().catch(() => null)) as | { error?: { code?: string; message?: string } } | null; + // #27509 — only the door's own not-found body (`RESOURCE_NOT_FOUND`) is + // that answer. A server without the route answers 404 too, from + // better-auth's router with an empty body: that is "the read did not + // happen", and it rejects below like any other failure, so a page never + // tells a valid invitation's holder that their link is dead. + if (response.status === 404 && body?.error?.code === INVITATION_PREVIEW_NOT_FOUND_CODE) { + return null; + } if (!response.ok) { const err = new Error( body?.error?.message ?? `Invitation preview failed with status ${response.status}`, diff --git a/ui/packages/auth/src/types.ts b/ui/packages/auth/src/types.ts index d028d0b954d..f8626cc3f87 100644 --- a/ui/packages/auth/src/types.ts +++ b/ui/packages/auth/src/types.ts @@ -463,8 +463,10 @@ export interface AuthClient { /** * The signed-out preview of an invitation (organization, inviter, masked * address). Needs no session. Resolves `null` for the server's one - * not-found answer (unknown, expired, used or withdrawn id); rejects on any - * other failure. + * not-found answer (unknown, expired, used or withdrawn id) — read it as + * "this invitation is no longer valid"; rejects on any other failure, + * including a 404 that does not carry that answer's body (a server without + * the route). */ getInvitationPreview: (invitationId: string) => Promise; /** Accept an invitation as the current user */ diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index 5c0f1f6468f..d4e8d63f983 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2836,6 +2836,9 @@ const ar = { invitationPreviewTitle: "دعاك {{inviter}} للانضمام إلى {{organization}}", invitationPreviewTitleNoInviter: "أنت مدعو للانضمام إلى {{organization}}", invitationPreviewDescription: "استخدم البريد الإلكتروني الذي أُرسلت إليه الدعوة ({{email}}).", + invitationGoneTitle: "رابط الدعوة هذا لم يعد صالحًا", + invitationGoneDescription: "ربما انتهت صلاحية الدعوة أو استُخدمت أو سُحبت. اطلب من الشخص الذي دعاك إرسال دعوة جديدة.", + invitationGoneSignIn: "الانتقال إلى تسجيل الدخول", showPasswordText: "إظهار كلمة المرور", hidePasswordText: "إخفاء كلمة المرور", footer: { @@ -2869,6 +2872,8 @@ const ar = { userExists: "يوجد حساب بهذا البريد الإلكتروني بالفعل. حاول تسجيل الدخول.", selfRegistrationClosed: "التسجيل الذاتي غير متاح في هذا النظام. اطلب دعوة من المسؤول.", emailDomainNotAllowed: "نطاق هذا البريد الإلكتروني غير مسموح له بالتسجيل هنا. استخدم البريد الإلكتروني لمؤسستك أو اطلب دعوة من المسؤول.", + invitationSentTo: "أُرسلت هذه الدعوة إلى {{email}}. سجّل بهذا العنوان لقبولها.", + invitationAddressOnly: "لا يمكن قبول هذه الدعوة إلا بالعنوان الذي أُرسلت إليه. سجّل بهذا العنوان.", }, verifyInbox: { title: "تحقق من صندوق الوارد", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 70c9cc22396..22c274cfe74 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2606,6 +2606,9 @@ const de = { invitationPreviewTitle: "{{inviter}} hat Sie eingeladen, {{organization}} beizutreten", invitationPreviewTitleNoInviter: "Sie sind eingeladen, {{organization}} beizutreten", invitationPreviewDescription: "Verwenden Sie die E-Mail-Adresse, an die die Einladung gesendet wurde ({{email}}).", + invitationGoneTitle: "Dieser Einladungslink ist nicht mehr gültig", + invitationGoneDescription: "Die Einladung ist möglicherweise abgelaufen, wurde bereits verwendet oder zurückgezogen. Bitten Sie die Person, die Sie eingeladen hat, eine neue Einladung zu senden.", + invitationGoneSignIn: "Zur Anmeldung", showPasswordText: "Passwort anzeigen", hidePasswordText: "Passwort verbergen", footer: { @@ -2639,6 +2642,8 @@ const de = { userExists: "Ein Konto mit dieser E-Mail existiert bereits. Versuchen Sie sich anzumelden.", selfRegistrationClosed: "Die Selbstregistrierung ist in diesem System nicht möglich. Bitten Sie einen Administrator um eine Einladung.", emailDomainNotAllowed: "Die Domain dieser E-Mail-Adresse ist für die Registrierung hier nicht zugelassen. Verwenden Sie die E-Mail-Adresse Ihrer Organisation oder bitten Sie einen Administrator um eine Einladung.", + invitationSentTo: "Diese Einladung wurde an {{email}} gesendet. Registrieren Sie sich mit dieser Adresse, um sie anzunehmen.", + invitationAddressOnly: "Diese Einladung kann nur mit der Adresse angenommen werden, an die sie gesendet wurde. Registrieren Sie sich mit dieser Adresse.", }, verifyInbox: { title: "Überprüfen Sie Ihren Posteingang", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 9147738676f..f534b2b08a1 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3121,6 +3121,11 @@ const en = { invitationPreviewTitle: '{{inviter}} invited you to join {{organization}}', invitationPreviewTitleNoInviter: 'You are invited to join {{organization}}', invitationPreviewDescription: 'Use the email address the invitation was sent to ({{email}}).', + // #27509 — the card for an invitation link the server no longer knows: + // the server's one not-found answer cannot say why, so neither does this. + invitationGoneTitle: 'This invitation link is no longer valid', + invitationGoneDescription: 'It may have expired, been used, or been withdrawn. Ask the person who invited you to send a new invitation.', + invitationGoneSignIn: 'Go to sign in', showPasswordText: 'Show password', hidePasswordText: 'Hide password', footer: { @@ -3164,6 +3169,10 @@ const en = { userExists: 'An account with this email already exists. Try signing in instead.', selfRegistrationClosed: 'Self-registration is not open on this system. Ask an administrator for an invitation.', emailDomainNotAllowed: "This email's domain is not allowed to register here. Use your organization email, or ask an administrator for an invitation.", + // #27509 — `selfRegistrationClosed` for a visitor holding an invitation + // link; `{{email}}` is the MASKED address the preview returns. + invitationSentTo: 'This invitation was sent to {{email}}. Register with that address to accept it.', + invitationAddressOnly: 'This invitation can only be accepted with the address it was sent to. Register with that address.', }, verifyInbox: { title: 'Check your inbox', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 683eb738c17..ce79c0cf033 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2665,6 +2665,9 @@ const es = { invitationPreviewTitle: "{{inviter}} te invitó a unirte a {{organization}}", invitationPreviewTitleNoInviter: "Te invitaron a unirte a {{organization}}", invitationPreviewDescription: "Usa el correo al que se envió la invitación ({{email}}).", + invitationGoneTitle: "Este enlace de invitación ya no es válido", + invitationGoneDescription: "Es posible que haya caducado, que ya se haya usado o que se haya retirado. Pide a quien te invitó que te envíe una nueva invitación.", + invitationGoneSignIn: "Ir a iniciar sesión", showPasswordText: "Mostrar contraseña", hidePasswordText: "Ocultar contraseña", footer: { @@ -2698,6 +2701,8 @@ const es = { userExists: "Ya existe una cuenta con ese correo. Intente iniciar sesión.", selfRegistrationClosed: "El registro por cuenta propia no está abierto en este sistema. Solicite una invitación a un administrador.", emailDomainNotAllowed: "El dominio de este correo no está autorizado para registrarse aquí. Use el correo de su organización o solicite una invitación a un administrador.", + invitationSentTo: "Esta invitación se envió a {{email}}. Regístrate con esa dirección para aceptarla.", + invitationAddressOnly: "Esta invitación solo puede aceptarse con la dirección a la que se envió. Regístrate con esa dirección.", }, verifyInbox: { title: "Revise su bandeja de entrada", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index c34ee34d777..631de3b53bc 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2661,6 +2661,9 @@ const fr = { invitationPreviewTitle: "{{inviter}} vous invite à rejoindre {{organization}}", invitationPreviewTitleNoInviter: "Vous êtes invité à rejoindre {{organization}}", invitationPreviewDescription: "Utilisez l'adresse e-mail à laquelle l'invitation a été envoyée ({{email}}).", + invitationGoneTitle: "Ce lien d'invitation n'est plus valide", + invitationGoneDescription: "Il a peut-être expiré, déjà été utilisé ou été retiré. Demandez à la personne qui vous a invité de vous envoyer une nouvelle invitation.", + invitationGoneSignIn: "Aller à la connexion", showPasswordText: "Afficher le mot de passe", hidePasswordText: "Masquer le mot de passe", footer: { @@ -2694,6 +2697,8 @@ const fr = { userExists: "Un compte avec cet e-mail existe déjà. Essayez de vous connecter.", selfRegistrationClosed: "L'inscription libre n'est pas ouverte sur ce système. Demandez une invitation à un administrateur.", emailDomainNotAllowed: "Le domaine de cet e-mail n'est pas autorisé à s'inscrire ici. Utilisez l'e-mail de votre organisation ou demandez une invitation à un administrateur.", + invitationSentTo: "Cette invitation a été envoyée à {{email}}. Inscrivez-vous avec cette adresse pour l'accepter.", + invitationAddressOnly: "Cette invitation ne peut être acceptée qu'avec l'adresse à laquelle elle a été envoyée. Inscrivez-vous avec cette adresse.", }, verifyInbox: { title: "Vérifiez votre boîte de réception", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 320d0ecb0d2..b6c4f956fdc 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2608,6 +2608,9 @@ const ja = { invitationPreviewTitle: "{{inviter}} さんから {{organization}} への招待が届いています", invitationPreviewTitleNoInviter: "{{organization}} への招待が届いています", invitationPreviewDescription: "招待を受け取ったメールアドレス({{email}})をご利用ください。", + invitationGoneTitle: "この招待リンクは無効になりました", + invitationGoneDescription: "招待の期限切れ、使用済み、または取り消しの可能性があります。招待した方に新しい招待の送信を依頼してください。", + invitationGoneSignIn: "サインインへ", showPasswordText: "パスワードを表示", hidePasswordText: "パスワードを非表示", footer: { @@ -2641,6 +2644,8 @@ const ja = { userExists: "このメールアドレスのアカウントはすでに存在します。代わりにサインインしてください。", selfRegistrationClosed: "このシステムではセルフ登録を受け付けていません。管理者に招待を依頼してください。", emailDomainNotAllowed: "このメールアドレスのドメインはここでの登録が許可されていません。組織のメールアドレスを使用するか、管理者に招待を依頼してください。", + invitationSentTo: "この招待は {{email}} 宛てに送信されました。招待を承諾するには、そのアドレスで登録してください。", + invitationAddressOnly: "この招待は送信先のアドレスでのみ承諾できます。そのアドレスで登録してください。", }, verifyInbox: { title: "受信箱を確認してください", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index ed43ffd6e2d..3d7e10dfa3b 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2606,6 +2606,9 @@ const ko = { invitationPreviewTitle: "{{inviter}} 님이 {{organization}}에 초대했습니다", invitationPreviewTitleNoInviter: "{{organization}}에 초대되었습니다", invitationPreviewDescription: "초대를 받은 이메일 주소({{email}})를 사용하세요.", + invitationGoneTitle: "이 초대 링크는 더 이상 유효하지 않습니다", + invitationGoneDescription: "초대가 만료되었거나, 이미 사용되었거나, 철회되었을 수 있습니다. 초대한 사람에게 새 초대를 요청하세요.", + invitationGoneSignIn: "로그인으로 이동", showPasswordText: "비밀번호 표시", hidePasswordText: "비밀번호 숨기기", footer: { @@ -2639,6 +2642,8 @@ const ko = { userExists: "해당 이메일로 이미 계정이 존재합니다. 로그인해 보세요.", selfRegistrationClosed: "이 시스템에서는 직접 가입을 받지 않습니다. 관리자에게 초대를 요청하세요.", emailDomainNotAllowed: "이 이메일의 도메인으로는 여기에서 가입할 수 없습니다. 조직 이메일을 사용하거나 관리자에게 초대를 요청하세요.", + invitationSentTo: "이 초대는 {{email}}(으)로 전송되었습니다. 초대를 수락하려면 해당 주소로 가입하세요.", + invitationAddressOnly: "이 초대는 초대를 받은 주소로만 수락할 수 있습니다. 해당 주소로 가입하세요.", }, verifyInbox: { title: "받은 편지함을 확인하세요", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index a77d65b4be1..6242e1bd875 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2660,6 +2660,9 @@ const pt = { invitationPreviewTitle: "{{inviter}} convidou você para participar de {{organization}}", invitationPreviewTitleNoInviter: "Você foi convidado para participar de {{organization}}", invitationPreviewDescription: "Use o e-mail para o qual o convite foi enviado ({{email}}).", + invitationGoneTitle: "Este link de convite não é mais válido", + invitationGoneDescription: "Ele pode ter expirado, já ter sido usado ou ter sido retirado. Peça a quem convidou você para enviar um novo convite.", + invitationGoneSignIn: "Ir para o login", showPasswordText: "Mostrar senha", hidePasswordText: "Ocultar senha", footer: { @@ -2693,6 +2696,8 @@ const pt = { userExists: "Já existe uma conta com esse e-mail. Tente fazer login.", selfRegistrationClosed: "O cadastro por conta própria não está aberto neste sistema. Peça um convite a um administrador.", emailDomainNotAllowed: "O domínio deste e-mail não tem permissão para se cadastrar aqui. Use o e-mail da sua organização ou peça um convite a um administrador.", + invitationSentTo: "Este convite foi enviado para {{email}}. Cadastre-se com esse endereço para aceitá-lo.", + invitationAddressOnly: "Este convite só pode ser aceito com o endereço para o qual foi enviado. Cadastre-se com esse endereço.", }, verifyInbox: { title: "Verifique sua caixa de entrada", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index aac45ecd2e6..5cf09079102 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2729,6 +2729,9 @@ const ru = { invitationPreviewTitle: "{{inviter}} приглашает вас в {{organization}}", invitationPreviewTitleNoInviter: "Вас приглашают в {{organization}}", invitationPreviewDescription: "Используйте адрес электронной почты, на который пришло приглашение ({{email}}).", + invitationGoneTitle: "Эта ссылка-приглашение больше недействительна", + invitationGoneDescription: "Возможно, срок действия приглашения истёк, оно уже было использовано или отозвано. Попросите пригласившего вас человека отправить новое приглашение.", + invitationGoneSignIn: "Перейти ко входу", showPasswordText: "Показать пароль", hidePasswordText: "Скрыть пароль", footer: { @@ -2762,6 +2765,8 @@ const ru = { userExists: "Аккаунт с таким email уже существует. Попробуйте войти.", selfRegistrationClosed: "Самостоятельная регистрация в этой системе закрыта. Обратитесь к администратору за приглашением.", emailDomainNotAllowed: "Регистрация с адресами этого домена здесь не разрешена. Используйте email вашей организации или обратитесь к администратору за приглашением.", + invitationSentTo: "Это приглашение было отправлено на {{email}}. Зарегистрируйтесь с этим адресом, чтобы принять его.", + invitationAddressOnly: "Это приглашение можно принять только с адресом, на который оно было отправлено. Зарегистрируйтесь с этим адресом.", }, verifyInbox: { title: "Проверьте входящие", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index 797f41156f1..0a7321dc9e7 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2669,6 +2669,9 @@ const zh = { invitationPreviewTitle: '{{inviter}} 邀请你加入 {{organization}}', invitationPreviewTitleNoInviter: '你受邀加入 {{organization}}', invitationPreviewDescription: '请使用收到邀请的邮箱({{email}})。', + invitationGoneTitle: '此邀请链接已失效', + invitationGoneDescription: '邀请可能已过期、已被使用或已被撤回。请联系邀请你的人重新发送邀请。', + invitationGoneSignIn: '前往登录', showPasswordText: '显示密码', hidePasswordText: '隐藏密码', footer: { @@ -2702,6 +2705,8 @@ const zh = { userExists: '该邮箱已被注册,请直接登录或更换邮箱。', selfRegistrationClosed: '本系统未开放自助注册,请联系管理员获取邀请。', emailDomainNotAllowed: '该邮箱的域名不允许在此注册,请使用组织邮箱,或联系管理员获取邀请。', + invitationSentTo: '此邀请发送至 {{email}},请使用该邮箱注册以接受邀请。', + invitationAddressOnly: '此邀请只能由收到邀请的邮箱接受,请使用该邮箱注册。', }, verifyInbox: { title: '请检查您的邮箱',