Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 20 additions & 0 deletions .changeset/26473-invitation-preview-landing.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 15 additions & 7 deletions ui/apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LoginForm>` renders
Expand Down Expand Up @@ -60,6 +63,8 @@ import {
needsBootstrapProbe,
useBootstrapStatus,
InvitationSignUpPrompt,
invitationArrivalCopy,
useInvitationPreview,
} from '@object-ui/app-shell';
import { AuthLayout } from './AuthLayout';
import { AuthFooter, readLegalLinks } from './AuthFooter';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -327,15 +337,13 @@ export function LoginPage() {
<>
<div className="mb-5">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
{invitationRedirect
? t('auth.login.invitationTitle', { defaultValue: 'Accept your invitation' })
{invitationCopy
? invitationCopy.title
: t('auth.login.welcomeTitle', { defaultValue: 'Welcome back' })}
</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
{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',
})}
Expand Down
22 changes: 14 additions & 8 deletions ui/apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import {
isInvitationRedirect,
needsBootstrapProbe,
useBootstrapStatus,
invitationArrivalCopy,
useInvitationPreview,
} from '@object-ui/app-shell';
import { AuthLayout } from './AuthLayout';
import { followOauthAuthorize } from './followAuthorize';
Expand Down Expand Up @@ -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(() => {
Expand Down Expand Up @@ -263,18 +270,17 @@ export function RegisterPage() {
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<RegisterForm
// #25886 — an invitee is told to register the invited address: the
// invitation is accepted only by the account that carries it.
// 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={
invitationRedirect
? t('auth.register.invitationTitle', { defaultValue: 'Create an account to join' })
invitationCopy
? invitationCopy.title
: t('auth.register.title', { defaultValue: 'Create an account' })
}
description={
invitationRedirect
? t('auth.register.invitationDescription', {
defaultValue:
'Register with the email address the invitation was sent to. The invitation can only be accepted with that address.',
})
invitationCopy
? invitationCopy.description
: t('auth.register.description', {
defaultValue: 'Create your account to start building.',
})
Expand Down
Original file line number Diff line number Diff line change
@@ -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(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<AuthProvider authUrl={AUTH_URL} client={client}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
</Routes>
</MemoryRouter>
</AuthProvider>
</I18nProvider>,
);
}

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();
});
});
19 changes: 13 additions & 6 deletions ui/packages/app-shell/src/console/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,9 @@
* 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.
* own login page does the same. The greeting names the organization and the
* inviter when the signed-out invitation preview answers (#26473,
* `useInvitationPreview`), and keeps the generic copy when it does not.
*/

import { useEffect, useState } from 'react';
Expand All @@ -39,7 +41,11 @@ 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';
import {
InvitationSignUpPrompt,
invitationArrivalCopy,
useInvitationPreview,
} from './invitationArrival.js';

const RouterLink = ({ href, className, children }: AuthLinkComponentProps) => (
<Link to={href} className={className}>{children}</Link>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -195,16 +203,15 @@ export function LoginPage() {

if (!invitationRedirect) return <AuthPageLayout>{form}</AuthPageLayout>;

const invitationCopy = invitationArrivalCopy('login', invitationPreview, t);

return (
<AuthPageLayout>
<div
data-testid="login-invitation-arrival"
className="mx-auto flex w-full flex-col justify-center space-y-6 sm:max-w-[400px]"
>
<AuthFormHeader
title={t('auth.login.invitationTitle')}
description={t('auth.login.invitationDescription')}
/>
<AuthFormHeader title={invitationCopy.title} description={invitationCopy.description} />
{invitationSignUpUrl ? <InvitationSignUpPrompt registerUrl={invitationSignUpUrl} /> : null}
{form}
</div>
Expand Down
16 changes: 9 additions & 7 deletions ui/packages/app-shell/src/console/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => (
<Link to={href} className={className}>{children}</Link>
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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={{
Expand Down
Loading
Loading