diff --git a/.changeset/25886-invitation-arrival-copy.md b/.changeset/25886-invitation-arrival-copy.md new file mode 100644 index 00000000000..7d5e3b382c0 --- /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-②: 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. +- **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/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" } } 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 891cfe2b3cf..076bb04be93 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2769,6 +2769,10 @@ const ar = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "مرحبًا بعودتك", welcomeDescription: "سجّل الدخول لمتابعة عملك", + invitationTitle: "اقبل دعوتك", + invitationDescription: "استخدم البريد الإلكتروني الذي أُرسلت إليه الدعوة.", + invitationSignUpButton: "إنشاء حساب والانضمام", + invitationSignInDivider: "أو سجّل الدخول بحساب موجود", showPasswordText: "إظهار كلمة المرور", hidePasswordText: "إخفاء كلمة المرور", footer: { @@ -2779,6 +2783,8 @@ const ar = { register: { title: "إنشاء حساب", description: "أدخل معلوماتك للبدء", + invitationTitle: "أنشئ حسابًا للانضمام", + invitationDescription: "سجّل بالبريد الإلكتروني الذي أُرسلت إليه الدعوة، فلا يمكن قبولها إلا بهذا العنوان.", nameLabel: "الاسم", namePlaceholder: "محمد أحمد", emailLabel: "البريد الإلكتروني", @@ -3605,6 +3611,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 45df300eb6b..f98cf4b74f7 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2567,6 +2567,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: { @@ -2577,6 +2581,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", @@ -3376,6 +3382,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 912c9ea46ae..44e1f960c02 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3064,6 +3064,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: { @@ -3083,6 +3091,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', @@ -4079,6 +4089,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 49755a9440b..312cbb0ea77 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2619,6 +2619,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: { @@ -2629,6 +2633,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", @@ -3434,6 +3440,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 977e180ee73..5c4082871dd 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2615,6 +2615,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: { @@ -2625,6 +2629,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", @@ -3430,6 +3436,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 567ea51bf82..8b0e7c417b7 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2569,6 +2569,10 @@ const ja = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "おかえりなさい", welcomeDescription: "サインインして作業を続けましょう", + invitationTitle: "招待を承諾", + invitationDescription: "招待を受け取ったメールアドレスをご利用ください。", + invitationSignUpButton: "アカウントを作成して参加", + invitationSignInDivider: "または既存のアカウントでサインイン", showPasswordText: "パスワードを表示", hidePasswordText: "パスワードを非表示", footer: { @@ -2579,6 +2583,8 @@ const ja = { register: { title: "アカウントを作成", description: "情報を入力して始めましょう", + invitationTitle: "アカウントを作成して参加する", + invitationDescription: "招待を受け取ったメールアドレスで登録してください。招待はそのアドレスでのみ承諾できます。", nameLabel: "名前", namePlaceholder: "田中太郎", emailLabel: "メールアドレス", @@ -3378,6 +3384,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 72a5459b2e7..e086165161f 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2567,6 +2567,10 @@ const ko = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "다시 오신 것을 환영합니다", welcomeDescription: "로그인하고 작업을 계속하세요", + invitationTitle: "초대 수락", + invitationDescription: "초대를 받은 이메일 주소를 사용하세요.", + invitationSignUpButton: "계정 만들고 참여하기", + invitationSignInDivider: "또는 기존 계정으로 로그인", showPasswordText: "비밀번호 표시", hidePasswordText: "비밀번호 숨기기", footer: { @@ -2577,6 +2581,8 @@ const ko = { register: { title: "계정 만들기", description: "정보를 입력하여 시작하세요", + invitationTitle: "계정을 만들고 참여하기", + invitationDescription: "초대를 받은 이메일 주소로 가입하세요. 초대는 해당 주소로만 수락할 수 있습니다.", nameLabel: "이름", namePlaceholder: "홍길동", emailLabel: "이메일", @@ -3375,6 +3381,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 02b58db8da4..86be48177ac 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2614,6 +2614,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: { @@ -2624,6 +2628,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", @@ -3429,6 +3435,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 747023fad25..01590969993 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2676,6 +2676,10 @@ const ru = { // field's reveal toggle, and the footer's legal links. welcomeTitle: "С возвращением", welcomeDescription: "Войдите, чтобы продолжить работу", + invitationTitle: "Примите приглашение", + invitationDescription: "Используйте адрес электронной почты, на который пришло приглашение.", + invitationSignUpButton: "Создать учётную запись и присоединиться", + invitationSignInDivider: "или войдите в существующую учётную запись", showPasswordText: "Показать пароль", hidePasswordText: "Скрыть пароль", footer: { @@ -2686,6 +2690,8 @@ const ru = { register: { title: "Создать аккаунт", description: "Введите свои данные, чтобы начать", + invitationTitle: "Создайте учётную запись, чтобы присоединиться", + invitationDescription: "Зарегистрируйтесь с адресом, на который пришло приглашение. Принять его можно только с этим адресом.", nameLabel: "Имя", namePlaceholder: "Иван Иванов", emailLabel: "Электронная почта", @@ -3501,6 +3507,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 aee0eb1ff9d..be0095fa576 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2631,6 +2631,10 @@ const zh = { // field's reveal toggle, and the footer's legal links. welcomeTitle: '欢迎回来', welcomeDescription: '登录后继续你的工作', + invitationTitle: '接受邀请', + invitationDescription: '请使用收到邀请的邮箱。', + invitationSignUpButton: '注册并加入', + invitationSignInDivider: '或登录已有账号', showPasswordText: '显示密码', hidePasswordText: '隐藏密码', footer: { @@ -2641,6 +2645,8 @@ const zh = { register: { title: '创建账户', description: '创建账号,开始搭建。', + invitationTitle: '注册账号并加入', + invitationDescription: '请使用收到邀请的邮箱注册,邀请只能由该邮箱接受。', nameLabel: '姓名', namePlaceholder: '张三', emailLabel: '邮箱', @@ -3556,6 +3562,7 @@ const zh = { organization: '组织', role: '角色', expiresAt: '过期时间', + invitedBy: '邀请人', accept: '接受邀请', accepted: '邀请已接受', acceptFailed: '接受邀请失败',