From b00d5fbe97d065e15b10e7c832760302d919f7ae Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:34:21 +0000 Subject: [PATCH 1/4] feat(ui): read the signed-out invitation preview on the invitation login and register pages (wip) Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ui/apps/console/src/pages/auth/LoginPage.tsx | 22 ++- .../console/src/pages/auth/RegisterPage.tsx | 22 +-- .../app-shell/src/console/auth/LoginPage.tsx | 19 ++- .../src/console/auth/RegisterPage.tsx | 16 ++- .../src/console/auth/invitationArrival.tsx | 136 ++++++++++++++++-- ui/packages/app-shell/src/index.ts | 9 +- ui/packages/auth/src/AuthContext.ts | 7 +- ui/packages/auth/src/AuthProvider.tsx | 11 +- ui/packages/auth/src/createAuthClient.ts | 54 ++++++- ui/packages/auth/src/index.ts | 1 + ui/packages/auth/src/types.ts | 24 ++++ ui/packages/auth/src/useAuth.ts | 1 + 12 files changed, 278 insertions(+), 44 deletions(-) diff --git a/ui/apps/console/src/pages/auth/LoginPage.tsx b/ui/apps/console/src/pages/auth/LoginPage.tsx index 78b8170cbf2..75b52a22a6b 100644 --- a/ui/apps/console/src/pages/auth/LoginPage.tsx +++ b/ui/apps/console/src/pages/auth/LoginPage.tsx @@ -30,7 +30,10 @@ * (`?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). + * 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. * * 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 @@ -60,6 +63,8 @@ import { needsBootstrapProbe, useBootstrapStatus, InvitationSignUpPrompt, + invitationArrivalCopy, + useInvitationPreview, } from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { AuthFooter, readLegalLinks } from './AuthFooter'; @@ -153,6 +158,11 @@ 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); + const invitationCopy = invitationRedirect + ? invitationArrivalCopy('login', invitationPreview, t) + : null; // Detect SSO hand-off so we can surface the relying-party host. const ssoTarget = useMemo(() => { @@ -327,15 +337,13 @@ export function LoginPage() { <>

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

- {invitationRedirect - ? t('auth.login.invitationDescription', { - defaultValue: 'Use the email address the invitation was sent to.', - }) + {invitationCopy + ? invitationCopy.description : t('auth.login.welcomeDescription', { defaultValue: 'Sign in to continue your work', })} diff --git a/ui/apps/console/src/pages/auth/RegisterPage.tsx b/ui/apps/console/src/pages/auth/RegisterPage.tsx index ad9c916f291..cbec172ee1c 100644 --- a/ui/apps/console/src/pages/auth/RegisterPage.tsx +++ b/ui/apps/console/src/pages/auth/RegisterPage.tsx @@ -40,6 +40,8 @@ import { isInvitationRedirect, needsBootstrapProbe, useBootstrapStatus, + invitationArrivalCopy, + useInvitationPreview, } from '@object-ui/app-shell'; import { AuthLayout } from './AuthLayout'; import { followOauthAuthorize } from './followAuthorize'; @@ -138,6 +140,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); + const invitationCopy = invitationRedirect + ? invitationArrivalCopy('register', invitationPreview, t) + : null; // Sign-up switched off — bounce to /login. useEffect(() => { @@ -263,18 +270,17 @@ export function RegisterPage() { ( {children} @@ -120,6 +126,8 @@ 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); const signUpOffer = decideSignUpOffer(authConfig, { invitationRedirect, bootstrap }); // Carry `?redirect=` into the sign-up link: it is how `/register` knows the @@ -195,16 +203,15 @@ export function LoginPage() { if (!invitationRedirect) return {form}; + const invitationCopy = invitationArrivalCopy('login', invitationPreview, t); + 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 3f39a89b1bb..492545092bc 100644 --- a/ui/packages/app-shell/src/console/auth/RegisterPage.tsx +++ b/ui/packages/app-shell/src/console/auth/RegisterPage.tsx @@ -42,6 +42,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'; const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => ( {children} @@ -105,6 +106,8 @@ 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); // 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. @@ -132,6 +135,7 @@ export function RegisterPage() { } const loginUrl = redirect ? `/login?redirect=${encodeURIComponent(redirect)}` : '/login'; + const invitationCopy = invitationRedirect ? invitationArrivalCopy('register', invitationPreview, 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 @@ -226,13 +230,11 @@ export function RegisterPage() { onVerificationRequired={(email) => setPendingEmail(email)} loginUrl={loginUrl} // #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') - } + // invitation is accepted only by the account that carries it. #26473 — + // with the preview, the card names the inviter and the organization, + // and shows the masked address; the email field stays empty. + title={invitationCopy ? invitationCopy.title : t('auth.register.title')} + description={invitationCopy ? invitationCopy.description : t('auth.register.description')} linkComponent={RouterLink} errorMessages={signUpRefusalMessages(t)} labels={{ diff --git a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx index d659d671e34..e2088bfed06 100644 --- a/ui/packages/app-shell/src/console/auth/invitationArrival.tsx +++ b/ui/packages/app-shell/src/console/auth/invitationArrival.tsx @@ -7,8 +7,8 @@ */ /** - * invitationArrival — the register-first action a login page shows a visitor - * who arrived signed out from an invitation link (#25886). + * invitationArrival — what a login or register page shows a visitor who + * arrived signed out from an invitation link (#25886, #26473). * * `DefaultAcceptInvitationPage` bounces a signed-out visitor to * `/login?redirect=/accept-invitation/ID` (`isInvitationRedirect` in @@ -16,20 +16,28 @@ * 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. + * `DefaultLoginPage` — retitle the card for that visitor and render + * `InvitationSignUpPrompt` above the form in place of the small link, so there + * is ONE prompt and no second copy of it. Both register pages retitle theirs. * - * 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`, + * The title names the organization and the inviter when the server's + * signed-out preview answers for the id the link carries + * (`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. + * + * 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 * refuses. */ +import { useEffect, useState } from 'react'; import { Link } from 'react-router-dom'; -import { useObjectTranslation } from '@object-ui/i18n'; +import { 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. */ const INVITATION_SIGN_UP_CLASS = @@ -59,3 +67,111 @@ export function InvitationSignUpPrompt({ registerUrl }: { registerUrl: string })
); } + +/** The basename-stripped route an invitation link opens, as `./signUpOffer` reads it. */ +const INVITATION_ROUTE_PREFIX = '/accept-invitation/'; + +/** + * The invitation id a `?redirect=` target carries, or `null` when it is not an + * invitation-acceptance path. Reads the same segment `isInvitationRedirect` + * tests for, so the two agree on which redirects are invitations. + */ +export function invitationIdOf(redirect: string | null | undefined): string | null { + if (!redirect || !redirect.startsWith(INVITATION_ROUTE_PREFIX)) return null; + const segment = redirect.slice(INVITATION_ROUTE_PREFIX.length).split(/[/?#]/, 1)[0]; + if (!segment) return null; + try { + return decodeURIComponent(segment); + } catch { + // A malformed escape is still the segment the link carried; the server + // answers its uniform 404 for it. + return segment; + } +} + +/** + * The signed-out invitation preview for the page's `?redirect=`, or `null`. + * + * `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. + */ +export function useInvitationPreview(redirect: string | null | undefined): AuthInvitationPreview | null { + const { getInvitationPreview } = useAuth(); + const invitationId = invitationIdOf(redirect); + const [answer, setAnswer] = useState<{ id: string; preview: AuthInvitationPreview } | null>(null); + useEffect(() => { + if (!invitationId) return; + let cancelled = false; + // Inside the chain, so a synchronous throw is a rejection like any other. + Promise.resolve() + .then(() => getInvitationPreview(invitationId)) + .then( + (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. + }, + ); + return () => { + cancelled = true; + }; + }, [invitationId, getInvitationPreview]); + return answer && answer.id === invitationId ? answer.preview : null; +} + +/** + * The invitation card's title and description for one of the two pages: with + * a preview, "INVITER invited you to join ORG" (or "You are invited to join + * ORG" when the inviter has no name) over the masked address to use; without + * one, the generic copy each page showed before the preview existed. + */ +export function invitationArrivalCopy( + page: 'login' | 'register', + preview: AuthInvitationPreview | null, + t: TranslateFn, +): { title: string; description: string } { + if (!preview) { + return page === 'login' + ? { + title: t('auth.login.invitationTitle', { defaultValue: 'Accept your invitation' }), + description: t('auth.login.invitationDescription', { + defaultValue: 'Use the email address the invitation was sent to.', + }), + } + : { + title: t('auth.register.invitationTitle', { defaultValue: 'Create an account to join' }), + description: t('auth.register.invitationDescription', { + defaultValue: + 'Register with the email address the invitation was sent to. The invitation can only be accepted with that address.', + }), + }; + } + const organization = preview.organizationName; + const title = preview.inviterName + ? t('auth.login.invitationPreviewTitle', { + defaultValue: '{{inviter}} invited you to join {{organization}}', + inviter: preview.inviterName, + organization, + }) + : t('auth.login.invitationPreviewTitleNoInviter', { + defaultValue: 'You are invited to join {{organization}}', + organization, + }); + const description = + page === 'login' + ? t('auth.login.invitationPreviewDescription', { + defaultValue: 'Use the email address the invitation was sent to ({{email}}).', + email: preview.email, + }) + : t('auth.register.invitationPreviewDescription', { + defaultValue: + 'Register with the email address the invitation was sent to ({{email}}). The invitation can only be accepted with that address.', + email: preview.email, + }); + return { title, description }; +} diff --git a/ui/packages/app-shell/src/index.ts b/ui/packages/app-shell/src/index.ts index 20adb37f977..4b0914b7ba2 100644 --- a/ui/packages/app-shell/src/index.ts +++ b/ui/packages/app-shell/src/index.ts @@ -273,8 +273,13 @@ export { } 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'; +// arrived from an invitation link (#25886), and the signed-out invitation +// preview both login and both register pages greet that visitor with (#26473). +export { + InvitationSignUpPrompt, + invitationArrivalCopy, + useInvitationPreview, +} 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/auth/src/AuthContext.ts b/ui/packages/auth/src/AuthContext.ts index 290e7cc33b2..da9c70d9a0d 100644 --- a/ui/packages/auth/src/AuthContext.ts +++ b/ui/packages/auth/src/AuthContext.ts @@ -7,7 +7,7 @@ */ import { createContext } from 'react'; -import type { AuthUser, AuthClientSession, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions, DelegableScope } from './types.js'; +import type { AuthUser, AuthClientSession, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthInvitationPreview, AuthPublicConfig, SignInWithProviderOptions, DelegableScope } from './types.js'; export interface AuthContextValue { /** Current authenticated user */ @@ -190,6 +190,11 @@ export interface AuthContextValue { cancelInvitation: (invitationId: string) => Promise; /** Get invitation details by id */ getInvitation: (invitationId: string) => Promise; + /** + * The signed-out invitation preview: `null` when the server does not know + * the id (or it is no longer pending); rejects on any other failure. + */ + getInvitationPreview: (invitationId: string) => Promise; /** Accept an invitation as the current user */ acceptInvitation: (invitationId: string) => Promise; /** Reject an invitation as the current user */ diff --git a/ui/packages/auth/src/AuthProvider.tsx b/ui/packages/auth/src/AuthProvider.tsx index 845dd05ac5c..aaa31126a81 100644 --- a/ui/packages/auth/src/AuthProvider.tsx +++ b/ui/packages/auth/src/AuthProvider.tsx @@ -8,7 +8,7 @@ import React, { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { authGateEvents } from './auth-gate-events.js'; -import type { AuthUser, AuthClient, AuthProviderOptions, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions } from './types.js'; +import type { AuthUser, AuthClient, AuthProviderOptions, PreviewModeOptions, AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthInvitationPreview, AuthPublicConfig, SignInWithProviderOptions } from './types.js'; import { AuthCtx, type AuthContextValue } from './AuthContext.js'; import { createAuthClient, takePreflightSession, TokenStorage } from './createAuthClient.js'; import { ActiveOrganizationStorage, SessionUserScope } from './ActiveOrganizationStorage.js'; @@ -1039,6 +1039,12 @@ export function AuthProvider({ [client], ); + const getInvitationPreview = useCallback( + (invitationId: string): Promise => + client.getInvitationPreview(invitationId), + [client], + ); + const acceptInvitation = useCallback( async (invitationId: string): Promise => { await client.acceptInvitation(invitationId); @@ -1130,6 +1136,7 @@ export function AuthProvider({ listInvitations, cancelInvitation, getInvitation, + getInvitationPreview, acceptInvitation, rejectInvitation, listUserInvitations, @@ -1142,7 +1149,7 @@ export function AuthProvider({ organizations, activeOrganization, activeMember, isOrganizationsLoading, isMembershipResolved, switchOrganization, createOrganization, refreshOrganizations, updateOrganization, deleteOrganization, leaveOrganization, getMembers, inviteMember, describeDelegableScope, removeMember, updateMemberRole, - listInvitations, cancelInvitation, getInvitation, acceptInvitation, rejectInvitation, listUserInvitations, + listInvitations, cancelInvitation, getInvitation, getInvitationPreview, acceptInvitation, rejectInvitation, listUserInvitations, ], ); diff --git a/ui/packages/auth/src/createAuthClient.ts b/ui/packages/auth/src/createAuthClient.ts index 188ff42c9fd..e9514f5a29b 100644 --- a/ui/packages/auth/src/createAuthClient.ts +++ b/ui/packages/auth/src/createAuthClient.ts @@ -10,7 +10,8 @@ import { createAuthClient as createBetterAuthClient, parseJSON } from 'better-au import { organizationClient, twoFactorClient } from 'better-auth/client/plugins'; import type { AuthClient, AuthClientConfig, AuthUser, AuthClientSession, SignInCredentials, SignUpData, - AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthPublicConfig, SignInWithProviderOptions, + AuthOrganization, AuthOrganizationMember, AuthInvitation, AuthInvitationPreview, AuthPublicConfig, + SignInWithProviderOptions, } from './types.js'; import { AUTH_INVITATION_STATUSES, isAuthInvitationStatus } from './invitation-status.js'; @@ -162,6 +163,32 @@ function asAuthInvitation(raw: unknown, method: string): AuthInvitation { return raw as AuthInvitation; } +/** + * The boundary for the signed-out invitation preview's success body, + * `{ success: true, data: { organizationName, inviterName, email } }` + * (objectstack `plugin-auth` `invitation-preview.ts`). Anything else is a + * producer defect and fails loudly, naming the method — the page that asked + * treats a rejection as "no preview" and keeps its generic copy, so a wrong + * shape never reaches the screen as a half-filled sentence. + */ +function asAuthInvitationPreview(raw: unknown): AuthInvitationPreview { + const body = raw as { success?: unknown; data?: Record } | null | undefined; + const data = body?.success === true ? body.data : undefined; + if ( + !data || + typeof data.organizationName !== 'string' || + !(data.inviterName === null || typeof data.inviterName === 'string') || + typeof data.email !== 'string' + ) { + throw new Error('getInvitationPreview: unexpected response body'); + } + return { + organizationName: data.organizationName, + inviterName: data.inviterName as string | null, + email: data.email, + }; +} + /** Same boundary, per row, for the list-returning invitation routes. */ function asAuthInvitations(raw: unknown, method: string): AuthInvitation[] { const rows = Array.isArray(raw) ? (raw as unknown[]) : []; @@ -1010,6 +1037,31 @@ export function createAuthClient(config: AuthClientConfig): AuthClient { return asAuthInvitation(data, 'getInvitation'); }, + async getInvitationPreview(invitationId: string): Promise { + // 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. + 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; + if (!response.ok) { + const err = new Error( + body?.error?.message ?? `Invitation preview failed with status ${response.status}`, + ) as Error & { code?: string; status?: number }; + if (body?.error?.code) err.code = body.error.code; + err.status = response.status; + throw err; + } + return asAuthInvitationPreview(body); + }, + async acceptInvitation(invitationId: string): Promise { const { error } = await (betterAuth as any).organization.acceptInvitation({ invitationId }); if (error) throw toAuthError(error, 'Failed to accept invitation'); diff --git a/ui/packages/auth/src/index.ts b/ui/packages/auth/src/index.ts index 6c895e0af1e..bf08f98d157 100644 --- a/ui/packages/auth/src/index.ts +++ b/ui/packages/auth/src/index.ts @@ -108,6 +108,7 @@ export type { AuthOrganization, AuthOrganizationMember, AuthInvitation, + AuthInvitationPreview, AuthSocialProvider, AuthPublicConfig, SignInWithProviderOptions, diff --git a/ui/packages/auth/src/types.ts b/ui/packages/auth/src/types.ts index 223b1aabef9..d028d0b954d 100644 --- a/ui/packages/auth/src/types.ts +++ b/ui/packages/auth/src/types.ts @@ -460,6 +460,13 @@ export interface AuthClient { cancelInvitation: (invitationId: string) => Promise; /** Get an invitation by id (used by accept-invitation landing page) */ getInvitation: (invitationId: string) => Promise; + /** + * 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. + */ + getInvitationPreview: (invitationId: string) => Promise; /** Accept an invitation as the current user */ acceptInvitation: (invitationId: string) => Promise; /** Reject an invitation as the current user */ @@ -544,6 +551,23 @@ export interface AuthInvitation { organizationSlug?: string; } +/** + * What a signed-out visitor may learn about an invitation from the id its link + * carries: the server's `GET {authUrl}/organization/invitation-preview?id=ID` + * (objectstack `plugin-auth` `invitation-preview.ts`). Exactly these three keys. + */ +export interface AuthInvitationPreview { + /** The inviting organization's display name. */ + organizationName: string; + /** The inviter's name; `null` when that account is gone or carries no name. */ + inviterName: string | null; + /** + * The invited address, MASKED by the server (`a***@acme.com`). A hint for + * the visitor to recognise, never a value to prefill: it is not an address. + */ + email: string; +} + /** Organization member */ export interface AuthOrganizationMember { /** Member record ID */ diff --git a/ui/packages/auth/src/useAuth.ts b/ui/packages/auth/src/useAuth.ts index 2f6a72b7152..b23ea2583d7 100644 --- a/ui/packages/auth/src/useAuth.ts +++ b/ui/packages/auth/src/useAuth.ts @@ -80,6 +80,7 @@ export function useAuth(): AuthContextValue { listInvitations: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, cancelInvitation: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, getInvitation: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, + getInvitationPreview: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, acceptInvitation: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, rejectInvitation: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, listUserInvitations: async () => { throw new Error('useAuth must be used within an AuthProvider'); }, From 6831dc8bf3b2546c68c9e7529482ce155df907a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:46:00 +0000 Subject: [PATCH 2/4] test(ui): pin the invitation preview on the login and register pages, found / 404 / failure (wip) Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../invitationPreview-26473.test.tsx | 167 ++++++++++++++ .../invitationPreview-26473.test.tsx | 215 ++++++++++++++++++ .../__tests__/invitationPreview-26473.test.ts | 85 +++++++ ui/packages/i18n/src/locales/en.ts | 10 +- ui/packages/i18n/src/locales/zh.ts | 4 + 5 files changed, 479 insertions(+), 2 deletions(-) create mode 100644 ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx create mode 100644 ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx create mode 100644 ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts 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 new file mode 100644 index 00000000000..d98a27595ce --- /dev/null +++ b/ui/apps/console/src/pages/auth/__tests__/invitationPreview-26473.test.tsx @@ -0,0 +1,167 @@ +/** + * #26473 — the console's own `/login` and `/register`, opened signed out from + * an invitation link, greet the visitor by WHO invited them and to WHAT. + * + * The read is app-shell's `useInvitationPreview` over the server's signed-out + * `GET {authUrl}/organization/invitation-preview?id=ID`; the package's default + * pages carry the same pins in + * `packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx`. + * Pinned here for the pages the console actually routes: + * + * - 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. + * + * Rendered with a real `AuthProvider` over a real `createAuthClient`; only + * `fetch` is a stub. Generic copy is read from the en pack. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen, cleanup, waitFor } from '@testing-library/react'; +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_26473'; +const INVITE_QUERY = `?redirect=${encodeURIComponent(`/accept-invitation/${INVITATION_ID}`)}`; +const ORG = 'Xiaolin Decoration Studio'; +const INVITER = 'Lin Xiao'; +const MASKED = 'z***@example.com'; +const MASKED_TEXT = new RegExp(MASKED.replace(/[*.]/g, '\\$&')); + +const CONFIG: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false }, + features: { audiencePosture: 'invite_only' }, +}; + +type PreviewAnswer = 'found' | 404 | 500; +let previewRequests: string[]; + +function stubServer(answer: PreviewAnswer): typeof fetch { + previewRequests = []; + 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')) { + previewRequests.push(url); + if (answer === 'found') { + return json({ success: true, data: { organizationName: ORG, inviterName: INVITER, email: MASKED } }); + } + return json( + { + success: false, + error: + answer === 404 + ? { code: 'RESOURCE_NOT_FOUND', message: 'Invitation not found.' } + : { code: 'INTERNAL_ERROR', message: 'The invitation preview is unavailable.' }, + }, + answer, + ); + } + 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, answer: PreviewAnswer) { + const fetchFn = stubServer(answer); + vi.stubGlobal('fetch', fetchFn); + window.history.replaceState({}, '', path); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + return render( + + + + + } /> + } /> + + + + , + ); +} + +async function previewAnswered() { + await waitFor(() => expect(previewRequests).toHaveLength(1)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +beforeEach(() => { + window.localStorage.clear(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + window.history.replaceState({}, '', '/'); +}); + +describe('console /login — the invitation landing reads the signed-out preview (#26473)', () => { + it('found: names the inviter and the organization, shows the masked address, prefills nothing', async () => { + renderAt(`/login${INVITE_QUERY}`, 'found'); + + const heading = await screen.findByRole('heading', { name: new RegExp(ORG) }); + expect(heading).toHaveTextContent(INVITER); + expect(screen.getByText(MASKED_TEXT)).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: en.login.invitationTitle })).toBeNull(); + expect(previewRequests).toEqual([`${AUTH_URL}/organization/invitation-preview?id=${INVITATION_ID}`]); + expect(await screen.findByLabelText(en.login.emailLabel)).toHaveValue(''); + }); + + 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.queryByText(new RegExp(ORG))).toBeNull(); + }); + + it('a failed read: keeps the generic copy and the sign-in form', async () => { + renderAt(`/login${INVITE_QUERY}`, 500); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); + expect(await screen.findByLabelText(en.login.emailLabel)).toBeInTheDocument(); + }); +}); + +describe('console /register — the sign-up step reads the same preview (#26473)', () => { + it('found: names the inviter and the organization, shows the masked address, prefills nothing', async () => { + renderAt(`/register${INVITE_QUERY}`, 'found'); + + const heading = await screen.findByRole('heading', { name: new RegExp(ORG) }); + expect(heading).toHaveTextContent(INVITER); + expect(screen.getByText(MASKED_TEXT)).toBeInTheDocument(); + expect(screen.getByLabelText(en.register.emailLabel)).toHaveValue(''); + }); + + it('404: keeps the generic invitation copy', async () => { + renderAt(`/register${INVITE_QUERY}`, 404); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + }); + + it('a failed read: keeps the generic copy and the form', async () => { + renderAt(`/register${INVITE_QUERY}`, 500); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument(); + }); +}); 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 new file mode 100644 index 00000000000..c4c2229ba3a --- /dev/null +++ b/ui/packages/app-shell/src/console/auth/__tests__/invitationPreview-26473.test.tsx @@ -0,0 +1,215 @@ +/** + * 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. + */ + +/** + * #26473 — a visitor who opens an invitation link signed out is greeted by + * WHO invited them and to WHAT, on `DefaultLoginPage` and on the + * `DefaultRegisterPage` it hands to. + * + * The server's signed-out read is `GET {authUrl}/organization/invitation-preview?id=ID` + * (objectstack `plugin-auth` `invitation-preview.ts`): `{ success, data: + * { organizationName, inviterName, email } }` with the address MASKED, and one + * uniform 404 for any id that is not a pending, unexpired invitation. Three + * outcomes are pinned here, each on both pages where it differs: + * + * - 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. + * + * Rendered as shipped, like `defaultPagesFollowPosture-11705.test.tsx` beside + * this file: 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. Generic copy is read from the en pack. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { act, render, screen, cleanup, waitFor } from '@testing-library/react'; +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_26473'; +const INVITE_QUERY = `?redirect=${encodeURIComponent(`/accept-invitation/${INVITATION_ID}`)}`; +const ORG = 'Xiaolin Decoration Studio'; +const INVITER = 'Lin Xiao'; +const MASKED = 'z***@example.com'; + +const CONFIG: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false, requireEmailVerification: false }, + features: { audiencePosture: 'invite_only' }, +}; + +type PreviewAnswer = 'found' | 'found-no-inviter' | 404 | 500 | 'network' | 'wrong-shape'; + +let previewRequests: string[]; + +function stubServer(answer: PreviewAnswer): typeof fetch { + previewRequests = []; + const json = (body: unknown, status = 200) => + new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + return (async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url.includes('/organization/invitation-preview')) { + previewRequests.push(`${init?.method ?? 'GET'} ${url}`); + switch (answer) { + case 'found': + 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 500: + return json( + { success: false, error: { code: 'INTERNAL_ERROR', message: 'The invitation preview is unavailable.' } }, + 500, + ); + case 'network': + throw new TypeError('Failed to fetch'); + case 'wrong-shape': + return json({ organizationName: ORG }); + } + } + 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, answer: PreviewAnswer) { + const fetchFn = stubServer(answer); + vi.stubGlobal('fetch', fetchFn); + const client = createAuthClient({ baseURL: AUTH_URL, fetchFn }); + return render( + + + + + } /> + } /> + + + + , + ); +} + +/** The preview was asked for, and its answer had the chance to land. */ +async function previewAnswered() { + await waitFor(() => expect(previewRequests).toHaveLength(1)); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} + +beforeEach(() => { + window.localStorage.clear(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe('DefaultLoginPage — the invitation landing reads the signed-out preview (#26473)', () => { + it('found: names the inviter and the organization, and shows the masked address', async () => { + renderAt(`/login${INVITE_QUERY}`, 'found'); + + const heading = await screen.findByRole('heading', { name: new RegExp(ORG) }); + expect(heading).toHaveTextContent(INVITER); + expect(screen.getByText(new RegExp(MASKED.replace(/[*.]/g, '\\$&')))).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: en.login.invitationTitle })).toBeNull(); + // One anonymous read, by the id the link carries. + expect(previewRequests).toEqual([ + `GET ${AUTH_URL}/organization/invitation-preview?id=${INVITATION_ID}`, + ]); + // The masked value is a hint, never a prefill. + expect(await screen.findByLabelText(en.login.emailLabel)).toHaveValue(''); + }); + + it('found without an inviter name: names the organization alone', async () => { + renderAt(`/login${INVITE_QUERY}`, 'found-no-inviter'); + + const heading = await screen.findByRole('heading', { name: new RegExp(ORG) }); + expect(heading).not.toHaveTextContent('null'); + 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)( + 'a failed read (%s): keeps the generic copy and the sign-in form', + async (answer) => { + renderAt(`/login${INVITE_QUERY}`, answer); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.login.invitationTitle })).toBeInTheDocument(); + expect(await screen.findByLabelText(en.login.emailLabel)).toBeInTheDocument(); + expect(screen.queryByText(new RegExp(ORG))).toBeNull(); + }, + ); + + it('asks for no preview when the visitor did not come from an invitation', async () => { + renderAt('/login', 'found'); + await screen.findByLabelText(en.login.emailLabel); + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)); + }); + + expect(previewRequests).toEqual([]); + }); +}); + +describe('DefaultRegisterPage — the sign-up step reads the same preview (#26473)', () => { + it('found: names the inviter and the organization, shows the masked address, prefills nothing', async () => { + renderAt(`/register${INVITE_QUERY}`, 'found'); + + const heading = await screen.findByRole('heading', { name: new RegExp(ORG) }); + expect(heading).toHaveTextContent(INVITER); + expect(screen.getByText(new RegExp(MASKED.replace(/[*.]/g, '\\$&')))).toBeInTheDocument(); + expect(screen.queryByRole('heading', { name: en.register.invitationTitle })).toBeNull(); + expect(screen.getByLabelText(en.register.emailLabel)).toHaveValue(''); + }); + + it('404: keeps the generic invitation copy', async () => { + renderAt(`/register${INVITE_QUERY}`, 404); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + expect(screen.getByText(en.register.invitationDescription)).toBeInTheDocument(); + }); + + it('a failed read: keeps the generic copy and the form', async () => { + renderAt(`/register${INVITE_QUERY}`, 500); + await previewAnswered(); + + expect(await screen.findByRole('heading', { name: en.register.invitationTitle })).toBeInTheDocument(); + expect(screen.getByLabelText(en.register.emailLabel)).toBeInTheDocument(); + }); +}); diff --git a/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts b/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts new file mode 100644 index 00000000000..0a7049821d3 --- /dev/null +++ b/ui/packages/auth/src/__tests__/invitationPreview-26473.test.ts @@ -0,0 +1,85 @@ +/** + * #26473 — `createAuthClient().getInvitationPreview(id)`, the client half of + * the server's signed-out read `GET {authUrl}/organization/invitation-preview?id=ID` + * (objectstack `plugin-auth` `invitation-preview.ts`). + * + * The server answers `{ success: true, data: { organizationName, inviterName, + * email } }` for a pending, unexpired invitation (the address masked), and ONE + * 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". + */ + +import { describe, it, expect, vi } from 'vitest'; +import { createAuthClient } from '../createAuthClient'; + +const AUTH_URL = 'http://localhost/api/v1/auth'; + +function clientAnswering(status: number, body: unknown) { + const calls: Array<{ url: string; method: string }> = []; + const fetchFn = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + calls.push({ url, method: init?.method ?? 'GET' }); + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); + }); + return { client: createAuthClient({ baseURL: AUTH_URL, fetchFn }), calls }; +} + +describe('getInvitationPreview (#26473)', () => { + it('found: returns exactly the three keys, from one GET keyed by the encoded id', async () => { + const { client, calls } = clientAnswering(200, { + success: true, + data: { organizationName: 'Acme', inviterName: 'Ada', email: 'a***@acme.com' }, + }); + + await expect(client.getInvitationPreview('inv 1/x')).resolves.toEqual({ + organizationName: 'Acme', + inviterName: 'Ada', + email: 'a***@acme.com', + }); + expect(calls).toEqual([ + { url: `${AUTH_URL}/organization/invitation-preview?id=inv%201%2Fx`, method: 'GET' }, + ]); + }); + + it('found with no inviter name: keeps the null', async () => { + const { client } = clientAnswering(200, { + success: true, + data: { organizationName: 'Acme', inviterName: null, email: 'a***@acme.com' }, + }); + + await expect(client.getInvitationPreview('inv_1')).resolves.toEqual({ + organizationName: 'Acme', + inviterName: null, + email: 'a***@acme.com', + }); + }); + + it('404: resolves null — the server does not know the id, which is an answer', async () => { + const { client } = clientAnswering(404, { + success: false, + error: { code: 'RESOURCE_NOT_FOUND', message: 'Invitation not found.' }, + }); + + await expect(client.getInvitationPreview('inv_gone')).resolves.toBeNull(); + }); + + it('a failed read rejects with the server code and the status', async () => { + const { client } = clientAnswering(429, { + success: false, + error: { code: 'RATE_LIMIT_EXCEEDED', message: 'Too many invitation preview requests.' }, + }); + + await expect(client.getInvitationPreview('inv_1')).rejects.toMatchObject({ + code: 'RATE_LIMIT_EXCEEDED', + status: 429, + }); + }); + + it('a success body of the wrong shape rejects instead of returning a partial preview', async () => { + const { client } = clientAnswering(200, { organizationName: 'Acme' }); + + await expect(client.getInvitationPreview('inv_1')).rejects.toThrow(/getInvitationPreview/); + }); +}); diff --git a/ui/packages/i18n/src/locales/en.ts b/ui/packages/i18n/src/locales/en.ts index 58f3ce379f1..792c8587c50 100644 --- a/ui/packages/i18n/src/locales/en.ts +++ b/ui/packages/i18n/src/locales/en.ts @@ -3026,12 +3026,17 @@ const en = { 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). + // register-first action above the form (#25886). The generic copy names + // no organization or inviter; the `invitationPreview*` lines below do, + // once the signed-out invitation preview answers for the link's id + // (#26473). `{{email}}` is the MASKED address the preview returns. 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', + 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}}).', showPasswordText: 'Show password', hidePasswordText: 'Hide password', footer: { @@ -3053,6 +3058,7 @@ const en = { 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.', + invitationPreviewDescription: 'Register with the email address the invitation was sent to ({{email}}). The invitation can only be accepted with that address.', nameLabel: 'Name', namePlaceholder: 'John Doe', emailLabel: 'Email', diff --git a/ui/packages/i18n/src/locales/zh.ts b/ui/packages/i18n/src/locales/zh.ts index e1ed49879d7..1e4d1c3efec 100644 --- a/ui/packages/i18n/src/locales/zh.ts +++ b/ui/packages/i18n/src/locales/zh.ts @@ -2588,6 +2588,9 @@ const zh = { invitationDescription: '请使用收到邀请的邮箱。', invitationSignUpButton: '注册并加入', invitationSignInDivider: '或登录已有账号', + invitationPreviewTitle: '{{inviter}} 邀请你加入 {{organization}}', + invitationPreviewTitleNoInviter: '你受邀加入 {{organization}}', + invitationPreviewDescription: '请使用收到邀请的邮箱({{email}})。', showPasswordText: '显示密码', hidePasswordText: '隐藏密码', footer: { @@ -2600,6 +2603,7 @@ const zh = { description: '创建账号,开始搭建。', invitationTitle: '注册账号并加入', invitationDescription: '请使用收到邀请的邮箱注册,邀请只能由该邮箱接受。', + invitationPreviewDescription: '请使用收到邀请的邮箱({{email}})注册,邀请只能由该邮箱接受。', nameLabel: '姓名', namePlaceholder: '张三', emailLabel: '邮箱', From b474d4ab85f18b88c6ea644e169b93b95f222672 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:49:32 +0000 Subject: [PATCH 3/4] feat(ui): the invitation preview strings in every locale pack, which key parity requires Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- ui/packages/i18n/src/locales/ar.ts | 4 ++++ ui/packages/i18n/src/locales/de.ts | 4 ++++ ui/packages/i18n/src/locales/es.ts | 4 ++++ ui/packages/i18n/src/locales/fr.ts | 4 ++++ ui/packages/i18n/src/locales/ja.ts | 4 ++++ ui/packages/i18n/src/locales/ko.ts | 4 ++++ ui/packages/i18n/src/locales/pt.ts | 4 ++++ ui/packages/i18n/src/locales/ru.ts | 4 ++++ 8 files changed, 32 insertions(+) diff --git a/ui/packages/i18n/src/locales/ar.ts b/ui/packages/i18n/src/locales/ar.ts index a349d9c2d03..1efa98a8925 100644 --- a/ui/packages/i18n/src/locales/ar.ts +++ b/ui/packages/i18n/src/locales/ar.ts @@ -2743,6 +2743,9 @@ const ar = { invitationDescription: "استخدم البريد الإلكتروني الذي أُرسلت إليه الدعوة.", invitationSignUpButton: "إنشاء حساب والانضمام", invitationSignInDivider: "أو سجّل الدخول بحساب موجود", + invitationPreviewTitle: "دعاك {{inviter}} للانضمام إلى {{organization}}", + invitationPreviewTitleNoInviter: "أنت مدعو للانضمام إلى {{organization}}", + invitationPreviewDescription: "استخدم البريد الإلكتروني الذي أُرسلت إليه الدعوة ({{email}}).", showPasswordText: "إظهار كلمة المرور", hidePasswordText: "إخفاء كلمة المرور", footer: { @@ -2755,6 +2758,7 @@ const ar = { description: "أدخل معلوماتك للبدء", invitationTitle: "أنشئ حسابًا للانضمام", invitationDescription: "سجّل بالبريد الإلكتروني الذي أُرسلت إليه الدعوة، فلا يمكن قبولها إلا بهذا العنوان.", + invitationPreviewDescription: "سجّل بالبريد الإلكتروني الذي أُرسلت إليه الدعوة ({{email}})، فلا يمكن قبولها إلا بهذا العنوان.", nameLabel: "الاسم", namePlaceholder: "محمد أحمد", emailLabel: "البريد الإلكتروني", diff --git a/ui/packages/i18n/src/locales/de.ts b/ui/packages/i18n/src/locales/de.ts index c2102cb2823..2056797794f 100644 --- a/ui/packages/i18n/src/locales/de.ts +++ b/ui/packages/i18n/src/locales/de.ts @@ -2533,6 +2533,9 @@ const de = { 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", + 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}}).", showPasswordText: "Passwort anzeigen", hidePasswordText: "Passwort verbergen", footer: { @@ -2545,6 +2548,7 @@ const de = { 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.", + invitationPreviewDescription: "Registrieren Sie sich mit der E-Mail-Adresse, an die die Einladung gesendet wurde ({{email}}). Nur mit dieser Adresse kann sie angenommen werden.", nameLabel: "Name", namePlaceholder: "Max Mustermann", emailLabel: "E-Mail", diff --git a/ui/packages/i18n/src/locales/es.ts b/ui/packages/i18n/src/locales/es.ts index 4ff0d657dac..a8b0471f3a4 100644 --- a/ui/packages/i18n/src/locales/es.ts +++ b/ui/packages/i18n/src/locales/es.ts @@ -2587,6 +2587,9 @@ const es = { 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", + 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}}).", showPasswordText: "Mostrar contraseña", hidePasswordText: "Ocultar contraseña", footer: { @@ -2599,6 +2602,7 @@ const es = { 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.", + invitationPreviewDescription: "Regístrate con el correo al que se envió la invitación ({{email}}). Solo se puede aceptar con esa dirección.", nameLabel: "Nombre", namePlaceholder: "Juan Pérez", emailLabel: "Correo electrónico", diff --git a/ui/packages/i18n/src/locales/fr.ts b/ui/packages/i18n/src/locales/fr.ts index 2e9d442f5ea..05d330c8764 100644 --- a/ui/packages/i18n/src/locales/fr.ts +++ b/ui/packages/i18n/src/locales/fr.ts @@ -2583,6 +2583,9 @@ const fr = { 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", + 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}}).", showPasswordText: "Afficher le mot de passe", hidePasswordText: "Masquer le mot de passe", footer: { @@ -2595,6 +2598,7 @@ const fr = { 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.", + invitationPreviewDescription: "Inscrivez-vous avec l'adresse e-mail à laquelle l'invitation a été envoyée ({{email}}). Elle ne peut être acceptée qu'avec cette adresse.", nameLabel: "Nom", namePlaceholder: "Jean Dupont", emailLabel: "E-mail", diff --git a/ui/packages/i18n/src/locales/ja.ts b/ui/packages/i18n/src/locales/ja.ts index 3c036cd6d9d..ef81639267a 100644 --- a/ui/packages/i18n/src/locales/ja.ts +++ b/ui/packages/i18n/src/locales/ja.ts @@ -2535,6 +2535,9 @@ const ja = { invitationDescription: "招待を受け取ったメールアドレスをご利用ください。", invitationSignUpButton: "アカウントを作成して参加", invitationSignInDivider: "または既存のアカウントでサインイン", + invitationPreviewTitle: "{{inviter}} さんから {{organization}} への招待が届いています", + invitationPreviewTitleNoInviter: "{{organization}} への招待が届いています", + invitationPreviewDescription: "招待を受け取ったメールアドレス({{email}})をご利用ください。", showPasswordText: "パスワードを表示", hidePasswordText: "パスワードを非表示", footer: { @@ -2547,6 +2550,7 @@ const ja = { description: "情報を入力して始めましょう", invitationTitle: "アカウントを作成して参加する", invitationDescription: "招待を受け取ったメールアドレスで登録してください。招待はそのアドレスでのみ承諾できます。", + invitationPreviewDescription: "招待を受け取ったメールアドレス({{email}})で登録してください。招待はそのアドレスでのみ承諾できます。", nameLabel: "名前", namePlaceholder: "田中太郎", emailLabel: "メールアドレス", diff --git a/ui/packages/i18n/src/locales/ko.ts b/ui/packages/i18n/src/locales/ko.ts index 51fb7ef2bb6..32263d8b4f5 100644 --- a/ui/packages/i18n/src/locales/ko.ts +++ b/ui/packages/i18n/src/locales/ko.ts @@ -2533,6 +2533,9 @@ const ko = { invitationDescription: "초대를 받은 이메일 주소를 사용하세요.", invitationSignUpButton: "계정 만들고 참여하기", invitationSignInDivider: "또는 기존 계정으로 로그인", + invitationPreviewTitle: "{{inviter}} 님이 {{organization}}에 초대했습니다", + invitationPreviewTitleNoInviter: "{{organization}}에 초대되었습니다", + invitationPreviewDescription: "초대를 받은 이메일 주소({{email}})를 사용하세요.", showPasswordText: "비밀번호 표시", hidePasswordText: "비밀번호 숨기기", footer: { @@ -2545,6 +2548,7 @@ const ko = { description: "정보를 입력하여 시작하세요", invitationTitle: "계정을 만들고 참여하기", invitationDescription: "초대를 받은 이메일 주소로 가입하세요. 초대는 해당 주소로만 수락할 수 있습니다.", + invitationPreviewDescription: "초대를 받은 이메일 주소({{email}})로 가입하세요. 초대는 해당 주소로만 수락할 수 있습니다.", nameLabel: "이름", namePlaceholder: "홍길동", emailLabel: "이메일", diff --git a/ui/packages/i18n/src/locales/pt.ts b/ui/packages/i18n/src/locales/pt.ts index 3190cad1465..4a53e2ef294 100644 --- a/ui/packages/i18n/src/locales/pt.ts +++ b/ui/packages/i18n/src/locales/pt.ts @@ -2582,6 +2582,9 @@ const pt = { invitationDescription: "Use o e-mail para o qual o convite foi enviado.", invitationSignUpButton: "Criar conta e participar", invitationSignInDivider: "ou entre com uma conta existente", + 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}}).", showPasswordText: "Mostrar senha", hidePasswordText: "Ocultar senha", footer: { @@ -2594,6 +2597,7 @@ const pt = { 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.", + invitationPreviewDescription: "Cadastre-se com o e-mail para o qual o convite foi enviado ({{email}}). Ele só pode ser aceito com esse endereço.", nameLabel: "Nome", namePlaceholder: "João Silva", emailLabel: "E-mail", diff --git a/ui/packages/i18n/src/locales/ru.ts b/ui/packages/i18n/src/locales/ru.ts index 95634b0af47..7e87155c990 100644 --- a/ui/packages/i18n/src/locales/ru.ts +++ b/ui/packages/i18n/src/locales/ru.ts @@ -2646,6 +2646,9 @@ const ru = { invitationDescription: "Используйте адрес электронной почты, на который пришло приглашение.", invitationSignUpButton: "Создать учётную запись и присоединиться", invitationSignInDivider: "или войдите в существующую учётную запись", + invitationPreviewTitle: "{{inviter}} приглашает вас в {{organization}}", + invitationPreviewTitleNoInviter: "Вас приглашают в {{organization}}", + invitationPreviewDescription: "Используйте адрес электронной почты, на который пришло приглашение ({{email}}).", showPasswordText: "Показать пароль", hidePasswordText: "Скрыть пароль", footer: { @@ -2658,6 +2661,7 @@ const ru = { description: "Введите свои данные, чтобы начать", invitationTitle: "Создайте учётную запись, чтобы присоединиться", invitationDescription: "Зарегистрируйтесь с адресом, на который пришло приглашение. Принять его можно только с этим адресом.", + invitationPreviewDescription: "Зарегистрируйтесь с адресом, на который пришло приглашение ({{email}}). Принять его можно только с этим адресом.", nameLabel: "Имя", namePlaceholder: "Иван Иванов", emailLabel: "Электронная почта", From b848d2b1baeb04f67ab3870c1760d82d7f5ada25 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 27 Oct 2026 05:50:07 +0000 Subject: [PATCH 4/4] chore: changeset for the signed-out invitation preview on the landing pages Claude-Session: https://claude.ai/code/session_01SMps7wq7rBTk53e8KTSEup Co-authored-by: Claude --- .../26473-invitation-preview-landing.md | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 .changeset/26473-invitation-preview-landing.md diff --git a/.changeset/26473-invitation-preview-landing.md b/.changeset/26473-invitation-preview-landing.md new file mode 100644 index 00000000000..562327912bf --- /dev/null +++ b/.changeset/26473-invitation-preview-landing.md @@ -0,0 +1,20 @@ +--- +'@object-ui/auth': patch +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +'@object-ui/console': patch +--- + +feat(auth,app-shell,i18n,console): an invitation link opened signed out names the inviter and the organization on the sign-in and sign-up pages + +Clause-②: no +Metadata-protocol: none + +A visitor who opened an invitation link while signed out landed on 「接受邀请 · 请使用收到邀请的邮箱。」 and then 「注册账号并加入」, and neither page said who had invited them or to which organization. The server has answered that question anonymously since `GET /api/v1/auth/organization/invitation-preview?id=ID`, but no page read it. + +- **`@object-ui/auth`:** `getInvitationPreview(invitationId)` on the auth client, `AuthProvider` and `useAuth()`, with the `AuthInvitationPreview` type (`organizationName`, `inviterName`, `email`). It needs no session. It resolves the preview, resolves `null` for the server's not-found answer, and rejects with the server's `code` and the HTTP `status` on any other failure. +- **`@object-ui/app-shell`:** `useInvitationPreview(redirect)` and `invitationArrivalCopy(page, preview, t)`. `DefaultLoginPage` and `DefaultRegisterPage` use them for a visitor arriving from `/accept-invitation/ID`, and the card reads 「林晓 邀请你加入 晓林装饰工作室」. The masked invited address (`z***@example.com`) is shown as the address to use. It never fills the email field, because a masked value is not an address. An unknown or expired invitation, a failed read, or a server without the route keeps the generic copy. +- **`@object-ui/console`:** the console's own `/login` and `/register` do the same. +- **`@object-ui/i18n`:** `auth.login.invitationPreviewTitle`, `auth.login.invitationPreviewTitleNoInviter`, `auth.login.invitationPreviewDescription` and `auth.register.invitationPreviewDescription`, in all ten locale packs. + +What to do: nothing. A host that mocks `useAuth()` without `getInvitationPreview` keeps the generic invitation copy.