Skip to content
Closed
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
16 changes: 16 additions & 0 deletions .changeset/25886-invitation-arrival-copy.md
Original file line number Diff line number Diff line change
@@ -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`.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.record.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,6 @@
"ui/packages/types"
],
"registrySources": 922,
"registrySourcesSha256": "3fe916234f1b5346bff70f6f99322077630b851ebef81c9ae6ba8c4ac08e2063"
"registrySourcesSha256": "40f7b3974c5d267fd653d8150160cd49287e218bbc394064e673ca4748b148c2"
}
}
32 changes: 25 additions & 7 deletions ui/apps/console/src/pages/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<LoginForm>` renders
* with `hideHeader`. On a dev instance the card's last row offers a one-click
Expand Down Expand Up @@ -54,6 +60,7 @@ import {
needsBootstrapProbe,
useBootstrapStatus,
LocaleSwitcher,
InvitationSignUpPrompt,
} from '@object-ui/app-shell';
import { AuthLayout } from './AuthLayout';
import { followOauthAuthorize } from './followAuthorize';
Expand Down Expand Up @@ -304,6 +311,7 @@ export function LoginPage() {
? `/register?redirect=${encodeURIComponent(redirect)}`
: '/register';
const legalLinks = readLegalLinks(authConfig);
const offerSignUp = configRead === 'known' && signUpOffer === 'form';

return (
<AuthLayout formWidth="base">
Expand Down Expand Up @@ -345,18 +353,28 @@ export function LoginPage() {
<>
<div className="mb-5">
<h1 className="text-xl font-semibold tracking-tight text-foreground">
{t('auth.login.welcomeTitle', { defaultValue: 'Welcome back' })}
{invitationRedirect
? t('auth.login.invitationTitle', { defaultValue: 'Accept your invitation' })
: t('auth.login.welcomeTitle', { defaultValue: 'Welcome back' })}
</h1>
<p className="mt-1 text-[13px] text-muted-foreground">
{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',
})}
</p>
</div>
{invitationRedirect && offerSignUp ? (
<div className="mb-5">
<InvitationSignUpPrompt registerUrl={registerUrl} />
</div>
) : null}
<LoginFormCard
registerUrl={
configRead === 'known' && signUpOffer === 'form' ? registerUrl : undefined
}
// An invitee's sign-up is the prompt above, not a second link.
registerUrl={offerSignUp && !invitationRedirect ? registerUrl : undefined}
redirect={redirect}
/>
{devSeedAdmin ? <DevAdminRow seed={devSeedAdmin} /> : null}
Expand Down
21 changes: 17 additions & 4 deletions ui/apps/console/src/pages/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,10 +257,23 @@ export function RegisterPage() {
<AuthLayout formWidth="md">
<Card className="border-border/60 px-4 py-8 shadow-sm shadow-primary/5 backdrop-blur supports-[backdrop-filter]:bg-card/95">
<RegisterForm
title={t('auth.register.title', { defaultValue: 'Create an account' })}
description={t('auth.register.description', {
defaultValue: 'Create your account to start building.',
})}
// #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', { defaultValue: 'Create an account to join' })
: 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.',
})
: t('auth.register.description', {
defaultValue: 'Create your account to start building.',
})
}
loginUrl={loginUrl}
verificationCallbackURL={verificationCallbackURL}
linkComponent={RouterLink}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ import { RegisterPage } from '../RegisterPage';

const AUTH_URL = 'http://localhost/api/v1/auth';
const SIGN_UP_LINK = { name: 'Sign up' } as const;
/** #25886 — an invitee's sign-up is the register-first prompt, not the small link. */
const INVITE_SIGN_UP = { name: builtInLocales.en.auth.login.invitationSignUpButton } as const;
const INVITATION = '/accept-invitation/inv_1';
const INVITE_QUERY = `?redirect=${encodeURIComponent(INVITATION)}`;
const SELF_REGISTRATION_CLOSED_TEXT = builtInLocales.en.auth.register.errors.selfRegistrationClosed;
Expand Down Expand Up @@ -162,11 +164,20 @@ describe('LoginPage — the "Sign up" link follows the audience posture (objectu
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, 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);
});

Expand Down Expand Up @@ -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();
});
});

Expand All @@ -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');
Expand Down
36 changes: 33 additions & 3 deletions ui/packages/app-shell/src/console/auth/LoginPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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) => (
<Link to={href} className={className}>{children}</Link>
Expand Down Expand Up @@ -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 (
<AuthPageLayout>
Expand All @@ -129,14 +141,15 @@ export function LoginPage() {
);
}

return (
<AuthPageLayout>
const form = (
<LoginForm
onSuccess={() => 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={{
Expand Down Expand Up @@ -178,6 +191,23 @@ export function LoginPage() {
backToSsoText: t('auth.login.backToSsoText'),
}}
/>
);

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

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')}
/>
{invitationSignUpUrl ? <InvitationSignUpPrompt registerUrl={invitationSignUpUrl} /> : null}
{form}
</div>
</AuthPageLayout>
);
}
10 changes: 8 additions & 2 deletions ui/packages/app-shell/src/console/auth/RegisterPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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={{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)}`;
Expand Down Expand Up @@ -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);
});

Expand Down Expand Up @@ -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);
});
});
Expand All @@ -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');
Expand Down
61 changes: 61 additions & 0 deletions ui/packages/app-shell/src/console/auth/invitationArrival.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div data-testid="invitation-sign-up" className="flex flex-col gap-4">
<Link to={registerUrl} className={INVITATION_SIGN_UP_CLASS}>
{t('auth.login.invitationSignUpButton', { defaultValue: 'Create an account and join' })}
</Link>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span aria-hidden="true" className="h-px flex-1 bg-border" />
<span>
{t('auth.login.invitationSignInDivider', {
defaultValue: 'or sign in to an existing account',
})}
</span>
<span aria-hidden="true" className="h-px flex-1 bg-border" />
</div>
</div>
);
}
Loading
Loading