Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/12034-console-i18n-after-signin.md
Original file line number Diff line number Diff line change
@@ -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.
6 changes: 5 additions & 1 deletion apps/console/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 (
<AuthProvider authUrl={AUTH_URL}>
<AuthProvider authUrl={AUTH_URL} onAuthStateChange={publishAuthState}>
{/* objectui#7482 — no `position` override: the console takes
`ConsoleToaster`'s own documented top-right anchor.

Expand Down
404 changes: 404 additions & 0 deletions apps/console/src/__tests__/i18nAfterSignIn-12034.test.tsx

Large diffs are not rendered by default.

70 changes: 70 additions & 0 deletions apps/console/src/i18nSession.ts
Original file line number Diff line number Diff line change
@@ -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 `<AuthProvider>` 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<AuthState, 'isAuthenticated' | 'isLoading'>): 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<boolean> {
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());
50 changes: 47 additions & 3 deletions apps/console/src/loadLanguage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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)', () => {
Expand Down Expand Up @@ -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');
});
});
9 changes: 8 additions & 1 deletion apps/console/src/loadLanguage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Record<string, unknown>> {
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 {};
Expand Down
52 changes: 48 additions & 4 deletions apps/console/src/loadLocales.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -58,7 +73,7 @@ describe('loadLocales', () => {
});

it('returns [] on a non-OK response', async () => {
respond({}, false, 503);
respond({}, 503);

await expect(loadLocales()).resolves.toEqual([]);
});
Expand All @@ -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');
});
});
9 changes: 8 additions & 1 deletion apps/console/src/loadLocales.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string[]> {
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 [];
Expand Down
4 changes: 4 additions & 0 deletions apps/console/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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(
<React.StrictMode>
<MobileProvider pwa={{ enabled: true, name: getProductName(), shortName: getProductShortName() }}>
Expand Down
Loading