>();
+ return { ...actual, toast: { success: toastSuccess, error: toastError } };
+});
+
+// Imported after the mocks so the barrel picks them up.
+const { DefaultAcceptInvitationPage } = await import('@object-ui/app-shell');
+
+const INVITATION = {
+ id: 'inv_1',
+ organizationId: 'org_1',
+ organizationName: 'Acme Corp',
+ email: 'ada@example.com',
+ role: 'admin',
+ status: 'pending',
+ expiresAt: '2026-12-24T10:00:00.000Z',
+};
+
+/** Records every path the router settles on, so "landed on X" is an assertion. */
+const seen: string[] = [];
+function Recorder() {
+ const location = useLocation();
+ seen.push(location.pathname + location.search);
+ return null;
+}
+
+/**
+ * Mount the page exactly as `apps/console/src/App.tsx` does: inside the
+ * console's `BrowserRouter`, on the real path, with NO layout wrapper.
+ * `basename` mirrors a console served under a `` mount.
+ */
+function renderRoute({ basename = '/', lang = 'en' }: { basename?: string; lang?: string } = {}) {
+ const mount = basename === '/' ? '' : basename;
+ window.history.pushState({}, '', `${mount}/accept-invitation/inv_1`);
+ return render(
+
+
+
+
+ } />
+ } />
+ } />
+ } />
+
+
+ ,
+ );
+}
+
+beforeEach(() => {
+ vi.clearAllMocks();
+ seen.length = 0;
+ window.localStorage.clear();
+ authState = {
+ isAuthenticated: true,
+ isLoading: false,
+ getInvitation: vi.fn().mockResolvedValue(INVITATION),
+ acceptInvitation: vi.fn().mockResolvedValue(undefined),
+ rejectInvitation: vi.fn().mockResolvedValue(undefined),
+ switchOrganization: vi.fn().mockResolvedValue(undefined),
+ };
+});
+
+afterEach(() => {
+ window.history.pushState({}, '', '/');
+});
+
+describe('objectui#3811 — console routes DefaultAcceptInvitationPage', () => {
+ describe('renders coherently in console\'s route, with no AuthLayout around it', () => {
+ it('paints its own full-viewport shell and a real card', async () => {
+ renderRoute();
+ const card = await screen.findByTestId('accept-invitation-page');
+
+ // The card chrome the page declares — present means the Tailwind classes
+ // survived the trip through the published build entry, not just that a
+ // exists.
+ for (const cls of ['rounded-xl', 'border', 'bg-card', 'p-8', 'shadow-sm']) {
+ expect(card.className, `card lost ${cls}`).toContain(cls);
+ }
+
+ // …and it centres itself in a full-viewport shell. This is what makes the
+ // bare route (no AuthLayout) render as a page rather than as a stray card
+ // pinned to the top-left, and it is the styling claim the ruling assumed.
+ const shell = card.parentElement as HTMLElement;
+ expect(shell).not.toBeNull();
+ for (const cls of ['min-h-svh', 'items-center', 'justify-center']) {
+ expect(shell.className, `shell lost ${cls}`).toContain(cls);
+ }
+ });
+
+ it('resolves the real Shadcn primitives — two working buttons, a real heading', async () => {
+ renderRoute();
+ await screen.findByTestId('accept-invitation-page');
+
+ const buttons = screen.getAllByRole('button');
+ expect(buttons).toHaveLength(2);
+ // cva-generated Shadcn classes: proof the real `Button` answered rather
+ // than a stub that would have rendered a bare, unstyled element.
+ for (const b of buttons) {
+ expect(b.className).toContain('inline-flex');
+ expect(b).toBeEnabled();
+ }
+ expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent('You have been invited');
+ });
+ });
+
+ describe('the two capabilities the thin console page did not have', () => {
+ it('shows which organization, which role and when the invitation expires', async () => {
+ renderRoute();
+ await screen.findByTestId('accept-invitation-page');
+
+ expect(authState.getInvitation).toHaveBeenCalledWith('inv_1');
+ // Organization — twice: in the sentence and in the detail row.
+ expect(screen.getAllByText(/Acme Corp/).length).toBeGreaterThanOrEqual(2);
+ expect(screen.getByText('Organization')).toBeInTheDocument();
+ expect(screen.getByText('Role')).toBeInTheDocument();
+ expect(screen.getByText('admin')).toBeInTheDocument();
+ expect(screen.getByText('Expires')).toBeInTheDocument();
+ expect(
+ screen.getByText(new Date(INVITATION.expiresAt).toLocaleDateString()),
+ ).toBeInTheDocument();
+ });
+
+ it('accept switches the user into the invited organization, then lands on /home', async () => {
+ const user = userEvent.setup();
+ renderRoute();
+ await screen.findByTestId('accept-invitation-page');
+
+ await user.click(screen.getByRole('button', { name: /Accept invitation/ }));
+
+ await waitFor(() => expect(authState.switchOrganization).toHaveBeenCalledWith('org_1'));
+ expect(authState.acceptInvitation).toHaveBeenCalledWith('inv_1');
+ // Order matters: the membership must exist before the switch is attempted.
+ expect((authState.acceptInvitation as ReturnType).mock.invocationCallOrder[0]).toBeLessThan(
+ (authState.switchOrganization as ReturnType).mock.invocationCallOrder[0],
+ );
+ await screen.findByTestId('home-sentinel');
+ expect(toastSuccess).toHaveBeenCalledWith('Invitation accepted');
+ });
+
+ it('decline rejects the invitation and lands on /organizations', async () => {
+ const user = userEvent.setup();
+ renderRoute();
+ await screen.findByTestId('accept-invitation-page');
+
+ await user.click(screen.getByRole('button', { name: /Decline/ }));
+
+ await waitFor(() => expect(authState.rejectInvitation).toHaveBeenCalledWith('inv_1'));
+ expect(authState.switchOrganization).not.toHaveBeenCalled();
+ await screen.findByTestId('orgs-sentinel');
+ });
+ });
+
+ describe('anonymous visitors still bounce to /login with a return path', () => {
+ it('emits a router-relative ?redirect= at the default mount', async () => {
+ authState.isAuthenticated = false;
+ renderRoute();
+
+ await screen.findByTestId('login-sentinel');
+ expect(seen).toContain(`/login?redirect=${encodeURIComponent('/accept-invitation/inv_1')}`);
+ // Nothing was fetched for an anonymous visitor.
+ expect(authState.getInvitation).not.toHaveBeenCalled();
+ });
+
+ it('does NOT carry the console mount prefix when served under a basename', async () => {
+ authState.isAuthenticated = false;
+ renderRoute({ basename: '/console' });
+
+ await screen.findByTestId('login-sentinel');
+ const redirects = seen.filter((p) => p.startsWith('/login'));
+ expect(redirects).not.toHaveLength(0);
+ // `LoginPage.withConsoleBase()` re-prefixes the mount, so a `?redirect=`
+ // that already contains `/console` produces `/console/console/…`. The
+ // pre-fix source read `window.location.pathname` and failed exactly here.
+ for (const r of redirects) {
+ expect(decodeURIComponent(r), 'redirect carries the mount prefix').not.toContain(
+ '/console/accept-invitation',
+ );
+ expect(decodeURIComponent(r)).toContain('/accept-invitation/inv_1');
+ }
+ });
+ });
+
+ it('takes its copy from `organization.accept.*` — the surviving namespace', async () => {
+ // The deleted thin page read `acceptInvitation.*`. Rendering in zh proves
+ // the pack is what answers (an inline English `defaultValue` could not),
+ // and that the answer comes from the namespace that stayed.
+ renderRoute({ lang: 'zh' });
+ await screen.findByTestId('accept-invitation-page');
+
+ const zh = builtInLocales.zh.organization.accept as Record;
+ expect(zh.accept).toBe('接受邀请');
+ expect(screen.getByRole('button', { name: zh.accept })).toBeInTheDocument();
+ expect(screen.getByText(zh.organization)).toBeInTheDocument();
+ expect(screen.getByText(zh.expiresAt)).toBeInTheDocument();
+ // The pack that used to serve this screen's other half is gone entirely.
+ expect((builtInLocales.zh as Record).acceptInvitation).toBeUndefined();
+ });
+});
diff --git a/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx b/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
index 9e3f847404..170446554a 100644
--- a/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
+++ b/packages/app-shell/src/console/organizations/manage/AcceptInvitationPage.tsx
@@ -3,10 +3,14 @@
*
* Standalone page for accepting or rejecting an organization invitation.
* Route: /accept-invitation/:invitationId
+ *
+ * Exported as `DefaultAcceptInvitationPage` and routed by `apps/console`
+ * (objectui#3811) as well as by downstream apps. The page paints its own
+ * full-viewport shell, so hosts mount it bare — no auth layout around it.
*/
import { useEffect, useState } from 'react';
-import { useNavigate, useParams } from 'react-router-dom';
+import { useLocation, useNavigate, useParams } from 'react-router-dom';
import { Button } from '@object-ui/components';
import { useAuth } from '@object-ui/auth';
import type { AuthInvitation } from '@object-ui/auth';
@@ -22,6 +26,7 @@ type InvitationWithOrg = AuthInvitation & {
export function AcceptInvitationPage() {
const { t } = useObjectTranslation();
const navigate = useNavigate();
+ const location = useLocation();
const { invitationId } = useParams<{ invitationId: string }>();
const { isAuthenticated, isLoading: isAuthLoading, getInvitation, acceptInvitation, rejectInvitation, switchOrganization } =
useAuth();
@@ -32,12 +37,21 @@ export function AcceptInvitationPage() {
const [isAccepting, setIsAccepting] = useState(false);
const [isDeclining, setIsDeclining] = useState(false);
- // Redirect to login if not authenticated
+ // Redirect to login if not authenticated.
+ //
+ // The return path comes from the ROUTER (`useLocation`), never from
+ // `window.location.pathname`: `?redirect=` is a basename-stripped path by
+ // contract — a host mounted under `` re-prefixes it
+ // before navigating (see `apps/console` `LoginPage.withConsoleBase`), so a
+ // window-derived path that already carries the mount doubles it into
+ // `/console/console/accept-invitation/…`. Under the default `/` mount the two
+ // spellings agree, which is why only the basename case can catch it
+ // (objectui#3811).
useEffect(() => {
if (!isAuthLoading && !isAuthenticated) {
- navigate('/login?redirect=' + encodeURIComponent(window.location.pathname));
+ navigate('/login?redirect=' + encodeURIComponent(location.pathname + location.search));
}
- }, [isAuthenticated, isAuthLoading, navigate]);
+ }, [isAuthenticated, isAuthLoading, navigate, location.pathname, location.search]);
// Fetch invitation details
useEffect(() => {
diff --git a/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx b/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx
index 3dedfd4768..65b269604e 100644
--- a/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx
+++ b/packages/i18n/src/__tests__/auth-namespace-3546.test.tsx
@@ -1,13 +1,14 @@
/**
- * The auth family — `auth` / `oauth` / `acceptInvitation`, objectui#3546 slice
- * three — resolves **from the locale packs, with a provider mounted**.
+ * The auth family — `auth` / `oauth`, objectui#3546 slice three — resolves
+ * **from the locale packs, with a provider mounted**.
*
* ## What was broken, precisely
*
- * `scripts/check-i18n-call-site-keys.mjs` measured 54 keys under these three
- * namespaces that a `t()` call site asks for and that NO pack defined — 54
- * distinct keys at 54 call sites (this slice happens to be 1:1; slice two was
- * 90 keys at 93 sites, so the denominator is measured, never counted by hand).
+ * `scripts/check-i18n-call-site-keys.mjs` measured 54 keys under three
+ * namespaces (`auth` 26, `oauth` 16, `acceptInvitation` 12) that a `t()` call
+ * site asks for and that NO pack defined — 54 distinct keys at 54 call sites
+ * (this slice happens to be 1:1; slice two was 90 keys at 93 sites, so the
+ * denominator is measured, never counted by hand).
* All 54 carried an inline `t(key, { defaultValue: 'English' })`, so this is the
* milder objectui#3517 class: English rendered correctly at every call site and
* **all ten languages were stuck on it**. Nothing rendered a raw key here —
@@ -15,6 +16,18 @@
* call sites in these namespaces found **zero** dead `t(key) || 'English'`
* fallbacks, which is why this slice touches no component file.
*
+ * ## Why 42 keys and not 54 (objectui#3811)
+ *
+ * The third namespace is gone. `acceptInvitation.*` served the console's own
+ * thin `/accept-invitation/:invitationId` page, which shipped alongside
+ * app-shell's richer page for the very same URL under `organization.accept.*`
+ * (backfilled by slice two) — one screen, two components, 26 keys of duplicated
+ * copy across ten packs, and console routed the weaker of the two. The
+ * maintainer ruled option A: console routes `DefaultAcceptInvitationPage`, the
+ * thin page is deleted, and its 12 keys go with it. The slice-three defect and
+ * its fix are unchanged for the 42 keys that remain; the removed 12 are pinned
+ * NEGATIVELY at the bottom of this file so the namespace cannot drift back in.
+ *
* Consequence for test design, same as slice two: `en` output was already
* correct before the change, so **an `en` assertion cannot discriminate before
* from after**. Every assertion that pins the fix is a non-`en` one; the `en`
@@ -22,9 +35,9 @@
*
* ## Why a provider is mounted
*
- * All six components behind these keys — `LoginPage`, `ForgotPasswordPage`,
- * `VerifyEmailPromptPage`, `DeviceAuthPage`, `OAuthConsentPage` and the
- * console's `AcceptInvitationPage` — bind `t` from a bare
+ * All five components behind these keys — `LoginPage`, `ForgotPasswordPage`,
+ * `VerifyEmailPromptPage`, `DeviceAuthPage` and `OAuthConsentPage` — bind `t`
+ * from a bare
* `useObjectTranslation()`. None sits behind a `createSafeTranslation` defaults
* map, so there is no provider-less path to be green on: without
* `I18nProvider`, i18next is not the thing answering and the test would
@@ -39,13 +52,17 @@
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
-import { existsSync, readFileSync } from 'node:fs';
+import { existsSync, readdirSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
import React from 'react';
import { I18nProvider, useObjectTranslation } from '../provider';
import { builtInLocales } from '../locales/index';
-/** The 54 keys this slice backfilled, grouped as the packs group them. */
+/**
+ * The keys this slice backfilled that are still live, grouped as the packs
+ * group them. 54 were backfilled; `acceptInvitation`'s 12 were retired whole
+ * with the console page that read them (objectui#3811), leaving 42.
+ */
const KEYS = [
// auth.login — the phone/OTP branch of the sign-in form (10)
'auth.login.emailOrPhoneLabel',
@@ -94,7 +111,19 @@ const KEYS = [
'oauth.consent.noRedirect',
'oauth.consent.failed',
'oauth.consent.footer',
- // acceptInvitation — the whole namespace is new (12)
+ // acceptInvitation — 12 more keys stood here until objectui#3811 deleted the
+ // namespace with its page. See RETIRED_ACCEPT_INVITATION_KEYS below.
+] as const;
+
+/**
+ * The third namespace slice three created, retired whole by objectui#3811.
+ *
+ * Listed by name rather than as a count so the pin is specific: any one of
+ * these coming back to any pack fails, whether it returns alone or as the
+ * complete set. That is the shape the defect had — a second namespace for one
+ * screen, each half correct when read on its own.
+ */
+const RETIRED_ACCEPT_INVITATION_KEYS = [
'acceptInvitation.title',
'acceptInvitation.description',
'acceptInvitation.accept',
@@ -167,18 +196,21 @@ beforeEach(() => {
window.localStorage.clear();
});
-describe('objectui#3546 slice three — the auth / oauth / acceptInvitation namespaces', () => {
- it('covers all ten packs and all fifty-four keys (guards the loops from emptying)', () => {
+describe('objectui#3546 slice three — the auth / oauth namespaces', () => {
+ it('covers all ten packs and all forty-two live keys (guards the loops from emptying)', () => {
expect(LANGS).toHaveLength(10);
- expect(KEYS).toHaveLength(54);
- expect(new Set(KEYS).size).toBe(54);
- // The measured split, so a later slice cannot quietly absorb keys from this one.
+ expect(KEYS).toHaveLength(42);
+ expect(new Set(KEYS).size).toBe(42);
+ // The measured split, so a later slice cannot quietly absorb keys from this
+ // one. `acceptInvitation: 12` stood here until objectui#3811 retired it; the
+ // slice total is still 54, and 42 + 12 is where that arithmetic lives.
const perNamespace = KEYS.reduce>((acc, k) => {
const ns = k.split('.')[0];
acc[ns] = (acc[ns] ?? 0) + 1;
return acc;
}, {});
- expect(perNamespace).toEqual({ auth: 26, oauth: 16, acceptInvitation: 12 });
+ expect(perNamespace).toEqual({ auth: 26, oauth: 16 });
+ expect(KEYS.length + RETIRED_ACCEPT_INVITATION_KEYS.length).toBe(54);
});
it.each(LANGS)('%s defines every auth-family key as a non-empty string', (lang) => {
@@ -201,8 +233,8 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name
}
}
expect(identical.sort()).toEqual(UNTRANSLATED_TOKENS);
- // 18 permitted pairs out of 486 — i.e. 52 of the 54 keys are translated in
- // every single pack. The set equality above is vacuous if the packs were
+ // 18 permitted pairs out of 378 — i.e. 40 of the 42 live keys are translated
+ // in every single pack. The set equality above is vacuous if the packs were
// empty, so pin the two facts it rests on separately: the exempt value is
// really the shared token, and a neighbour that embeds it is really not.
expect(UNTRANSLATED_TOKENS).toHaveLength(18);
@@ -251,6 +283,9 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name
missingKeys: Record;
missingPrefixes: Record;
};
+ // `acceptInvitation.` stays in this list on purpose even though the
+ // namespace no longer exists (objectui#3811): a revived call site would
+ // arrive here as a fresh baseline entry, and this is where that goes red.
const stillBaselined = Object.keys(baseline.missingKeys).filter((k) =>
['auth.', 'oauth.', 'acceptInvitation.'].some((ns) => k.startsWith(ns)),
);
@@ -276,7 +311,6 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name
['auth.verifyEmail.resendUnavailable', 'VerifyEmailPromptPage'],
['auth.device.disabledTitle', 'DeviceAuthPage'],
['oauth.consent.authorize', 'OAuthConsentPage'],
- ['acceptInvitation.accept', 'AcceptInvitationPage'],
];
it.each(['en', 'zh'])('%s resolves every sampled key from the pack', (lang) => {
@@ -298,20 +332,22 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name
expect(t('auth.device.disabledTitle')).toBe('未启用设备授权');
expect(t('oauth.consent.authorize')).toBe('授权');
expect(t('oauth.consent.willAllow')).toBe('此应用将能够:');
- expect(t('acceptInvitation.accept')).toBe('接受邀请');
});
it('the other eight packs answer in their own language on the user-facing buttons', () => {
// Spread across the four writing systems the packs cover, so a single
// pack silently reverting to English cannot hide behind zh.
+ // fr and ko carried an `acceptInvitation.*` button each until objectui#3811
+ // retired that namespace; both moved to another user-facing button in the
+ // same pack, so the spread across writing systems is unchanged.
const cases: Array<[lang: string, key: string, expected: string]> = [
- ['fr', 'acceptInvitation.accept', "Accepter l'invitation"],
+ ['fr', 'oauth.consent.deny', 'Refuser'],
['de', 'auth.login.usePhoneOtpText', 'Mit Bestätigungscode anmelden'],
['es', 'oauth.consent.footer', 'Puede revocar el acceso en cualquier momento desde la configuración de su cuenta.'],
['pt', 'auth.forgotPassword.usePhoneResetText', 'Redefinir com código por SMS'],
['ru', 'auth.forgotPassword.resetButton', 'Сбросить пароль'],
['ja', 'oauth.consent.scope.profile', '基本プロフィール(名前、画像)を読み取る'],
- ['ko', 'acceptInvitation.declining', '거절 중…'],
+ ['ko', 'auth.login.sendOtpButton', '코드 받기'],
['ar', 'auth.device.disabledTitle', 'تفويض الأجهزة غير مُمكَّن'],
];
for (const [lang, key, expected] of cases) {
@@ -376,22 +412,96 @@ describe('objectui#3546 slice three — the auth / oauth / acceptInvitation name
});
});
- it('`acceptInvitation` and `organization.accept` stay separate namespaces', () => {
- // Two components serve `/accept-invitation/:invitationId`: the console's own
- // thin page (this namespace) and app-shell's richer one exported as
- // `DefaultAcceptInvitationPage` (`organization.accept.*`, backfilled by
- // slice two). Merging them would silently repoint whichever page a
- // consumer actually routes. Same-English keys share the same translation on
- // purpose; the keys do not.
- expect(at(builtInLocales.en, 'acceptInvitation.accept')).toBe('Accept invitation');
- expect(at(builtInLocales.en, 'organization.accept.accept')).toBe('Accept invitation');
- expect(at(builtInLocales.fr, 'acceptInvitation.accept')).toBe(
- at(builtInLocales.fr, 'organization.accept.accept'),
- );
- // …and the two pages' own copy differs, which is why both namespaces exist.
- expect(at(builtInLocales.en, 'acceptInvitation.acceptFailed')).toBe('Could not accept');
- expect(at(builtInLocales.en, 'organization.accept.acceptFailed')).toBe('Failed to accept invitation');
- expect(at(builtInLocales.en, 'acceptInvitation.expiresAt')).toBeUndefined();
+ describe('`/accept-invitation/:invitationId` has ONE namespace — objectui#3811', () => {
+ /*
+ * This block replaces the assertion that used to pin the opposite fact
+ * ("`acceptInvitation` and `organization.accept` stay separate namespaces").
+ * That assertion was correct and deliberate: slice three found two
+ * components shipped for one URL — the console's own thin page reading
+ * `acceptInvitation.*`, and app-shell's richer page exported as
+ * `DefaultAcceptInvitationPage` reading `organization.accept.*` (slice two)
+ * — and pinned the two-namespace fact in place rather than guessing which
+ * one should win. objectui#3811 escalated that observation and the
+ * maintainer ruled: console routes the richer page, the thin page and its
+ * namespace are deleted.
+ *
+ * So the pin inverts. It has to be a NEGATIVE pin, because the failure it
+ * guards is silent by construction: a second namespace for one screen reads
+ * as perfectly healthy from inside either half — full key parity, real
+ * translations in ten packs, every value gate green — and is only visible
+ * when both halves are laid side by side, which no gate does. Restoring any
+ * `acceptInvitation.*` key to any pack must go red here, and the surviving
+ * namespace must keep serving the screen.
+ */
+ it('no pack defines any `acceptInvitation.*` key, and the namespace itself is gone', () => {
+ expect(RETIRED_ACCEPT_INVITATION_KEYS).toHaveLength(12);
+ const revived: string[] = [];
+ for (const lang of LANGS) {
+ // The namespace root, not just its keys: an empty `acceptInvitation: {}`
+ // left behind is the shape a partial revert produces.
+ if (at(builtInLocales[lang], 'acceptInvitation') !== undefined) {
+ revived.push(`${lang} :: acceptInvitation (namespace root)`);
+ }
+ for (const key of RETIRED_ACCEPT_INVITATION_KEYS) {
+ if (at(builtInLocales[lang], key) !== undefined) revived.push(`${lang} :: ${key}`);
+ }
+ }
+ expect(revived).toEqual([]);
+ });
+
+ it('`organization.accept.*` is the one namespace this screen reads, in all ten packs', () => {
+ // The surviving half must not be hollowed out by the deletion — the two
+ // keys below are the ones the thin page never had, i.e. the capabilities
+ // the ruling was bought with (org / role / expiry on screen).
+ for (const lang of LANGS) {
+ expect(typeof at(builtInLocales[lang], 'organization.accept.accept'), lang).toBe('string');
+ expect(typeof at(builtInLocales[lang], 'organization.accept.expiresAt'), lang).toBe('string');
+ expect(typeof at(builtInLocales[lang], 'organization.accept.organization'), lang).toBe('string');
+ expect(typeof at(builtInLocales[lang], 'organization.accept.role'), lang).toBe('string');
+ }
+ expect(at(builtInLocales.en, 'organization.accept.accept')).toBe('Accept invitation');
+ expect(at(builtInLocales.en, 'organization.accept.acceptFailed')).toBe(
+ 'Failed to accept invitation',
+ );
+ expect(at(builtInLocales.en, 'organization.accept.expiresAt')).toBe('Expires');
+ });
+
+ it('neither consuming package asks `t()` for an `acceptInvitation.*` key any more', () => {
+ // Scoped to the two trees that ever read this namespace — the console
+ // (the deleted thin page) and app-shell (the page that replaced it).
+ //
+ // Why pin the READER at all, when `check:i18n-keys` already fails on a
+ // `t()` key that no pack defines: that gate reports it as "key missing
+ // from `en`", and the obvious repair for a missing key is to backfill it
+ // — which is precisely the move that rebuilds the second namespace. This
+ // assertion is where the reason lives, so the next author reads "there is
+ // one namespace for this screen" instead of "one pack is behind".
+ const roots = ['apps/console/src', 'packages/app-shell/src'];
+ const offenders: string[] = [];
+ const walk = (dir: string) => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (entry.name === 'node_modules' || entry.name === 'dist') continue;
+ walk(full);
+ } else if (/\.tsx?$/.test(entry.name)) {
+ const src = readFileSync(full, 'utf8');
+ // Call-SHAPED on purpose (`t('acceptInvitation.…` /
+ // ``t(`acceptInvitation.${…``), not a bare mention of the string:
+ // this file and `apps/console/src/App.tsx` both name the retired
+ // namespace in prose to explain why it is retired, and a naive
+ // substring scan would score its own documentation as the offence.
+ if (/\bt\(\s*['"`]acceptInvitation\./.test(src)) offenders.push(full);
+ }
+ }
+ };
+ for (const root of roots) {
+ const abs = join(process.cwd(), root);
+ expect(existsSync(abs), `scan root missing: ${abs}`).toBe(true);
+ walk(abs);
+ }
+ expect(offenders).toEqual([]);
+ });
});
it('`oauth` is its own namespace, not a branch of `auth`', () => {
diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts
index 11c9e8a821..a0d7ba9d47 100644
--- a/packages/i18n/src/locales/ar.ts
+++ b/packages/i18n/src/locales/ar.ts
@@ -2091,22 +2091,6 @@ const ar = {
footer: "يمكنك إلغاء الوصول في أي وقت من إعدادات حسابك.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "قبول دعوة المؤسسة",
- description: "تمت دعوتك للانضمام إلى مؤسسة.",
- accept: "قبول الدعوة",
- accepting: "جارٍ القبول…",
- accepted: "تم قبول الدعوة",
- acceptFailed: "تعذّر القبول",
- decline: "رفض",
- declining: "جارٍ الرفض…",
- declined: "تم رفض الدعوة",
- declineFailed: "تعذّر الرفض",
- invalidTitle: "رابط دعوة غير صالح",
- invalidDescription: "معرّف الدعوة غير موجود في عنوان URL.",
- },
profile: {
title: "الملف الشخصي",
subtitle: "إدارة إعدادات حسابك",
diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts
index 8a72a60f7a..deade11f13 100644
--- a/packages/i18n/src/locales/de.ts
+++ b/packages/i18n/src/locales/de.ts
@@ -2087,22 +2087,6 @@ const de = {
footer: "Sie können den Zugriff jederzeit in Ihren Kontoeinstellungen widerrufen.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "Organisationseinladung annehmen",
- description: "Sie wurden eingeladen, einer Organisation beizutreten.",
- accept: "Einladung annehmen",
- accepting: "Nehme an…",
- accepted: "Einladung angenommen",
- acceptFailed: "Annehmen fehlgeschlagen",
- decline: "Ablehnen",
- declining: "Lehne ab…",
- declined: "Einladung abgelehnt",
- declineFailed: "Ablehnen fehlgeschlagen",
- invalidTitle: "Ungültiger Einladungslink",
- invalidDescription: "In der URL fehlt die Einladungs-ID.",
- },
profile: {
title: "Profil",
subtitle: "Verwalten Sie Ihre Kontoeinstellungen",
diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts
index 9818212d17..65b1eba36b 100644
--- a/packages/i18n/src/locales/en.ts
+++ b/packages/i18n/src/locales/en.ts
@@ -2351,24 +2351,6 @@ const en = {
footer: 'You can revoke access at any time from your account settings.',
},
},
- // The console's own `/accept-invitation/:invitationId` page. Distinct from
- // `organization.accept.*`, which belongs to app-shell's richer page for the
- // same route (it fetches the invitation and shows org/role/expiry). Two
- // components, two namespaces — see the note in the slice-three test.
- acceptInvitation: {
- title: 'Accept organization invitation',
- description: "You've been invited to join an organization.",
- accept: 'Accept invitation',
- accepting: 'Accepting…',
- accepted: 'Invitation accepted',
- acceptFailed: 'Could not accept',
- decline: 'Decline',
- declining: 'Declining…',
- declined: 'Invitation declined',
- declineFailed: 'Could not decline',
- invalidTitle: 'Invalid invitation link',
- invalidDescription: 'The invitation id is missing from the URL.',
- },
profile: {
title: 'Profile',
subtitle: 'Manage your account settings',
diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts
index cdf1e2c83f..7b218cb910 100644
--- a/packages/i18n/src/locales/es.ts
+++ b/packages/i18n/src/locales/es.ts
@@ -2091,22 +2091,6 @@ const es = {
footer: "Puede revocar el acceso en cualquier momento desde la configuración de su cuenta.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "Aceptar la invitación a la organización",
- description: "Te han invitado a unirte a una organización.",
- accept: "Aceptar invitación",
- accepting: "Aceptando…",
- accepted: "Invitación aceptada",
- acceptFailed: "No se pudo aceptar",
- decline: "Rechazar",
- declining: "Rechazando…",
- declined: "Invitación rechazada",
- declineFailed: "No se pudo rechazar",
- invalidTitle: "Enlace de invitación inválido",
- invalidDescription: "Falta el identificador de la invitación en la URL.",
- },
profile: {
title: "Perfil",
subtitle: "Gestione la configuración de su cuenta",
diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts
index 573f3f54e9..947e8aedcb 100644
--- a/packages/i18n/src/locales/fr.ts
+++ b/packages/i18n/src/locales/fr.ts
@@ -2087,22 +2087,6 @@ const fr = {
footer: "Vous pouvez révoquer cet accès à tout moment dans les paramètres de votre compte.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "Accepter l'invitation à l'organisation",
- description: "Vous avez été invité à rejoindre une organisation.",
- accept: "Accepter l'invitation",
- accepting: "Acceptation…",
- accepted: "Invitation acceptée",
- acceptFailed: "Impossible d'accepter",
- decline: "Refuser",
- declining: "Refus…",
- declined: "Invitation refusée",
- declineFailed: "Impossible de refuser",
- invalidTitle: "Lien d'invitation invalide",
- invalidDescription: "L'identifiant d'invitation est absent de l'URL.",
- },
profile: {
title: "Profil",
subtitle: "Gérez les paramètres de votre compte",
diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts
index 9eae0a2bb7..9bae1ad021 100644
--- a/packages/i18n/src/locales/ja.ts
+++ b/packages/i18n/src/locales/ja.ts
@@ -2087,22 +2087,6 @@ const ja = {
footer: "アクセス権はアカウント設定からいつでも取り消せます。",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "組織への招待を承諾",
- description: "組織に招待されています。",
- accept: "招待を承諾",
- accepting: "承諾中…",
- accepted: "招待を承諾しました",
- acceptFailed: "承諾できませんでした",
- decline: "辞退",
- declining: "辞退中…",
- declined: "招待を辞退しました",
- declineFailed: "辞退できませんでした",
- invalidTitle: "無効な招待リンク",
- invalidDescription: "URL に招待 ID が含まれていません。",
- },
profile: {
title: "プロフィール",
subtitle: "アカウント設定を管理します",
diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts
index cf52e814ce..0e443f1fa0 100644
--- a/packages/i18n/src/locales/ko.ts
+++ b/packages/i18n/src/locales/ko.ts
@@ -2087,22 +2087,6 @@ const ko = {
footer: "계정 설정에서 언제든지 액세스를 취소할 수 있습니다.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "조직 초대 수락",
- description: "조직에 초대되었습니다.",
- accept: "초대 수락",
- accepting: "수락 중…",
- accepted: "초대를 수락함",
- acceptFailed: "수락할 수 없습니다",
- decline: "거절",
- declining: "거절 중…",
- declined: "초대를 거절함",
- declineFailed: "거절할 수 없습니다",
- invalidTitle: "잘못된 초대 링크",
- invalidDescription: "URL에 초대 ID가 없습니다.",
- },
profile: {
title: "프로필",
subtitle: "계정 설정을 관리하세요",
diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts
index 756f99e37a..3b17b68abc 100644
--- a/packages/i18n/src/locales/pt.ts
+++ b/packages/i18n/src/locales/pt.ts
@@ -2086,22 +2086,6 @@ const pt = {
footer: "Você pode revogar o acesso a qualquer momento nas configurações da sua conta.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "Aceitar convite da organização",
- description: "Você foi convidado para entrar em uma organização.",
- accept: "Aceitar convite",
- accepting: "Aceitando…",
- accepted: "Convite aceito",
- acceptFailed: "Não foi possível aceitar",
- decline: "Recusar",
- declining: "Recusando…",
- declined: "Convite recusado",
- declineFailed: "Não foi possível recusar",
- invalidTitle: "Link de convite inválido",
- invalidDescription: "O identificador do convite não está presente na URL.",
- },
profile: {
title: "Perfil",
subtitle: "Gerencie as configurações da sua conta",
diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts
index 2172b18807..a511c5b921 100644
--- a/packages/i18n/src/locales/ru.ts
+++ b/packages/i18n/src/locales/ru.ts
@@ -2093,22 +2093,6 @@ const ru = {
footer: "Вы можете отозвать доступ в любое время в настройках аккаунта.",
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: "Принять приглашение в организацию",
- description: "Вас пригласили присоединиться к организации.",
- accept: "Принять приглашение",
- accepting: "Принятие…",
- accepted: "Приглашение принято",
- acceptFailed: "Не удалось принять",
- decline: "Отклонить",
- declining: "Отклонение…",
- declined: "Приглашение отклонено",
- declineFailed: "Не удалось отклонить",
- invalidTitle: "Недействительная ссылка приглашения",
- invalidDescription: "В URL отсутствует идентификатор приглашения.",
- },
profile: {
title: "Профиль",
subtitle: "Управляйте настройками своей учётной записи",
diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts
index f4d19378eb..8ad48a72c9 100644
--- a/packages/i18n/src/locales/zh.ts
+++ b/packages/i18n/src/locales/zh.ts
@@ -2267,22 +2267,6 @@ const zh = {
footer: '您可以随时在账户设置中撤销访问权限。',
},
},
- // objectui#3546 slice three — the console's own /accept-invitation page.
- // Distinct from `organization.accept.*` (app-shell's richer page, same route).
- acceptInvitation: {
- title: '接受组织邀请',
- description: '您受邀加入一个组织。',
- accept: '接受邀请',
- accepting: '接受中…',
- accepted: '邀请已接受',
- acceptFailed: '接受失败',
- decline: '拒绝',
- declining: '拒绝中…',
- declined: '邀请已拒绝',
- declineFailed: '拒绝失败',
- invalidTitle: '无效的邀请链接',
- invalidDescription: 'URL 中缺少邀请 ID。',
- },
profile: {
title: '个人资料',
subtitle: '管理您的账户设置',