Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
561e499
fix(plugin-map): degrade a failed map style to the record list with t…
claude Oct 26, 2026
5f0bd60
test(i18n): the map's record-list label leaves the aria-label literal…
claude Oct 26, 2026
cf9a965
fix(components): a refused submit focuses the first invalid field ins…
claude Oct 26, 2026
f4d90ca
chore(sdui): re-record the manifest record for the plugin-map source …
claude Oct 26, 2026
ab645af
fix(components): a client-side refusal retires the previous outcome b…
claude Oct 26, 2026
214f11d
fix(app-shell,console): greet a signed-out invitee for the invitation…
claude Oct 26, 2026
60aa2b4
chore: changeset and sdui manifest record for the form refusal change
claude Oct 26, 2026
6c063fb
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
557378c
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
baa3ba9
chore(sdui): re-record the manifest's registry hash for the invitatio…
claude Oct 26, 2026
451d710
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
79dcecf
chore(sdui): re-record the manifest record after merging main
claude Oct 26, 2026
cb3fe1a
chore: re-record the sdui manifest hash after merging main
claude Oct 26, 2026
5a7ff83
chore(changeset): declare Clause-② yes for the new InvitationSignUpPr…
claude Oct 26, 2026
32a5792
fix(components,i18n): drop the now-unread validation.formInvalid key …
claude Oct 26, 2026
024bef8
chore: re-record the sdui manifest hash
claude Oct 26, 2026
b5d9b55
chore(i18n): delete 92 dead keys from all ten locale packs
claude Oct 26, 2026
9e53f05
test(i18n): move ufTabs and viewTypeUnavailableShort to the retired o…
claude Oct 26, 2026
b7bc639
test(app-shell): drop workspace.invite and workspace.members from the…
claude Oct 26, 2026
330ff59
Merge remote-tracking branch 'origin/main' into claude/project-thread…
claude Oct 26, 2026
8bdaaff
chore(sdui): re-record the i18n source digest
claude Oct 26, 2026
d1355a7
Merge PR 26040 (form refusal toast) into the i18n batch
claude Oct 26, 2026
0746279
Merge PR 26044 (invitation arrival copy) into the i18n batch
claude Oct 26, 2026
8eda9b8
Merge PR 26048 (map style failure notice) into the i18n batch
claude Oct 26, 2026
5b6eb98
Merge origin/main into the i18n batch
claude Oct 26, 2026
ad1e186
chore(changeset): Clause-② yes for the dead i18n key deletion
claude Oct 26, 2026
5e59433
chore(sdui): re-record components, i18n and plugin-map source digests…
claude Oct 26, 2026
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
15 changes: 15 additions & 0 deletions .changeset/25874-form-refusal-no-field-toast.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
---
'@object-ui/components': patch
'@object-ui/i18n': patch
---

fix(components): a refused submit moves focus to the first invalid field and no longer toasts the field list over the dialog's title

Clause-②: no
Metadata-protocol: none

- **Before.** A submit refused by field validation, on the client or by the server's `fields[]`, raised an error toast naming the fields ("Please check the highlighted fields: First name, Last name, Company…") on top of the message already written under each input. The toast cut its list after three names, and in a record create / edit dialog it was drawn over the title row.
- **Now.** The form raises no toast for a refusal that every field carries inline. It scrolls to the first invalid field in form order and focuses it, and first activates that field's tab when it is on a background tab. The message under each input is the whole report.
- **A field with nothing drawn to carry it.** When the server rejects a field that has no drawn input, for example one on a tab or section a `visibleWhen` predicate hides, the form shows the form-level banner and its toast with the server's sentence, the same path a rejection naming an unknown field already takes. The hidden tab is not activated.
- **Earlier outcomes retire.** A refusal on the client now takes down the previous attempt's banner and toast, and a clear-on-hide notice, as an attempt that passes validation already did. Before, the refusal toast replaced them under the form's outcome id.
- **`validation.formInvalid` removed.** The toast's sentence ("Please check the highlighted fields: {{fields}}") had no other reader, so it leaves the form's default map and all ten `@object-ui/i18n` locale packs. `validation.formInvalidJoiner` stays: the clear-on-hide notice, the faulted-`visibleWhen` refusal and the option-group hint still join field labels with it. A host bundle that overrides `validation.formInvalid` can drop the entry; nothing reads it.
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`.
14 changes: 14 additions & 0 deletions .changeset/25889-map-style-failure-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
'@object-ui/plugin-map': patch
'@object-ui/i18n': patch
---

fix(plugin-map): a map whose style or tiles fail to load shows the record list with a translated notice, instead of a grey canvas under an English alert

Clause-②: no
Metadata-protocol: none

- **`@object-ui/plugin-map`:** when MapLibre reports a style or tile load failure (an intranet or proxied deployment that cannot reach the tile host, an unreachable style URL), the map view now removes the map and lists its records in its place, the same list a browser without WebGL2 already got: searchable, and each row opens the record the way its marker did. A "Try the map again" button brings the map back. The notice above the list keeps MapLibre's reason and names a remedy that exists: a view with its own map style is told to check that URL, and a view on the public default style is told to ask an administrator to set a map style for the view. It no longer says the markers are "listed in the search box" or asks for a "custom map style for production use" setting that does not exist. That notice, the WebGL2 notice, the list's label and empty text, and the "records excluded for missing or invalid coordinates" count now read the translation bundle, so a zh-CN console shows them in Chinese.
- **`@object-ui/i18n`:** eight new `element.map.*` keys in all ten packs.

What to do: nothing. A deployment-wide map style setting is not part of this change.
17 changes: 17 additions & 0 deletions .changeset/25921-delete-dead-i18n-keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
---
'@object-ui/i18n': patch
---

chore(i18n): delete 92 message keys nothing reads from all ten locale packs

Clause-②: yes (TranslationKeys narrows by 92 unread members; patch per the #4730 retirement precedent)
Metadata-protocol: none

`ui/scripts/check-i18n-dead-keys.mjs` listed 122 keys with no call site and no textual occurrence anywhere in the repository. 92 of them are deleted from `ar`, `de`, `en`, `es`, `fr`, `ja`, `ko`, `pt`, `ru` and `zh`; no key is renamed or added, and no screen changes, because no code asked for any of them. The largest groups are `appDesigner.*` (20 keys), `report.*` (9), `publicForm.*` (8), `table.*` (7) and `layout.appSwitcher.*` (7).

Two groups the script also listed stay:

- 28 `_one` / `_other` plural forms (`preview.changes.typeCount.*`, `notifications.arrivalMany` / `arrivalRepeats`, `fields.relativeDate.overdue`). Their base keys are called with `count`, and i18next reads the plural form, so they are in use.
- `console.ai.agentLabels.dataChat` and `metadataAssistant`, which the pack keeps on purpose for external overrides.

`TranslationKeys` (`typeof en`) and the other pack objects' types lose the 92 members. A host that reads one of them by property gets a type error; nothing in this repository does, and no `t()` call anywhere asks for one.
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/components.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/components","sources":226,"sha256":"7c50070c8bc750d5717fce0a081038855777bfb749567757047e672262517886","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/components","sources":226,"sha256":"cd1d23767a76e6b4dcf2c0d4ca61b10ba27b336eeab990b22bfb0f7d10bb0424","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/i18n.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/i18n","sources":31,"sha256":"57909816bcbfa5661852ecf652392b19d6a1e971a608b07360bfa2399bca11e4","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/i18n","sources":31,"sha256":"ae5a36bc2327b985cb909eed35e099d1b2d953b7c4cb7dda463e4d8d3ef5cc86","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
2 changes: 1 addition & 1 deletion scripts/sdui-manifest.sources/plugin-map.json
Original file line number Diff line number Diff line change
@@ -1 +1 @@
{"package":"ui/packages/plugin-map","sources":4,"sha256":"9987a8a9895284b5e3c44407a44f284462c5f522616cf9f88f9d9bdd7e0b8d90","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
{"package":"ui/packages/plugin-map","sources":4,"sha256":"7e7bcc627a2cf9334b56fe20b1b97ab2732eec9f1e5fe77ede4e570bde95d6b1","manifestSha256":"1f6e1ccb925a678eaa393f9091dfc7d0285e4e3626e0a5c61a5474687debabb7"}
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
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,7 @@
"createFailed",
"createTitle",
"default",
"invite",
"label",
"members",
"multiOrgDisabled",
"nameLabel",
"namePlaceholder",
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
Loading
Loading