From ee433e2d374444be9a92115188510b03bf1fe52f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 10 Aug 2026 15:12:42 +0000 Subject: [PATCH] fix(console): route /accept-invitation to DefaultAcceptInvitationPage (#3811) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two components shipped for `/accept-invitation/:invitationId` under two i18n namespaces, and console routed the weaker one: a thin accept/decline pair that never showed which organization, which role or when the link expired, and left the user in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — does all of that plus `switchOrganization` on accept. Maintainer ruling on #3811 is option A: console routes the richer page; the thin page and its `acceptInvitation.*` namespace (12 keys x 10 packs) are deleted. Nothing published is removed — `DefaultAcceptInvitationPage` keeps its export and becomes the routed one. The #3546 slice-three assertion that pinned "two namespaces stay separate" now pins the inverse, negatively: no pack may define any of the 12 retired keys or an emptied namespace root, and neither consuming package may ask `t()` for one. One repair was required before the swap was safe. `?redirect=` is a basename-stripped path by contract here (`LoginPage.withConsoleBase` re-prefixes the mount); the thin page built it from the route param and app-shell's built it from `window.location.pathname`, which already carries the mount. A console under `` would have returned the user to `/console/console/accept-invitation/…`. It now reads the router, like every other producer of that parameter in this repo. Measured, not argued: the basename case fails on the pre-fix source and passes after. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3 --- .../accept-invitation-single-route-3811.md | 15 + apps/console/src/App.tsx | 16 +- .../src/pages/auth/AcceptInvitationPage.tsx | 150 ---------- .../__tests__/AcceptInvitationRoute.test.tsx | 269 ++++++++++++++++++ .../manage/AcceptInvitationPage.tsx | 22 +- .../__tests__/auth-namespace-3546.test.tsx | 190 ++++++++++--- packages/i18n/src/locales/ar.ts | 16 -- packages/i18n/src/locales/de.ts | 16 -- packages/i18n/src/locales/en.ts | 18 -- packages/i18n/src/locales/es.ts | 16 -- packages/i18n/src/locales/fr.ts | 16 -- packages/i18n/src/locales/ja.ts | 16 -- packages/i18n/src/locales/ko.ts | 16 -- packages/i18n/src/locales/pt.ts | 16 -- packages/i18n/src/locales/ru.ts | 16 -- packages/i18n/src/locales/zh.ts | 16 -- 16 files changed, 466 insertions(+), 358 deletions(-) create mode 100644 .changeset/accept-invitation-single-route-3811.md delete mode 100644 apps/console/src/pages/auth/AcceptInvitationPage.tsx create mode 100644 apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx diff --git a/.changeset/accept-invitation-single-route-3811.md b/.changeset/accept-invitation-single-route-3811.md new file mode 100644 index 0000000000..e4e0ce0313 --- /dev/null +++ b/.changeset/accept-invitation-single-route-3811.md @@ -0,0 +1,15 @@ +--- +'@object-ui/console': minor +'@object-ui/i18n': minor +'@object-ui/app-shell': patch +--- + +`/accept-invitation/:invitationId` is one route, one component, one namespace — the console now renders the invitation page that actually shows you the invitation + +Two components shipped for this single URL. The console routed its own thin page, which offered nothing but an Accept and a Decline button: it never told the user which organization they had been invited to, in what role, or when the link expires, and accepting left them in whatever organization they were already in. App-shell's page — exported as `DefaultAcceptInvitationPage`, routed by nobody — fetches the invitation, shows the organization, the role and the expiry date, and switches the user into that organization on accept. Console now routes that one. The thin page is deleted. + +Behind them sat two i18n namespaces for one screen: `acceptInvitation.*` (12 keys) for the thin page and `organization.accept.*` (14) for the richer one, both freshly translated into ten languages by different slices of objectui#3546, neither wrong when read on its own. That is 26 keys of duplicated copy with no gate to tell the next author which of the two to edit — the failure mode this repo already has an uncollected precedent for. `acceptInvitation.*` is removed from all ten packs, and its absence is pinned negatively so it cannot drift back: the slice-three test now asserts that no pack defines any of the 12 retired keys (nor an emptied namespace root left by a partial revert), and that neither consuming package asks `t()` for one. + +One behavior needed repairing before the swap was safe rather than after. `?redirect=` is a basename-stripped path by contract in this console — `LoginPage` re-prefixes it with the mount before navigating — and the thin page built it from the route param, correctly. App-shell's page built it from `window.location.pathname`, which already carries the mount, so a console served under a `` would have sent the user back to `/console/console/accept-invitation/…` after signing in. It now reads the router (`useLocation`), like every other producer of that parameter in this repo. Under the default `/` mount the two spellings are identical, which is why only a basename case can see the difference; that case is now a test. + +Nothing published was removed: `DefaultAcceptInvitationPage` keeps its export and simply becomes the routed implementation. Downstream apps mounting it get the redirect fix and are otherwise untouched. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index 2201d1126e..a577cbc688 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -36,6 +36,7 @@ import { DefaultMembersPage, DefaultInvitationsPage, DefaultSettingsPage, + DefaultAcceptInvitationPage, DefaultAiChatPage, StudioDesignSurface, BuilderLanding, @@ -62,7 +63,6 @@ import { VerifyEmailPromptPage } from './pages/auth/VerifyEmailPromptPage'; import { SetupPage } from './pages/auth/SetupPage'; import { OAuthConsentPage } from './pages/auth/OAuthConsentPage'; import { DeviceAuthPage } from './pages/auth/DeviceAuthPage'; -import { AcceptInvitationPage } from './pages/auth/AcceptInvitationPage'; const AUTH_URL = `${import.meta.env.VITE_SERVER_URL || ''}/api/v1/auth`; @@ -183,9 +183,21 @@ export function App() { } /> } /> } /> + {/* + * Invitation acceptance — app-shell's `DefaultAcceptInvitationPage` + * (objectui#3811). This route used to render a console-local thin + * page that offered only accept/decline; the app-shell page fetches + * the invitation and shows which organization, which role and when + * it expires, then switches the user into that organization on + * accept. Both were shipped for the same URL under two i18n + * namespaces; the thin page and its `acceptInvitation.*` keys are + * gone, `organization.accept.*` is the only namespace for this + * screen. Rendered bare (no `AuthLayout`) — the page paints its own + * full-viewport shell, like the other self-shelled auth surfaces. + */} } + element={} /> {/* * Public anonymous form — rendered OUTSIDE ProtectedRoute so diff --git a/apps/console/src/pages/auth/AcceptInvitationPage.tsx b/apps/console/src/pages/auth/AcceptInvitationPage.tsx deleted file mode 100644 index 93fbab50c8..0000000000 --- a/apps/console/src/pages/auth/AcceptInvitationPage.tsx +++ /dev/null @@ -1,150 +0,0 @@ -/** - * AcceptInvitationPage — /accept-invitation/:invitationId surface. - * - * Ported from `framework/apps/account/src/routes/accept-invitation.$invitationId.tsx`. - * - * Anonymous users get bounced to /login with `?redirect=` so they come - * back here once signed in. Authenticated users get an accept/decline - * prompt; both actions hit `useAuth()` and land on /organizations. - */ - -import { useEffect, useState } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { toast } from 'sonner'; -import { useAuth } from '@object-ui/auth'; -import { useObjectTranslation } from '@object-ui/i18n'; -import { - Button, - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from '@object-ui/components'; - -export function AcceptInvitationPage() { - const { t } = useObjectTranslation(); - const { invitationId } = useParams<{ invitationId: string }>(); - const navigate = useNavigate(); - const { - user, - isLoading, - acceptInvitation, - rejectInvitation, - refreshOrganizations, - } = useAuth(); - - const [accepting, setAccepting] = useState(false); - const [rejecting, setRejecting] = useState(false); - - // Anonymous users can't accept — bounce to /login with a return URL. - useEffect(() => { - if (isLoading) return; - if (!user && invitationId) { - const next = `/accept-invitation/${invitationId}`; - navigate(`/login?redirect=${encodeURIComponent(next)}`, { replace: true }); - } - }, [isLoading, user, invitationId, navigate]); - - if (!invitationId) { - return ( -
- - - - {t('acceptInvitation.invalidTitle', { - defaultValue: 'Invalid invitation link', - })} - - - {t('acceptInvitation.invalidDescription', { - defaultValue: 'The invitation id is missing from the URL.', - })} - - - -
- ); - } - - const handleAccept = async () => { - setAccepting(true); - try { - await acceptInvitation(invitationId); - await refreshOrganizations().catch(() => undefined); - toast.success( - t('acceptInvitation.accepted', { defaultValue: 'Invitation accepted' }), - ); - navigate('/organizations'); - } catch (err) { - toast.error( - t('acceptInvitation.acceptFailed', { defaultValue: 'Could not accept' }), - { description: (err as Error).message }, - ); - } finally { - setAccepting(false); - } - }; - - const handleReject = async () => { - setRejecting(true); - try { - await rejectInvitation(invitationId); - toast.success( - t('acceptInvitation.declined', { defaultValue: 'Invitation declined' }), - ); - navigate('/organizations'); - } catch (err) { - toast.error( - t('acceptInvitation.declineFailed', { defaultValue: 'Could not decline' }), - { description: (err as Error).message }, - ); - } finally { - setRejecting(false); - } - }; - - return ( -
-
- - - - {t('acceptInvitation.title', { - defaultValue: 'Accept organization invitation', - })} - - - {t('acceptInvitation.description', { - defaultValue: "You've been invited to join an organization.", - })} - - - - - - - -
-
- ); -} - -export default AcceptInvitationPage; diff --git a/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx b/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx new file mode 100644 index 0000000000..b504207a62 --- /dev/null +++ b/apps/console/src/pages/auth/__tests__/AcceptInvitationRoute.test.tsx @@ -0,0 +1,269 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `/accept-invitation/:invitationId` renders app-shell's + * `DefaultAcceptInvitationPage` — objectui#3811. + * + * ## What this file exists to prove + * + * Two components shipped for this one URL. Console routed its own thin page + * (`apps/console/src/pages/auth/AcceptInvitationPage.tsx`, deleted with this + * change) which offered a bare accept/decline pair; app-shell's page was + * exported as `DefaultAcceptInvitationPage` and routed by nobody, even though + * it fetches the invitation, shows the organization / role / expiry, and + * switches the user into that organization on accept. The maintainer ruling on + * #3811 is option A: console routes the richer page and the thin one — plus its + * `acceptInvitation.*` namespace, 12 keys in each of ten packs — is deleted. + * + * So this file measures three things the swap could get wrong, and it measures + * them through the SHIPPED export (`@object-ui/app-shell`), not a local copy: + * + * 1. **It renders coherently in console's route.** The ruling presumed the + * styling was compatible; that presumption is checked here rather than + * assumed. Console mounts this route BARE — no `AuthLayout` wrapper, the + * same shape `App.tsx` gives it (`App.tsx`, `/accept-invitation/…`) — so + * the page has to paint its own full-viewport shell, which is exactly what + * the sibling self-shelled auth surfaces do. The real + * `@object-ui/components` `Button` and real lucide icons are used, NOT + * passthrough stubs: a stubbed primitive can only tell you the component + * called it, never that the result is a coherent screen. + * 2. **The two capabilities the thin page lacked are live** — org / role / + * expiry on screen, and `switchOrganization` on accept. These are the + * user-visible gains the ruling buys, so they are pinned, not assumed. + * 3. **The anonymous bounce still produces a router-relative `?redirect=`.** + * Console's `?redirect=` contract is a basename-STRIPPED path — `LoginPage` + * re-prefixes it with `withConsoleBase()` before a full-page navigation, + * and `App.tsx`'s own `LoginRedirect` produces it from `useLocation()`. A + * producer reading `window.location.pathname` instead hands over a path + * that already carries the mount prefix, and the console then doubles it. + * The thin page built the path from the route param and was correct; the + * app-shell page read `window.location.pathname`. The `basename` case below + * is the one that separates them — it fails on the pre-fix source with + * `/console/accept-invitation/inv_1` and passes on the fixed one. Under the + * default `/` mount both spellings agree, which is why the plain case alone + * would have shipped the regression green. + * + * Locale coverage lives with the packs (`packages/i18n/src/__tests__/ + * auth-namespace-3546.test.tsx`); what is asserted here is only that this + * screen's copy now arrives through `organization.accept.*`. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { BrowserRouter, Routes, Route, useLocation } from 'react-router-dom'; +import { I18nProvider, builtInLocales } from '@object-ui/i18n'; + +/** + * Only `useAuth` is replaced — everything else in `@object-ui/auth` stays real, + * because app-shell's barrel pulls the module in for its own providers and a + * whole-module stub would break the import rather than the component. + */ +let authState: Record; +vi.mock('@object-ui/auth', async (importOriginal) => ({ + ...(await importOriginal>()), + useAuth: () => authState, +})); + +const toastSuccess = vi.fn(); +const toastError = vi.fn(); +vi.mock('sonner', async (importOriginal) => { + const actual = await importOriginal>(); + 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: '管理您的账户设置',