diff --git a/.changeset/12034-console-i18n-after-signin.md b/.changeset/12034-console-i18n-after-signin.md new file mode 100644 index 0000000000..3db3ff42f8 --- /dev/null +++ b/.changeset/12034-console-i18n-after-signin.md @@ -0,0 +1,12 @@ +--- +'@object-ui/console': patch +--- + +The console's sign-in page no longer reads `/api/v1/i18n`, and the application's translations and locale list load with the session's credentials once signed in (objectui#12034). + +The two loaders the console hands to `I18nProvider` used to run as soon as the page loaded, before the session was known, with a bare `fetch`. So the sign-in page read `/api/v1/i18n/translations/:locale` and `/api/v1/i18n/locales` as an anonymous caller, and a signed-in page load sent no bearer with them. Where no session cookie rides along (a console built with an absolute `VITE_SERVER_URL`), a framework that refuses an anonymous `/i18n` read (objectstack-ai/objectstack#22432) answers both with 401, and a signed-in user's application labels stay in the language they were authored in. + +- **Signed out:** neither loader sends a request. The sign-in page renders from the built-in language packs. +- **Signed in:** both loaders wait for the console's `AuthProvider` to answer for the page load, then read through `withSettleSignal(createAuthenticatedFetch())`, the request path the data adapter uses (bearer, `X-Tenant-ID`, `Accept-Language`, and the in-flight count `window.__objectui` reports). Every console sign-in ends in a full-page navigation, so the page load after sign-in is the one that reads them. + +Nothing published changes: no export, prop or language-pack key is added, and `I18nProvider` is used through its existing `loadLanguage` and `loadLocales` props. diff --git a/apps/console/src/App.tsx b/apps/console/src/App.tsx index dc60f00bc0..554919c48f 100644 --- a/apps/console/src/App.tsx +++ b/apps/console/src/App.tsx @@ -41,6 +41,7 @@ import { } from '@object-ui/app-shell'; import { AppContent } from './AppContent'; +import { publishAuthState } from './i18nSession'; import { FaviconSync } from './components/FaviconSync'; import { RootLandingRedirect } from './components/RootLandingRedirect'; import { ProtectedRoute } from './components/ProtectedRoute'; @@ -172,8 +173,11 @@ export function App() { () => createObjectStackUploadAdapter({ baseUrl: SERVER_URL, fetchImpl: uploadFetch }), [uploadFetch], ); + // `onAuthStateChange`: the session answer the two `/i18n` loaders `main.tsx` + // hands to `I18nProvider` wait for, so a signed-out page load never reads + // `/i18n` and a signed-in one reads it with the session (objectui#12034). return ( - + {/* objectui#7482 — no `position` override: the console takes `ConsoleToaster`'s own documented top-right anchor. diff --git a/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx b/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx new file mode 100644 index 0000000000..482ffd6ff9 --- /dev/null +++ b/apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx @@ -0,0 +1,404 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * objectui#12034: the sign-in page renders from the built-in language packs, + * and the application's translations load after sign-in, with the session. + * + * ## The server this file talks to + * + * A fake framework whose `/api/v1/i18n/*` answers `401 UNAUTHENTICATED` to any + * caller without the session's bearer: the head of + * objectstack-ai/objectstack#22432, which gives that domain the anonymous + * refusal every other dispatcher domain has. It knows the bearer only. That is + * the deployment where no session cookie rides along (a console built with an + * absolute `VITE_SERVER_URL`). On a same-origin cookie deployment the old bare + * `fetch` was served after sign-in by the cookie; it never sent the bearer. + * + * ## The tree + * + * The REAL `App`: its `AuthProvider` props (so its `onAuthStateChange`), its + * route table, the real `LoginPage` and the real `ProtectedRoute`. The only + * auth substitution is the network client the provider talks to. The + * `I18nProvider` mount is `main.tsx`'s, transcribed, with the real loaders: + * that module boots the page as a side effect of being imported, so a test + * cannot import it. The application route's element (`AppContent`) is a probe + * that draws one application label (`useObjectLabel`, the reader the app's + * labels go through) and one built-in-pack string. Everything else `App` + * imports and this card does not touch is stubbed, as in the other `App` + * tests in this folder. + * + * ## The pins, one per pin of the card + * + * 1. Signed out on the sign-in page, through the sign-in itself: no `/i18n` + * request, and the page is drawn in Chinese from the built-in pack. + * 2. Signed in: the application's label comes from the authenticated + * `/i18n` reads. Asserted on the RENDERED text of one mounted heading, + * which changes from the authored literal to the translation when the + * read lands (objectui#10382: a re-render here has been a no-op before, + * so a request count alone proves nothing). + * 3. The whole sign-in → app path against that server shows no raw key, on + * any frame, from the first render of the sign-in page to the translated + * application label after sign-in. A positive control shows the recorder + * catches a key that is drawn raw. + * + * Between the two boots the console does what it does after sign-in: a + * full-page navigation (`window.location.assign`, pinned by + * `authExitBasename.test.tsx`). Here that is the end of one render and the + * start of the next, with the session the sign-in established. + */ + +import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'; +import { StrictMode, type ReactNode } from 'react'; +import { render, screen, cleanup, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +const { passthrough, stub, authClient } = vi.hoisted(() => ({ + passthrough: ({ children }: { children?: ReactNode }) => <>{children}, + stub: (testid: string) => () =>
, + /** The network client the REAL `AuthProvider` talks to, set per boot. */ + authClient: { current: null as unknown }, +})); + +vi.mock('@object-ui/app-shell', async (importOriginal) => ({ + ...(await importOriginal>()), + ConsoleShell: passthrough, + ConsoleToaster: () => null, + LoadingScreen: stub('loading-screen'), + RedirectWithSplash: stub('redirect-with-splash'), + RequireAiSurface: passthrough, + SystemRedirect: () => null, + DefaultHomeLayout: passthrough, + DefaultHomePage: stub('home-page'), + DefaultOrganizationsLayout: passthrough, + DefaultOrganizationsPage: stub('organizations-page'), + DefaultOrganizationLayout: stub('organization-layout'), + DefaultMembersPage: stub('members-page'), + DefaultInvitationsPage: stub('invitations-page'), + DefaultSettingsPage: stub('settings-page'), + DefaultAcceptInvitationPage: stub('accept-invitation-page'), + DefaultAiChatPage: stub('ai-chat-page'), + StudioDesignSurface: stub('studio-design-surface'), + BuilderLanding: stub('builder-landing'), + getProductName: () => 'ObjectOS', + getFaviconUrl: () => '', + // `ProtectedRoute` stays real; the shell it mounts is cut down to its + // children, and its loading state to a marker. + ConnectedShell: passthrough, + RequireOrganization: passthrough, + LoadingFallback: stub('loading-fallback'), +})); + +vi.mock('@object-ui/auth', async (importOriginal) => { + const actual = await importOriginal(); + const RealAuthProvider = actual.AuthProvider; + return { + ...actual, + // App's own props, `onAuthStateChange` among them, reach the REAL + // provider; only the network client is this file's. + AuthProvider: (props: Parameters[0]) => ( + + ), + }; +}); + +vi.mock('../AppContent', async () => { + const { useObjectLabel, useObjectTranslation } = await import('@object-ui/i18n'); + /** The application route: one application label, one built-in-pack string. */ + function AppProbe() { + const { objectLabel } = useObjectLabel(); + const { t } = useObjectTranslation(); + return ( +
+

{objectLabel({ name: 'crm_lead', label: 'Lead' })}

+ +
+ ); + } + return { AppContent: AppProbe }; +}); + +vi.mock('../components/RootLandingRedirect', () => ({ RootLandingRedirect: stub('root-landing') })); +vi.mock('../components/SetupRoute', () => ({ SetupRoute: stub('setup-route') })); +vi.mock('../components/MetadataHmrReloader', () => ({ MetadataHmrReloader: () => null })); +vi.mock('../pages/SharedRecordPage', () => ({ default: stub('shared-record-page') })); +vi.mock('../pages/DocPage', () => ({ default: stub('doc-page') })); +vi.mock('../pages/DocsIndex', () => ({ default: stub('docs-index') })); +vi.mock('../pages/DocsSlug', () => ({ default: stub('docs-slug') })); +vi.mock('../pages/DocsLayout', () => ({ default: stub('docs-layout') })); +vi.mock('../pages/auth/RegisterPage', () => ({ RegisterPage: stub('register-page') })); +vi.mock('../pages/auth/ForgotPasswordPage', () => ({ ForgotPasswordPage: stub('forgot-page') })); +vi.mock('../pages/auth/ResetPasswordPage', () => ({ ResetPasswordPage: stub('reset-page') })); +vi.mock('../pages/auth/SetPasswordPage', () => ({ SetPasswordPage: stub('set-password-page') })); +vi.mock('../pages/auth/VerifyEmailPage', () => ({ VerifyEmailPage: stub('verify-email-page') })); +vi.mock('../pages/auth/VerifyEmailPromptPage', () => ({ VerifyEmailPromptPage: stub('verify-prompt-page') })); +vi.mock('../pages/auth/OAuthConsentPage', () => ({ OAuthConsentPage: stub('oauth-consent-page') })); +vi.mock('../pages/auth/DeviceAuthPage', () => ({ DeviceAuthPage: stub('device-auth-page') })); +vi.mock('../dev/DevMasterDetail', () => ({ DevMasterDetail: stub('dev-master-detail') })); +vi.mock('../dev/DevLists', () => ({ DevLists: stub('dev-lists') })); +vi.mock('../dev/DevModal', () => ({ DevModal: stub('dev-modal') })); +vi.mock('../dev/DevLookup', () => ({ DevLookup: stub('dev-lookup') })); +vi.mock('../dev/DevRowActions', () => ({ DevRowActions: stub('dev-row-actions') })); +vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } })); + +import { I18nProvider, LOCALE_STORAGE_KEY, preloadBootstrapLocale, useObjectTranslation } from '@object-ui/i18n'; +import { builtInLocales } from '@object-ui/i18n/locales'; +import { TokenStorage, type AuthClient, type AuthPublicConfig, type AuthUser } from '@object-ui/auth'; +import { App } from '../App'; +import { loadLanguage } from '../loadLanguage'; +import { loadLocales } from '../loadLocales'; + +const zh = builtInLocales.zh; +const TOKEN = 'tok-12034'; +const USER = { id: 'u_12034', email: 'ada@example.com', name: 'Ada' } as unknown as AuthUser; +const SESSION = { token: TOKEN, expiresAt: new Date(Date.now() + 3_600_000) }; +const ORG = { id: 'org_12034', name: 'Acme', slug: 'acme' }; +const OPEN: AuthPublicConfig = { + emailPassword: { enabled: true, disableSignUp: false }, + features: { audiencePosture: 'open' }, +}; + +// --------------------------------------------------------------------------- +// The fake framework +// --------------------------------------------------------------------------- + +interface I18nRead { + path: string; + authorization: string | null; + status: number; +} + +/** Every `/i18n` request the console made, with the bearer it carried and the status it got. */ +let i18nReads: I18nRead[] = []; +/** When set, the translations answer waits for it. */ +let translationsGate: Promise | null = null; + +function json(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } }); +} + +async function framework(input: RequestInfo | URL, init?: RequestInit): Promise { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + const { pathname } = new URL(url, window.location.href); + if (!pathname.startsWith('/api/v1/i18n/')) return json(404, {}); + const authorization = new Headers(init?.headers).get('Authorization'); + const signedIn = authorization === `Bearer ${TOKEN}`; + i18nReads.push({ path: pathname, authorization, status: signedIn ? 200 : 401 }); + if (!signedIn) { + return json(401, { success: false, error: { code: 'UNAUTHENTICATED', message: 'Authentication required' } }); + } + if (pathname === '/api/v1/i18n/locales') { + return json(200, { data: { locales: [{ code: 'en' }, { code: 'zh' }] } }); + } + const locale = decodeURIComponent(pathname.slice('/api/v1/i18n/translations/'.length)); + if (translationsGate) await translationsGate; + return json(200, { data: { locale, translations: { objects: { crm_lead: { label: '线索' } } } } }); +} + +// --------------------------------------------------------------------------- +// The two network clients the auth provider talks to +// --------------------------------------------------------------------------- + +/** Nobody signed in; `signIn` answers as the server does, and the page load after it holds the session. */ +function signedOutClient(): AuthClient { + return { + getSession: vi.fn().mockResolvedValue(null), + getConfig: vi.fn().mockResolvedValue(OPEN), + signIn: vi.fn(async () => ({ user: USER, session: SESSION })), + ...memberOfOrg(), + } as unknown as AuthClient; +} + +/** The page load after sign-in: the session is there from the first read. */ +function signedInClient(): AuthClient { + return { + getSession: vi.fn().mockResolvedValue({ user: USER, session: SESSION }), + getConfig: vi.fn().mockResolvedValue(OPEN), + ...memberOfOrg(), + } as unknown as AuthClient; +} + +function memberOfOrg(): Partial { + return { + listOrganizations: vi.fn().mockResolvedValue([ORG]), + getActiveOrganization: vi.fn().mockResolvedValue(ORG), + setActiveOrganization: vi.fn().mockResolvedValue(ORG), + getActiveMember: vi.fn().mockResolvedValue({ id: 'mem_12034', organizationId: ORG.id, userId: USER.id, role: 'owner' }), + } as unknown as Partial; +} + +// --------------------------------------------------------------------------- +// Raw keys, on every frame +// --------------------------------------------------------------------------- + +/** + * What an i18next key drawn raw looks like: dot-separated identifier segments + * and nothing else. Unicode letter classes, per AGENTS.md: an ASCII `\w` would + * be blind to a key spelled in any other script. + */ +const KEY_SHAPE = /^[\p{L}_][\p{L}\p{N}_-]*(?:\.[\p{L}_][\p{L}\p{N}_-]*)+$/u; +const KEY_ATTRIBUTES = ['aria-label', 'placeholder', 'title', 'alt']; + +function rawKeysIn(root: HTMLElement): string[] { + const found: string[] = []; + const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); + for (let node = walker.nextNode(); node; node = walker.nextNode()) { + const text = node.textContent?.trim() ?? ''; + if (KEY_SHAPE.test(text)) found.push(text); + } + for (const el of root.querySelectorAll('*')) { + for (const name of KEY_ATTRIBUTES) { + const value = el.getAttribute(name)?.trim(); + if (value && KEY_SHAPE.test(value)) found.push(value); + } + } + return found; +} + +/** Records every raw key drawn on any frame from now until `stop()`. */ +function recordRawKeys(): { seen: Set; stop: () => void } { + const seen = new Set(); + const scan = () => rawKeysIn(document.body).forEach((key) => seen.add(key)); + const observer = new MutationObserver(scan); + observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true }); + scan(); + return { + seen, + stop: () => { + scan(); + observer.disconnect(); + }, + }; +} + +// --------------------------------------------------------------------------- +// Boots +// --------------------------------------------------------------------------- + +let assign: ReturnType; + +/** One page load of the console at `url`, as `main.tsx` mounts it. */ +async function bootConsole(url: string, client: AuthClient) { + authClient.current = client; + window.history.pushState({}, '', url); + await preloadBootstrapLocale({ hasLoader: true }); + return render( + + + + + , + ); +} + +/** Sign in through the real form; resolves with where the console's full-page exit goes. */ +async function signInThroughTheForm(): Promise { + const user = userEvent.setup(); + await user.type(await screen.findByLabelText(zh.auth.login.emailLabel), 'ada@example.com'); + await user.type(screen.getByLabelText(zh.auth.login.passwordLabel), 'correct horse'); + await user.click(screen.getByRole('button', { name: zh.auth.login.submitButton })); + await waitFor(() => expect(assign).toHaveBeenCalled()); + return assign.mock.calls[0][0] as string; +} + +beforeEach(() => { + window.localStorage.clear(); + TokenStorage.clear(); + // A Chinese reader, so "drawn from the built-in pack" is visible as Chinese. + window.localStorage.setItem(LOCALE_STORAGE_KEY, 'zh'); + i18nReads = []; + translationsGate = null; + vi.stubGlobal('fetch', vi.fn(framework)); + assign = vi.fn(); + vi.spyOn(window.location, 'assign').mockImplementation(assign as never); +}); + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + TokenStorage.clear(); + window.localStorage.clear(); + window.history.pushState({}, '', '/'); +}); + +describe('console i18n before and after sign-in (objectui#12034)', () => { + it('signed out, the sign-in page asks /i18n nothing and is drawn from the built-in pack, through the sign-in itself', async () => { + await bootConsole('/login?redirect=%2Fapps%2Fcrm', signedOutClient()); + + expect(await screen.findByLabelText(zh.auth.login.emailLabel)).toBeInTheDocument(); + expect(screen.getByRole('button', { name: zh.auth.login.submitButton })).toBeInTheDocument(); + + const exit = await signInThroughTheForm(); + + expect(new URL(exit, window.location.href).pathname).toBe('/apps/crm'); + expect(i18nReads).toEqual([]); + }); + + it('signed in, the application label comes from the authenticated /i18n reads, and the mounted heading re-renders to it', async () => { + let release!: () => void; + translationsGate = new Promise((resolve) => { + release = resolve; + }); + TokenStorage.set(TOKEN); + await bootConsole('/apps/crm', signedInClient()); + + // First render after sign-in: the authored literal and the built-in pack, no key. + const heading = await screen.findByRole('heading', { name: 'Lead' }); + expect(screen.getByRole('button', { name: zh.common.save })).toBeInTheDocument(); + + release(); + + await waitFor(() => expect(heading).toHaveTextContent('线索')); + expect(heading).toBeInTheDocument(); + expect(i18nReads).toEqual( + expect.arrayContaining([ + { path: '/api/v1/i18n/translations/zh', authorization: `Bearer ${TOKEN}`, status: 200 }, + { path: '/api/v1/i18n/locales', authorization: `Bearer ${TOKEN}`, status: 200 }, + ]), + ); + expect(i18nReads.every((read) => read.status === 200)).toBe(true); + }); + + it('against a framework that refuses an anonymous /i18n read, the whole sign-in → app path draws no raw key on any frame', async () => { + const recorder = recordRawKeys(); + + await bootConsole('/login?redirect=%2Fapps%2Fcrm', signedOutClient()); + const exit = await signInThroughTheForm(); + + // The full-page navigation: this page load ends, the next one starts signed in. + cleanup(); + TokenStorage.set(TOKEN); + await bootConsole(exit, signedInClient()); + expect(await screen.findByRole('heading', { name: '线索' })).toBeInTheDocument(); + expect(screen.getByRole('button', { name: zh.common.save })).toBeInTheDocument(); + + recorder.stop(); + expect([...recorder.seen]).toEqual([]); + expect(i18nReads.length).toBeGreaterThan(0); + expect(i18nReads.filter((read) => read.status !== 200)).toEqual([]); + }); + + it('control: the raw-key recorder catches a key that is drawn raw', async () => { + const recorder = recordRawKeys(); + function MissingKey() { + const { t } = useObjectTranslation(); + return

{t('console.noSuchKey12034')}

; + } + // `missingKeyHandler` warns in development builds; the warning is not the subject here. + vi.spyOn(console, 'warn').mockImplementation(() => {}); + render( + + + , + ); + + await waitFor(() => expect(recorder.seen.has('console.noSuchKey12034')).toBe(true)); + recorder.stop(); + }); +}); diff --git a/apps/console/src/i18nSession.ts b/apps/console/src/i18nSession.ts new file mode 100644 index 0000000000..45ddcb98f4 --- /dev/null +++ b/apps/console/src/i18nSession.ts @@ -0,0 +1,70 @@ +/** + * The session answer the console's two `/i18n` loaders wait for + * (objectui#12034). + * + * `loadLanguage` and `loadLocales` are handed to `I18nProvider` in `main.tsx`. + * That provider mounts ABOVE `` and calls both loaders on its + * first commit, before anyone knows whether this page load is signed in. They + * used to fetch at once with a bare `fetch`. So the sign-in page read + * `/api/v1/i18n/*` as an anonymous caller, which the framework refuses with a + * 401 once that domain gets the anonymous refusal every other dispatcher domain + * has (objectstack-ai/objectstack#22432). And the signed-in boot's read + * carried the session only when a same-origin cookie rode along, never the + * bearer the rest of the console authenticates with. + * + * This module holds the one fact both loaders now wait for: the console + * `AuthProvider`'s answer for this page load. `App.tsx` feeds it through that + * provider's `onAuthStateChange`. That is the session authority itself: not a + * second session read, and not a token lifted out of storage. + * + * - Signed out: no request. The sign-in page renders from the built-in + * language packs alone. + * - Signed in: the read goes through `withSettleSignal(createAuthenticatedFetch())`, + * the request path the data adapter uses for every signed-in read (bearer, + * `X-Tenant-ID`, `Accept-Language`, session-rotation adoption, and the + * in-flight count an automated driver waits on). + * - Not answered yet (the provider's `isLoading`): the loader waits. The wait + * ends when the session read does, and it never rejects. + * + * Why nothing re-runs the loaders after sign-in: every console sign-in exit is + * a full-page navigation (`window.location.assign` in `LoginPage`, + * `RegisterPage` and `SetupPage`; `authExitBasename.test.tsx` pins it). The + * post-sign-in load is therefore the next boot's own loader call, answered + * signed in, and `I18nProvider`'s own store write re-renders every reader when + * it lands (objectui#10382). A session that began in place, with no such + * navigation, would keep the built-in packs until the next page load. + */ +import { createAuthenticatedFetch, type AuthState } from '@object-ui/auth'; +import { withSettleSignal } from '@object-ui/app-shell'; + +/** `null` while the session is unanswered: before the first read, and while it re-resolves. */ +let signedIn: boolean | null = null; +const waiting: Array<(answer: boolean) => void> = []; + +/** + * Record the console `AuthProvider`'s current answer. Passed to that provider as + * `onAuthStateChange`, which calls it on mount and on every change. + * + * A module-level function, so its identity never changes: the provider keys + * the effect that calls it on it. + */ +export function publishAuthState({ + isAuthenticated, + isLoading, +}: Pick): void { + signedIn = isLoading ? null : isAuthenticated; + if (signedIn === null) return; + const answer = signedIn; + for (const resolve of waiting.splice(0)) resolve(answer); +} + +/** Resolves `true` when this page load is signed in, `false` when it is not. Never rejects. */ +export function whenSessionAnswered(): Promise { + if (signedIn !== null) return Promise.resolve(signedIn); + return new Promise((resolve) => { + waiting.push(resolve); + }); +} + +/** The data adapter's own request path (`AdapterProvider`), for the two `/i18n` reads. */ +export const i18nFetch = withSettleSignal(createAuthenticatedFetch()); diff --git a/apps/console/src/loadLanguage.test.tsx b/apps/console/src/loadLanguage.test.tsx index e4ec7eb6b0..454649628b 100644 --- a/apps/console/src/loadLanguage.test.tsx +++ b/apps/console/src/loadLanguage.test.tsx @@ -20,18 +20,28 @@ * The render tests go through the real loader — envelope unwrap, predicate, * transform — and hand its answer to i18next with the `addResourceBundle` call * `I18nProvider` makes, then draw the two readers the card names. + * + * The loader reads only for a signed-in page load (objectui#12034), so every + * test here runs signed in unless it says otherwise; the session rule itself + * is the last describe block. */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; import { render, screen, within } from '@testing-library/react'; import { GetTranslationsResponseSchema } from '@objectstack/spec/api'; import type { TranslationData } from '@objectstack/spec/system'; import { createI18n, I18nProvider, useObjectLabel } from '@object-ui/i18n'; import { FlowRunner, type ScreenFlowState } from '@object-ui/app-shell'; +import { TokenStorage } from '@object-ui/auth'; import { loadLanguage } from './loadLanguage'; +import { publishAuthState } from './i18nSession'; -/** Serve `body` as the endpoint's JSON answer. */ +/** + * Serve `body` as the endpoint's JSON answer. A real `Response`: the loader + * reads through the console's authenticated fetch, which reads the answer's + * headers (objectui#12034). + */ function respond(body: unknown) { - vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => body }))); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(body), { status: 200 }))); } /** Serve `translations` inside the spec REST envelope, as the server does. */ @@ -48,9 +58,14 @@ async function loadedInstance(translations: TranslationData) { return instance; } +beforeEach(() => { + publishAuthState({ isAuthenticated: true, isLoading: false }); +}); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + TokenStorage.clear(); }); describe('loadLanguage — a spec payload is recognised by any group it carries (objectui#10235)', () => { @@ -147,3 +162,32 @@ describe('loadLanguage — an already-namespaced tree is returned as-is', () => await expect(loadLanguage('zh-CN')).resolves.toEqual(flat); }); }); + +describe('loadLanguage — reads only for a signed-in page load, with its credentials (objectui#12034)', () => { + it('signed out: requests nothing and answers no translations', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + publishAuthState({ isAuthenticated: false, isLoading: false }); + + await expect(loadLanguage('zh-CN')).resolves.toEqual({}); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('unanswered: waits for the session, then reads with the bearer the data adapter sends', async () => { + serve({ objects: { crm_lead: { label: '线索' } } }); + const fetchSpy = vi.mocked(fetch); + TokenStorage.set('tok-12034'); + publishAuthState({ isAuthenticated: false, isLoading: true }); + + const pending = loadLanguage('zh-CN'); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSpy).not.toHaveBeenCalled(); + + publishAuthState({ isAuthenticated: true, isLoading: false }); + expect(Object.keys(await pending)).toEqual(['app']); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/i18n/translations/zh-CN'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok-12034'); + }); +}); diff --git a/apps/console/src/loadLanguage.ts b/apps/console/src/loadLanguage.ts index b8d9c80299..ed8dac749d 100644 --- a/apps/console/src/loadLanguage.ts +++ b/apps/console/src/loadLanguage.ts @@ -10,13 +10,20 @@ * `@object-ui/i18n` (`transformSpecTranslations`) so that downstream forks * stay in lock-step with new spec scopes (e.g. `_views`, `_actions`, …) * without having to maintain their own copy of the transform. + * + * The read waits for this page load's session answer and is made only when + * signed in, with the session's credentials (objectui#12034, see + * `./i18nSession.ts`). Signed out, it requests nothing and answers `{}`, so the + * sign-in page renders from the built-in packs. */ import { isSpecTranslationData, transformSpecTranslations } from '@object-ui/i18n'; +import { i18nFetch, whenSessionAnswered } from './i18nSession'; export async function loadLanguage(lang: string): Promise> { + if (!(await whenSessionAnswered())) return {}; try { const serverUrl = import.meta.env.VITE_SERVER_URL || ''; - const res = await fetch(`${serverUrl}/api/v1/i18n/translations/${lang}`); + const res = await i18nFetch(`${serverUrl}/api/v1/i18n/translations/${lang}`); if (!res.ok) { console.warn(`[i18n] Failed to load translations for '${lang}': HTTP ${res.status}`); return {}; diff --git a/apps/console/src/loadLocales.test.ts b/apps/console/src/loadLocales.test.ts index e29cde7d39..71cb301323 100644 --- a/apps/console/src/loadLocales.test.ts +++ b/apps/console/src/loadLocales.test.ts @@ -11,17 +11,32 @@ * Every failure path returns `[]` — the provider reads that as "I don't know" * and falls back to the built-in packs, so a language menu can never take the * console down. + * + * The loader reads only for a signed-in page load (objectui#12034), so every + * test here runs signed in unless it says otherwise; the session rule itself + * is the last describe block. */ -import { describe, it, expect, vi, afterEach } from 'vitest'; +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { TokenStorage } from '@object-ui/auth'; import { loadLocales } from './loadLocales'; +import { publishAuthState } from './i18nSession'; -function respond(json: unknown, ok = true, status = 200) { - vi.stubGlobal('fetch', vi.fn(async () => ({ ok, status, json: async () => json }))); +/** + * A real `Response`: the loader reads through the console's authenticated + * fetch, which reads the answer's headers (objectui#12034). + */ +function respond(json: unknown, status = 200) { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(json), { status }))); } +beforeEach(() => { + publishAuthState({ isAuthenticated: true, isLoading: false }); +}); + afterEach(() => { vi.unstubAllGlobals(); vi.restoreAllMocks(); + TokenStorage.clear(); }); describe('loadLocales', () => { @@ -58,7 +73,7 @@ describe('loadLocales', () => { }); it('returns [] on a non-OK response', async () => { - respond({}, false, 503); + respond({}, 503); await expect(loadLocales()).resolves.toEqual([]); }); @@ -75,3 +90,32 @@ describe('loadLocales', () => { await expect(loadLocales()).resolves.toEqual([]); }); }); + +describe('loadLocales — reads only for a signed-in page load, with its credentials (objectui#12034)', () => { + it('signed out: requests nothing and answers no list', async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal('fetch', fetchSpy); + publishAuthState({ isAuthenticated: false, isLoading: false }); + + await expect(loadLocales()).resolves.toEqual([]); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it('unanswered: waits for the session, then reads with the bearer the data adapter sends', async () => { + respond({ data: { locales: [{ code: 'en' }, { code: 'th' }] } }); + const fetchSpy = vi.mocked(fetch); + TokenStorage.set('tok-12034'); + publishAuthState({ isAuthenticated: false, isLoading: true }); + + const pending = loadLocales(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(fetchSpy).not.toHaveBeenCalled(); + + publishAuthState({ isAuthenticated: true, isLoading: false }); + await expect(pending).resolves.toEqual(['en', 'th']); + expect(fetchSpy).toHaveBeenCalledTimes(1); + const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/i18n/locales'); + expect(new Headers(init.headers).get('Authorization')).toBe('Bearer tok-12034'); + }); +}); diff --git a/apps/console/src/loadLocales.ts b/apps/console/src/loadLocales.ts index 9a6d795dde..cd4b1b14c2 100644 --- a/apps/console/src/loadLocales.ts +++ b/apps/console/src/loadLocales.ts @@ -15,12 +15,19 @@ * and local-dev servers, same degrade-quietly contract. A language menu is * never worth taking the console down for: every failure returns `[]`, which * the provider reads as "I don't know" and answers with the built-in packs. + * + * Same session rule as the sibling too (objectui#12034, see `./i18nSession.ts`): + * the read waits for this page load's session answer and is made only when + * signed in, with the session's credentials. Signed out, it requests nothing + * and answers `[]`. */ +import { i18nFetch, whenSessionAnswered } from './i18nSession'; export async function loadLocales(): Promise { + if (!(await whenSessionAnswered())) return []; try { const serverUrl = import.meta.env.VITE_SERVER_URL || ''; - const res = await fetch(`${serverUrl}/api/v1/i18n/locales`); + const res = await i18nFetch(`${serverUrl}/api/v1/i18n/locales`); if (!res.ok) { console.warn(`[i18n] Failed to load the app locale list: HTTP ${res.status}`); return []; diff --git a/apps/console/src/main.tsx b/apps/console/src/main.tsx index 93f3b70e23..0d44aeb5b8 100644 --- a/apps/console/src/main.tsx +++ b/apps/console/src/main.tsx @@ -152,6 +152,10 @@ Promise.all([ manifestLink.href = manifestUrl; document.head.appendChild(manifestLink); + // The two loaders wait for this page load's session answer. Signed in, they + // read `/i18n` with the session's credentials; signed out, they request + // nothing and the sign-in page renders from the built-in packs + // (objectui#12034, `./i18nSession.ts`). ReactDOM.createRoot(document.getElementById('root')!).render(