From 214f11de8895fdb9c5a2ae429683174b84c4019f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:23:29 +0000 Subject: [PATCH 1/3] fix(app-shell,console): greet a signed-out invitee for the invitation and name the inviter on the accept page A visitor who opens an invitation link signed out lands on /login with ?redirect=/accept-invitation/ID. Both login pages now title the card for the invitation instead of "Welcome back", and where sign-up is offered show a full-width "Create an account and join" link above the sign-in form in place of the small "Sign up" link. Both register pages ask for the invited address. The accept page shows "Invited by" with the inviterEmail that get-invitation already returns. No organization, inviter or invited address is shown signed out: the only invitation read is session-gated (401 signed out), so that part needs a server read and is left out. Claude-Session: https://claude.ai/code/session_016msqupguN18F4BUKKWYbWY Co-authored-by: Claude --- .changeset/25886-invitation-arrival-copy.md | 16 +++ ui/apps/console/src/pages/auth/LoginPage.tsx | 32 +++-- .../console/src/pages/auth/RegisterPage.tsx | 21 +++- .../signUpFollowsPosture-11691.test.tsx | 26 +++- .../app-shell/src/console/auth/LoginPage.tsx | 36 +++++- .../src/console/auth/RegisterPage.tsx | 10 +- .../defaultPagesFollowPosture-11705.test.tsx | 19 ++- .../src/console/auth/invitationArrival.tsx | 61 ++++++++++ .../acceptInvitationInviter-25886.test.tsx | 111 ++++++++++++++++++ .../manage/AcceptInvitationPage.tsx | 16 +++ ui/packages/app-shell/src/index.ts | 3 + ui/packages/i18n/src/locales/ar.ts | 7 ++ ui/packages/i18n/src/locales/de.ts | 7 ++ ui/packages/i18n/src/locales/en.ts | 11 ++ ui/packages/i18n/src/locales/es.ts | 7 ++ ui/packages/i18n/src/locales/fr.ts | 7 ++ ui/packages/i18n/src/locales/ja.ts | 7 ++ ui/packages/i18n/src/locales/ko.ts | 7 ++ ui/packages/i18n/src/locales/pt.ts | 7 ++ ui/packages/i18n/src/locales/ru.ts | 7 ++ ui/packages/i18n/src/locales/zh.ts | 7 ++ 21 files changed, 403 insertions(+), 22 deletions(-) create mode 100644 .changeset/25886-invitation-arrival-copy.md create mode 100644 ui/packages/app-shell/src/console/auth/invitationArrival.tsx create mode 100644 ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx diff --git a/.changeset/25886-invitation-arrival-copy.md b/.changeset/25886-invitation-arrival-copy.md new file mode 100644 index 00000000000..e7762cb479b --- /dev/null +++ b/.changeset/25886-invitation-arrival-copy.md @@ -0,0 +1,16 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/console': patch +'@object-ui/i18n': patch +--- + +fix(app-shell,console): an invitee who opens the invitation link signed out is greeted for the invitation, offered "Create an account and join" first, and the accept page names the inviter + +Clause-②: no +Metadata-protocol: none + +- **Login page.** A signed-out visitor arriving at `/login?redirect=/accept-invitation/ID` used to see the returning-user card ("Welcome back · Sign in to continue your work") with a small "Sign up" link under the form. Both login pages (the console's own and `DefaultLoginPage`) now title the card "Accept your invitation · Use the email address the invitation was sent to." Where the server offers sign-up to this visitor, a full-width "Create an account and join" link (the new `InvitationSignUpPrompt` export) sits above the sign-in form in place of the small link. With sign-up switched off, the visitor gets the invitation header and the sign-in form only. +- **Register page.** From an invitation redirect, both register pages title the form "Create an account to join" and ask for the address the invitation was sent to, because only that account can accept it. +- **Accept page.** `DefaultAcceptInvitationPage` shows an "Invited by" row with the inviting member's email, which `GET /organization/get-invitation` already returns as `inviterEmail`. That route returns no inviter name. +- **Not in this change.** The signed-out pages name no organization, inviter or invited address, and do not prefill the email. The only invitation read the server serves answers a signed-out caller `401`, so a preview for signed-out visitors needs a new server read first. +- New locale keys in all ten packs: `auth.login.invitationTitle`, `auth.login.invitationDescription`, `auth.login.invitationSignUpButton`, `auth.login.invitationSignInDivider`, `auth.register.invitationTitle`, `auth.register.invitationDescription`, `organization.accept.invitedBy`. diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index 98238560ed8..373bb325b30 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -26,6 +26,12 @@ * (objectui#11806). The sign-in request goes to the same server, so a * live-looking form would only have deferred that news to the submit. * + * A visitor who arrived signed out from an invitation link + * (`?redirect=/accept-invitation/ID`) is greeted for that invitation instead + * of as a returning user, and, where sign-up is offered, gets app-shell's + * `InvitationSignUpPrompt` above the form in place of the small "Sign up" + * link — the same prompt `DefaultLoginPage` shows (#25886). + * * 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 * with `hideHeader`. On a dev instance the card's last row offers a one-click @@ -54,6 +60,7 @@ import { needsBootstrapProbe, useBootstrapStatus, LocaleSwitcher, + InvitationSignUpPrompt, } from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; @@ -304,6 +311,7 @@ export function LoginPage() { ? `/register?redirect=${encodeURIComponent(redirect)}` : '/register'; const legalLinks = readLegalLinks(authConfig); + const offerSignUp = configRead === 'known' && signUpOffer === 'form'; return ( @@ -345,18 +353,28 @@ export function LoginPage() { <>

- {t('auth.login.welcomeTitle', { defaultValue: 'Welcome back' })} + {invitationRedirect + ? t('auth.login.invitationTitle', { defaultValue: 'Accept your invitation' }) + : t('auth.login.welcomeTitle', { defaultValue: 'Welcome back' })}

- {t('auth.login.welcomeDescription', { - defaultValue: 'Sign in to continue your work', - })} + {invitationRedirect + ? t('auth.login.invitationDescription', { + defaultValue: 'Use the email address the invitation was sent to.', + }) + : t('auth.login.welcomeDescription', { + defaultValue: 'Sign in to continue your work', + })}

+ {invitationRedirect && offerSignUp ? ( +
+ +
+ ) : null} {devSeedAdmin ? : null} diff --git a/ui/apps/console/src/pages/auth/RegisterPage.tsx b/ui/apps/console/src/pages/auth/RegisterPage.tsx index 1d55ad0e912..c0894dfc8bc 100644 --- a/ui/apps/console/src/pages/auth/RegisterPage.tsx +++ b/ui/apps/console/src/pages/auth/RegisterPage.tsx @@ -257,10 +257,23 @@ export function RegisterPage() { { + it('under invite_only, still offers sign-up to an invitation redirect, carrying the redirect', async () => { renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); await loginConfigApplied(); - expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe(`/register${INVITE_QUERY}`); + // #25886 — greeted for the invitation, not as a returning user, with the + // register-first prompt in place of the small link, never both. + expect( + screen.getByRole('heading', { name: builtInLocales.en.auth.login.invitationTitle }), + ).toBeInTheDocument(); + expect( + screen.queryByRole('heading', { name: builtInLocales.en.auth.login.welcomeTitle }), + ).toBeNull(); + expect(screen.getByRole('link', INVITE_SIGN_UP).getAttribute('href')).toBe(`/register${INVITE_QUERY}`); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); expect(wire.bootstrapProbes).toBe(0); }); @@ -202,6 +213,11 @@ describe('LoginPage — the "Sign up" link follows the audience posture (objectu await loginConfigApplied(); expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + // #25886 — still greeted for the invitation, but offered no registration. + expect( + screen.getByRole('heading', { name: builtInLocales.en.auth.login.invitationTitle }), + ).toBeInTheDocument(); + expect(screen.queryByRole('link', INVITE_SIGN_UP)).toBeNull(); }); }); @@ -220,9 +236,13 @@ describe('RegisterPage — explains invitation-only registration before the form it('an invitation redirect reaches a working registration under invite_only, from /login on', async () => { renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); await loginConfigApplied(); - await userEvent.click(screen.getByRole('link', SIGN_UP_LINK)); + await userEvent.click(screen.getByRole('link', INVITE_SIGN_UP)); await userEvent.type(await screen.findByLabelText('Name'), 'Ada'); + // #25886 — the form asks for the invited address. + expect( + screen.getByRole('heading', { name: builtInLocales.en.auth.register.invitationTitle }), + ).toBeInTheDocument(); await userEvent.type(screen.getByLabelText('Email'), 'ada@example.com'); await userEvent.type(screen.getByLabelText('Password'), 'hunter2hunter2'); await userEvent.type(screen.getByLabelText('Confirm Password'), 'hunter2hunter2'); diff --git a/ui/packages/app-shell/src/console/auth/LoginPage.tsx b/ui/packages/app-shell/src/console/auth/LoginPage.tsx index ebb7e268d18..f7e43f633c2 100644 --- a/ui/packages/app-shell/src/console/auth/LoginPage.tsx +++ b/ui/packages/app-shell/src/console/auth/LoginPage.tsx @@ -14,6 +14,12 @@ * Retry that reads it again, in place of the form (objectui#11806). The * sign-in request goes to the same server, so a live-looking form would only * have deferred that news to the submit. + * + * A visitor who arrived from an invitation link (`?redirect=/accept-invitation/ID`) + * is greeted for that invitation instead of as a returning user, and, where + * sign-up is offered, gets `InvitationSignUpPrompt` (`./invitationArrival`) + * above the form in place of the small "Sign up" link (#25886). The console's + * own login page does the same. */ import { useEffect, useState } from 'react'; @@ -33,6 +39,7 @@ import { AuthPageLayout } from './AuthPageLayout.js'; import { signInRefusalMessages } from './signInRefusalMessages.js'; import { decideSignUpOffer, isInvitationRedirect, needsBootstrapProbe } from './signUpOffer.js'; import { useBootstrapStatus } from './bootstrapStatus.js'; +import { InvitationSignUpPrompt } from './invitationArrival.js'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -121,6 +128,11 @@ export function LoginPage() { ? `/register?redirect=${encodeURIComponent(redirect)}` : '/register'; + const offerSignUp = configRead === 'known' && signUpOffer === 'form'; + // #25886 — an invitee is greeted for the invitation, and offered the + // register-first prompt in place of `LoginForm`'s own small link. + const invitationSignUpUrl = invitationRedirect && offerSignUp ? registerUrl : undefined; + if (serverUnreachable) { return ( @@ -129,14 +141,15 @@ export function LoginPage() { ); } - return ( - + const form = ( navigate('/')} - registerUrl={configRead === 'known' && signUpOffer === 'form' ? registerUrl : undefined} + registerUrl={offerSignUp && !invitationRedirect ? registerUrl : undefined} forgotPasswordUrl="/forgot-password" title={t('auth.login.title')} description={t('auth.login.description')} + // An invitee's header is drawn above the prompt, below. + hideHeader={invitationRedirect} linkComponent={RouterLink} errorMessages={signInRefusalMessages(t)} labels={{ @@ -178,6 +191,23 @@ export function LoginPage() { backToSsoText: t('auth.login.backToSsoText'), }} /> + ); + + if (!invitationRedirect) return {form}; + + return ( + +
+ + {invitationSignUpUrl ? : null} + {form} +
); } diff --git a/ui/packages/app-shell/src/console/auth/RegisterPage.tsx b/ui/packages/app-shell/src/console/auth/RegisterPage.tsx index 96ca820c27c..3f39a89b1bb 100644 --- a/ui/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/ui/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -225,8 +225,14 @@ export function RegisterPage() { onSuccess={() => navigate('/')} onVerificationRequired={(email) => setPendingEmail(email)} loginUrl={loginUrl} - title={t('auth.register.title')} - description={t('auth.register.description')} + // #25886 — an invitee is told to register the invited address: the + // invitation is accepted only by the account that carries it. + title={invitationRedirect ? t('auth.register.invitationTitle') : t('auth.register.title')} + description={ + invitationRedirect + ? t('auth.register.invitationDescription') + : t('auth.register.description') + } linkComponent={RouterLink} errorMessages={signUpRefusalMessages(t)} labels={{ diff --git a/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx b/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx index 9a758689f91..ae3419bb1e8 100644 --- a/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx +++ b/ui/packages/app-shell/src/console/auth/__tests__/defaultPagesFollowPosture-11705.test.tsx @@ -51,6 +51,8 @@ import { RegisterPage } from '../RegisterPage'; const AUTH_URL = 'http://localhost/api/v1/auth'; const en = builtInLocales.en.auth; const SIGN_UP_LINK = { name: en.login.signUpText } as const; +/** #25886 — an invitee's sign-up is the register-first prompt, not the small link. */ +const INVITE_SIGN_UP = { name: en.login.invitationSignUpButton } as const; const CREATE_ACCOUNT = { name: en.register.submitButton } as const; const INVITATION = '/accept-invitation/inv_1'; const INVITE_QUERY = `?redirect=${encodeURIComponent(INVITATION)}`; @@ -167,11 +169,16 @@ describe('DefaultLoginPage — the "Sign up" link follows the audience posture ( expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); }); - it('under invite_only, still offers "Sign up" to an invitation redirect, carrying the redirect', async () => { + it('under invite_only, still offers sign-up to an invitation redirect, carrying the redirect', async () => { renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); await loginConfigApplied(); - expect(screen.getByRole('link', SIGN_UP_LINK).getAttribute('href')).toBe(`/register${INVITE_QUERY}`); + // #25886 — greeted for the invitation, with the register-first prompt in + // place of the small link, never both. + expect(screen.getByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: en.login.title })).toBeNull(); + expect(screen.getByRole('link', INVITE_SIGN_UP).getAttribute('href')).toBe(`/register${INVITE_QUERY}`); + expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); expect(wire.bootstrapProbes).toBe(0); }); @@ -207,6 +214,9 @@ describe('DefaultLoginPage — the "Sign up" link follows the audience posture ( await loginConfigApplied(); expect(screen.queryByRole('link', SIGN_UP_LINK)).toBeNull(); + // #25886 — still greeted for the invitation, but offered no registration. + expect(screen.getByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); + expect(screen.queryByRole('link', INVITE_SIGN_UP)).toBeNull(); expect(wire.bootstrapProbes).toBe(0); }); }); @@ -227,9 +237,12 @@ describe('DefaultRegisterPage — explains invitation-only registration before t it('an invitation redirect reaches a working registration under invite_only, from /login on', async () => { renderAt(`/login${INVITE_QUERY}`, configFor('invite_only')); await loginConfigApplied(); - await userEvent.click(screen.getByRole('link', SIGN_UP_LINK)); + await userEvent.click(screen.getByRole('link', INVITE_SIGN_UP)); await userEvent.type(await screen.findByLabelText(en.register.nameLabel), 'Ada'); + // #25886 — the form asks for the invited address. + expect(screen.getByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + expect(screen.getByText(en.register.invitationDescription)).toBeInTheDocument(); await userEvent.type(screen.getByLabelText(en.register.emailLabel), 'ada@example.com'); await userEvent.type(screen.getByLabelText(en.register.passwordLabel), 'hunter2hunter2'); await userEvent.type(screen.getByLabelText(en.register.confirmPasswordLabel), 'hunter2hunter2'); diff --git a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx new file mode 100644 index 00000000000..d659d671e34 --- /dev/null +++ b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx @@ -0,0 +1,61 @@ +/** + * 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. + */ + +/** + * invitationArrival — the register-first action a login page shows a visitor + * who arrived signed out from an invitation link (#25886). + * + * `DefaultAcceptInvitationPage` bounces a signed-out visitor to + * `/login?redirect=/accept-invitation/ID` (`isInvitationRedirect` in + * `./signUpOffer`). Most invitees have never had an account here, so for them + * the generic "Welcome back" card with a small "Sign up" link under the form + * read as the wrong site. Both login pages — the console's own + * (`apps/console/src/pages/auth/LoginPage.tsx`) and this package's + * `DefaultLoginPage` — retitle the card for that visitor and render this + * prompt above the form in place of the small link, so there is ONE prompt and + * no second copy of it. + * + * The prompt names no organization and no inviter: the only invitation read + * the server serves (`GET /organization/get-invitation`) answers a signed-out + * caller `401`, and an anonymous preview is a server decision this package + * does not make. It 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 + * refuses. + */ + +import { Link } from 'react-router-dom'; +import { useObjectTranslation } from '@object-ui/i18n'; + +/** A full-width outlined link with the auth primary button's box. */ +const INVITATION_SIGN_UP_CLASS = + 'inline-flex h-10 w-full items-center justify-center gap-2 rounded-lg border border-primary/40 bg-primary/5 px-4 py-2 text-sm font-medium text-primary shadow-sm transition-colors hover:bg-primary/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40 focus-visible:ring-offset-2'; + +/** + * "Create an account and join", then a divider that hands over to the sign-in + * form below it. `registerUrl` is the page's own `/register?redirect=…`, so the + * invitation survives the hop. + */ +export function InvitationSignUpPrompt({ registerUrl }: { registerUrl: string }) { + const { t } = useObjectTranslation(); + return ( +
+ + {t('auth.login.invitationSignUpButton', { defaultValue: 'Create an account and join' })} + +
+
+
+ ); +} diff --git a/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx b/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx new file mode 100644 index 00000000000..f634967b89e --- /dev/null +++ b/ui/packages/app-shell/src/console/organizations/__tests__/acceptInvitationInviter-25886.test.tsx @@ -0,0 +1,111 @@ +/** + * 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. + */ + +/** + * #25886 — the accept page names who sent the invitation. + * + * better-auth's `GET /organization/get-invitation` answers with the inviting + * member's email (`inviterEmail`) beside the organization snapshot; it returns + * no inviter name. The page shows that address under the translated + * `organization.accept.invitedBy` label, and draws no row for an answer that + * carries none. + */ + +import '@testing-library/jest-dom/vitest'; +import * as React from 'react'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import { render, screen, cleanup } from '@testing-library/react'; +import { I18nProvider } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; + +const INVITATION = { + id: 'inv-1', + organizationId: 'org-42', + organizationName: 'Acme', + email: 'ada@example.com', + role: 'member', + status: 'pending', + expiresAt: '2099-03-04T12:00:00.000Z', + inviterId: 'u0', + inviterEmail: 'grace@example.com', +}; + +const answer: { current: Record } = { current: INVITATION }; + +// ⚠️ STABLE identities: the page keys its fetch effect on `getInvitation`, so +// a mock that minted a new function per render would loop the effect. +vi.mock('@object-ui/auth', async (importActual) => { + const auth = { + isAuthenticated: true, + isLoading: false, + getInvitation: async () => answer.current, + acceptInvitation: async () => undefined, + rejectInvitation: async () => undefined, + switchOrganization: async () => undefined, + }; + return { + ...(await importActual()), + useAuth: () => auth, + }; +}); + +vi.mock('react-router-dom', async (importActual) => { + const navigate = () => {}; + const location = { pathname: '/accept-invitation/inv-1', search: '' }; + const params = { invitationId: 'inv-1' }; + return { + ...(await importActual()), + useNavigate: () => navigate, + useLocation: () => location, + useParams: () => params, + }; +}); + +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { AcceptInvitationPage } from '../manage/AcceptInvitationPage'; + +afterEach(() => { + cleanup(); + answer.current = INVITATION; +}); + +function renderIn(language: 'en' | 'zh'): void { + render( + + + , + ); +} + +describe('AcceptInvitationPage — names the inviter (#25886)', () => { + it('shows the inviting member under the "Invited by" label', async () => { + renderIn('en'); + + const inviter = await screen.findByTestId('accept-invitation-inviter'); + expect(inviter).toHaveTextContent('grace@example.com'); + expect(screen.getByText(builtInLocales.en.organization.accept.invitedBy)).toBeInTheDocument(); + }); + + it('reads the label from the session language', async () => { + renderIn('zh'); + + await screen.findByTestId('accept-invitation-inviter'); + expect(screen.getByText(builtInLocales.zh.organization.accept.invitedBy)).toBeInTheDocument(); + }); + + it('draws no inviter row when the answer carries no inviter address', async () => { + const { inviterEmail: _omitted, ...withoutInviter } = INVITATION; + answer.current = withoutInviter; + renderIn('en'); + + await screen.findByTestId('accept-invitation-role'); + expect(screen.queryByTestId('accept-invitation-inviter')).toBeNull(); + expect(screen.queryByText(builtInLocales.en.organization.accept.invitedBy)).toBeNull(); + }); +}); diff --git a/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx b/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx index d95cf2ffed0..28919dd0d12 100644 --- a/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx +++ b/ui/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx @@ -24,6 +24,12 @@ import { resolveRootUrl } from '../resolveHomeUrl.js'; type InvitationWithOrg = AuthInvitation & { organizationName?: string; organizationSlug?: string; + /** + * The inviting member's email, which better-auth's `get-invitation` adds to + * its answer. The route returns no inviter name, so the page names the + * inviter by this address (#25886). + */ + inviterEmail?: string; }; export function AcceptInvitationPage() { @@ -225,6 +231,16 @@ export function AcceptInvitationPage() { {resolveOrgRoleLabel(invitation.role, t)} + {invitation.inviterEmail && ( +
+ + {t('organization.accept.invitedBy', { defaultValue: 'Invited by' })} + + + {invitation.inviterEmail} + +
+ )} {invitation.expiresAt && (
diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index c0e26384e2d..807d46d84c4 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -296,6 +296,9 @@ export { type SignUpOfferContext, } from './console/auth/signUpOffer.js'; 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). +export { InvitationSignUpPrompt } from './console/auth/invitationArrival.js'; export { ForgotPasswordPage as DefaultForgotPasswordPage } from './console/auth/ForgotPasswordPage.js'; export { HomeLayout as DefaultHomeLayout, HomeLayout } from './console/home/HomeLayout.js'; export { HomePage as DefaultHomePage, HomePage } from './console/home/HomePage.js'; diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index ac057c1c960..c8b844101b9 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2763,6 +2763,10 @@ const ar = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "مرحبًا بعودتك", welcomeDescription: "سجّل الدخول لمتابعة عملك", + invitationTitle: "اقبل دعوتك", + invitationDescription: "استخدم البريد الإلكتروني الذي أُرسلت إليه الدعوة.", + invitationSignUpButton: "إنشاء حساب والانضمام", + invitationSignInDivider: "أو سجّل الدخول بحساب موجود", showPasswordText: "إظهار كلمة المرور", hidePasswordText: "إخفاء كلمة المرور", footer: { @@ -2773,6 +2777,8 @@ const ar = { register: { title: "إنشاء حساب", description: "أدخل معلوماتك للبدء", + invitationTitle: "أنشئ حسابًا للانضمام", + invitationDescription: "سجّل بالبريد الإلكتروني الذي أُرسلت إليه الدعوة، فلا يمكن قبولها إلا بهذا العنوان.", nameLabel: "الاسم", namePlaceholder: "محمد أحمد", emailLabel: "البريد الإلكتروني", @@ -3599,6 +3605,7 @@ const ar = { organization: "المؤسسة", role: "الدور", expiresAt: "تنتهي في", + invitedBy: "الدعوة من", accept: "قبول الدعوة", accepted: "تم قبول الدعوة", acceptFailed: "فشل قبول الدعوة", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index 2dca8512ae5..a010e84161c 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2561,6 +2561,10 @@ const de = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "Willkommen zurück", welcomeDescription: "Melden Sie sich an, um weiterzuarbeiten", + invitationTitle: "Einladung annehmen", + invitationDescription: "Verwenden Sie die E-Mail-Adresse, an die die Einladung gesendet wurde.", + invitationSignUpButton: "Konto erstellen und beitreten", + invitationSignInDivider: "oder mit einem bestehenden Konto anmelden", showPasswordText: "Passwort anzeigen", hidePasswordText: "Passwort verbergen", footer: { @@ -2571,6 +2575,8 @@ const de = { register: { title: "Konto erstellen", description: "Geben Sie Ihre Daten ein, um loszulegen", + invitationTitle: "Konto erstellen, um beizutreten", + invitationDescription: "Registrieren Sie sich mit der E-Mail-Adresse, an die die Einladung gesendet wurde. Nur mit dieser Adresse kann sie angenommen werden.", nameLabel: "Name", namePlaceholder: "Max Mustermann", emailLabel: "E-Mail", @@ -3370,6 +3376,7 @@ const de = { organization: "Organisation", role: "Rolle", expiresAt: "Läuft ab am", + invitedBy: "Eingeladen von", accept: "Einladung annehmen", accepted: "Einladung angenommen", acceptFailed: "Einladung konnte nicht angenommen werden", diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index ef2ecfa2f7c..131966cb494 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3058,6 +3058,14 @@ const en = { // field's reveal toggle, and the footer's legal links. welcomeTitle: 'Welcome back', welcomeDescription: 'Sign in to continue your work', + // The same card's header when the visitor arrived signed out from an + // invitation link (`?redirect=/accept-invitation/ID`), and the + // register-first action above the form. The copy names no organization + // or inviter: a signed-out page has no read of the invitation (#25886). + invitationTitle: 'Accept your invitation', + invitationDescription: 'Use the email address the invitation was sent to.', + invitationSignUpButton: 'Create an account and join', + invitationSignInDivider: 'or sign in to an existing account', showPasswordText: 'Show password', hidePasswordText: 'Hide password', footer: { @@ -3077,6 +3085,8 @@ const en = { register: { title: 'Create an account', description: 'Create your account to start building.', + invitationTitle: 'Create an account to join', + invitationDescription: 'Register with the email address the invitation was sent to. The invitation can only be accepted with that address.', nameLabel: 'Name', namePlaceholder: 'John Doe', emailLabel: 'Email', @@ -4073,6 +4083,7 @@ const en = { organization: 'Organization', role: 'Role', expiresAt: 'Expires', + invitedBy: 'Invited by', accept: 'Accept invitation', accepted: 'Invitation accepted', acceptFailed: 'Failed to accept invitation', diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 464ece759f5..4daee6f8749 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2613,6 +2613,10 @@ const es = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "Bienvenido de nuevo", welcomeDescription: "Inicia sesión para continuar con tu trabajo", + invitationTitle: "Acepta tu invitación", + invitationDescription: "Usa el correo al que se envió la invitación.", + invitationSignUpButton: "Crear una cuenta y unirme", + invitationSignInDivider: "o inicia sesión con una cuenta existente", showPasswordText: "Mostrar contraseña", hidePasswordText: "Ocultar contraseña", footer: { @@ -2623,6 +2627,8 @@ const es = { register: { title: "Crear una cuenta", description: "Ingresa tu información para comenzar", + invitationTitle: "Crea una cuenta para unirte", + invitationDescription: "Regístrate con el correo al que se envió la invitación. Solo se puede aceptar con esa dirección.", nameLabel: "Nombre", namePlaceholder: "Juan Pérez", emailLabel: "Correo electrónico", @@ -3428,6 +3434,7 @@ const es = { organization: "Organización", role: "Rol", expiresAt: "Caduca", + invitedBy: "Invitado por", accept: "Aceptar invitación", accepted: "Invitación aceptada", acceptFailed: "No se pudo aceptar la invitación", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 16c2d6bef70..d48bbfe3197 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2609,6 +2609,10 @@ const fr = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "Bon retour", welcomeDescription: "Connectez-vous pour reprendre votre travail", + invitationTitle: "Acceptez votre invitation", + invitationDescription: "Utilisez l'adresse e-mail à laquelle l'invitation a été envoyée.", + invitationSignUpButton: "Créer un compte et rejoindre", + invitationSignInDivider: "ou connectez-vous à un compte existant", showPasswordText: "Afficher le mot de passe", hidePasswordText: "Masquer le mot de passe", footer: { @@ -2619,6 +2623,8 @@ const fr = { register: { title: "Créer un compte", description: "Entrez vos informations pour commencer", + invitationTitle: "Créez un compte pour rejoindre", + invitationDescription: "Inscrivez-vous avec l'adresse e-mail à laquelle l'invitation a été envoyée. Elle ne peut être acceptée qu'avec cette adresse.", nameLabel: "Nom", namePlaceholder: "Jean Dupont", emailLabel: "E-mail", @@ -3424,6 +3430,7 @@ const fr = { organization: "Organisation", role: "Rôle", expiresAt: "Expire le", + invitedBy: "Invité par", accept: "Accepter l'invitation", accepted: "Invitation acceptée", acceptFailed: "Impossible d'accepter l'invitation", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index ae922186d1a..f6b28fc2389 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2563,6 +2563,10 @@ const ja = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "おかえりなさい", welcomeDescription: "サインインして作業を続けましょう", + invitationTitle: "招待を承諾", + invitationDescription: "招待を受け取ったメールアドレスをご利用ください。", + invitationSignUpButton: "アカウントを作成して参加", + invitationSignInDivider: "または既存のアカウントでサインイン", showPasswordText: "パスワードを表示", hidePasswordText: "パスワードを非表示", footer: { @@ -2573,6 +2577,8 @@ const ja = { register: { title: "アカウントを作成", description: "情報を入力して始めましょう", + invitationTitle: "アカウントを作成して参加する", + invitationDescription: "招待を受け取ったメールアドレスで登録してください。招待はそのアドレスでのみ承諾できます。", nameLabel: "名前", namePlaceholder: "田中太郎", emailLabel: "メールアドレス", @@ -3372,6 +3378,7 @@ const ja = { organization: "組織", role: "ロール", expiresAt: "有効期限", + invitedBy: "招待者", accept: "招待を承諾", accepted: "招待を承諾しました", acceptFailed: "招待の承諾に失敗しました", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 4d2e691ec58..afb9139c654 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2561,6 +2561,10 @@ const ko = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "다시 오신 것을 환영합니다", welcomeDescription: "로그인하고 작업을 계속하세요", + invitationTitle: "초대 수락", + invitationDescription: "초대를 받은 이메일 주소를 사용하세요.", + invitationSignUpButton: "계정 만들고 참여하기", + invitationSignInDivider: "또는 기존 계정으로 로그인", showPasswordText: "비밀번호 표시", hidePasswordText: "비밀번호 숨기기", footer: { @@ -2571,6 +2575,8 @@ const ko = { register: { title: "계정 만들기", description: "정보를 입력하여 시작하세요", + invitationTitle: "계정을 만들고 참여하기", + invitationDescription: "초대를 받은 이메일 주소로 가입하세요. 초대는 해당 주소로만 수락할 수 있습니다.", nameLabel: "이름", namePlaceholder: "홍길동", emailLabel: "이메일", @@ -3369,6 +3375,7 @@ const ko = { organization: "조직", role: "역할", expiresAt: "만료", + invitedBy: "초대한 사람", accept: "초대 수락", accepted: "초대를 수락함", acceptFailed: "초대 수락 실패", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 6947902e67b..eceb4acd3b5 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2608,6 +2608,10 @@ const pt = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "Bem-vindo de volta", welcomeDescription: "Entre para continuar seu trabalho", + invitationTitle: "Aceite seu convite", + invitationDescription: "Use o e-mail para o qual o convite foi enviado.", + invitationSignUpButton: "Criar conta e participar", + invitationSignInDivider: "ou entre com uma conta existente", showPasswordText: "Mostrar senha", hidePasswordText: "Ocultar senha", footer: { @@ -2618,6 +2622,8 @@ const pt = { register: { title: "Criar uma conta", description: "Digite suas informações para começar", + invitationTitle: "Crie uma conta para participar", + invitationDescription: "Cadastre-se com o e-mail para o qual o convite foi enviado. Ele só pode ser aceito com esse endereço.", nameLabel: "Nome", namePlaceholder: "João Silva", emailLabel: "E-mail", @@ -3423,6 +3429,7 @@ const pt = { organization: "Organização", role: "Função", expiresAt: "Expira em", + invitedBy: "Convidado por", accept: "Aceitar convite", accepted: "Convite aceito", acceptFailed: "Falha ao aceitar o convite", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index bd5316c6919..d5b2cfd72ab 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2670,6 +2670,10 @@ const ru = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "С возвращением", welcomeDescription: "Войдите, чтобы продолжить работу", + invitationTitle: "Примите приглашение", + invitationDescription: "Используйте адрес электронной почты, на который пришло приглашение.", + invitationSignUpButton: "Создать учётную запись и присоединиться", + invitationSignInDivider: "или войдите в существующую учётную запись", showPasswordText: "Показать пароль", hidePasswordText: "Скрыть пароль", footer: { @@ -2680,6 +2684,8 @@ const ru = { register: { title: "Создать аккаунт", description: "Введите свои данные, чтобы начать", + invitationTitle: "Создайте учётную запись, чтобы присоединиться", + invitationDescription: "Зарегистрируйтесь с адресом, на который пришло приглашение. Принять его можно только с этим адресом.", nameLabel: "Имя", namePlaceholder: "Иван Иванов", emailLabel: "Электронная почта", @@ -3495,6 +3501,7 @@ const ru = { organization: "Организация", role: "Роль", expiresAt: "Истекает", + invitedBy: "Приглашение от", accept: "Принять приглашение", accepted: "Приглашение принято", acceptFailed: "Не удалось принять приглашение", diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e7253201c8d..28e4ad26b34 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2625,6 +2625,10 @@ const zh = { // field's reveal toggle, and the footer's legal links. welcomeTitle: '欢迎回来', welcomeDescription: '登录后继续你的工作', + invitationTitle: '接受邀请', + invitationDescription: '请使用收到邀请的邮箱。', + invitationSignUpButton: '注册并加入', + invitationSignInDivider: '或登录已有账号', showPasswordText: '显示密码', hidePasswordText: '隐藏密码', footer: { @@ -2635,6 +2639,8 @@ const zh = { register: { title: '创建账户', description: '创建账号,开始搭建。', + invitationTitle: '注册账号并加入', + invitationDescription: '请使用收到邀请的邮箱注册,邀请只能由该邮箱接受。', nameLabel: '姓名', namePlaceholder: '张三', emailLabel: '邮箱', @@ -3550,6 +3556,7 @@ const zh = { organization: '组织', role: '角色', expiresAt: '过期时间', + invitedBy: '邀请人', accept: '接受邀请', accepted: '邀请已接受', acceptFailed: '接受邀请失败', From baa3ba945889def09faf0212d8a5276451c4b753 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 09:39:28 +0000 Subject: [PATCH 2/3] chore(sdui): re-record the manifest's registry hash for the invitation-arrival change Claude-Session: https://claude.ai/code/session_016msqupguN18F4BUKKWYbWY Co-authored-by: Claude --- scripts/sdui-manifest.record.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/sdui-manifest.record.json b/scripts/sdui-manifest.record.json index f65f470a705..d9a07499712 100644 --- a/scripts/sdui-manifest.record.json +++ b/scripts/sdui-manifest.record.json @@ -49,6 +49,6 @@ "ui/packages/types" ], "registrySources": 922, - "registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063" + "registrySourcesSha256": "40f7b3974c5d267fd653d8150160cd49287e218bbc394064e673ca4748b148c2" } } From 5a7ff8392dfb9c5a7fa83aab58f569bf0134391f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 26 Oct 2026 10:10:42 +0000 Subject: [PATCH 3/3] =?UTF-8?q?chore(changeset):=20declare=20Clause-?= =?UTF-8?q?=E2=91=A1=20yes=20for=20the=20new=20InvitationSignUpPrompt=20ex?= =?UTF-8?q?port?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prompt is a new root export of the published @object-ui/app-shell, so the change widens a public surface. Claude-Session: https://claude.ai/code/session_016msqupguN18F4BUKKWYbWY Co-authored-by: Claude --- .changeset/25886-invitation-arrival-copy.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/25886-invitation-arrival-copy.md b/.changeset/25886-invitation-arrival-copy.md index e7762cb479b..7d5e3b382c0 100644 --- a/.changeset/25886-invitation-arrival-copy.md +++ b/.changeset/25886-invitation-arrival-copy.md @@ -6,7 +6,7 @@ fix(app-shell,console): an invitee who opens the invitation link signed out is greeted for the invitation, offered "Create an account and join" first, and the accept page names the inviter -Clause-②: no +Clause-②: yes Metadata-protocol: none - **Login page.** A signed-out visitor arriving at `/login?redirect=/accept-invitation/ID` used to see the returning-user card ("Welcome back · Sign in to continue your work") with a small "Sign up" link under the form. Both login pages (the console's own and `DefaultLoginPage`) now title the card "Accept your invitation · Use the email address the invitation was sent to." Where the server offers sign-up to this visitor, a full-width "Create an account and join" link (the new `InvitationSignUpPrompt` export) sits above the sign-in form in place of the small link. With sign-up switched off, the visitor gets the invitation header and the sign-in form only.