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
5 changes: 4 additions & 1 deletion docs/adr/0069-member-locale-preference.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,10 @@ with the product's five-value constraint: `en`, `ko`, `zh`, `ja`, or `vi`.
The GNB selector updates both the local display and the authenticated member
preference through `/api/me/preferences`. On login, the server preference wins
over browser detection; browser storage remains only the unauthenticated or
offline fallback.
offline fallback. If the member changes the GNB selector while the initial
`/api/me` preference request is still pending, that current interaction wins
over the late server response and the server update remains the authoritative
next-login value.

## Consequences

Expand Down
15 changes: 15 additions & 0 deletions frontend/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ describe("App, authenticated", () => {
pendingTeppRun?: boolean;
pluralAffiliations?: boolean;
deferMe?: boolean;
preferredLocale?: string | null;
meFailed?: boolean;
postBody?: string;
manyCustomerHints?: number;
Expand Down Expand Up @@ -156,6 +157,7 @@ describe("App, authenticated", () => {
user_account_id: options?.admin ? "acct-admin" : "acct-1",
display_name: options?.admin ? "Demo Admin" : "Demo Analyst",
permission_codes: options?.admin ? ["post_read", "post_admin"] : ["post_read"],
preferred_locale: options?.preferredLocale ?? null,
corporate_entities: options?.pluralAffiliations
? [
{ corporate_entity_id: "corp-demo", entity_name: "Demo Corp" },
Expand Down Expand Up @@ -1932,6 +1934,19 @@ describe("App, authenticated", () => {
});
});

it("does not let a late member preference overwrite a new GNB choice", async () => {
const backend = stubBackend({ deferMe: true, preferredLocale: "en" });
render(<App showLabPanels />);

const language = await screen.findByRole("combobox", {
name: /language|언어|言語|语言|ngôn ngữ/i,
});
await userEvent.selectOptions(language, "ja");
backend.releaseMe();

await waitFor(() => expect(language).toHaveValue("ja"));
});

it("rebuilds lineage when the account has post_admin", async () => {
const fetchMock = stubBackend({ admin: true });
render(<App showLabPanels />);
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
isSupportedLocale,
LOCALE_LABELS,
SUPPORTED_LOCALES,
getLocale,
setLocale,
t,
tf,
Expand Down Expand Up @@ -4581,9 +4582,16 @@ export default function App({ showLabPanels = false }: { showLabPanels?: boolean
useEffect(() => {
if (!accessToken) return;
let active = true;
const localeBeforeMemberFetch = getLocale();
fetchMe(accessToken)
.then((member) => {
if (active && isSupportedLocale(member.preferred_locale)) setLocale(member.preferred_locale);
if (
active &&
getLocale() === localeBeforeMemberFetch &&
isSupportedLocale(member.preferred_locale)
) {
setLocale(member.preferred_locale);
}
Comment on lines +4585 to +4594

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Server preference still wins on clean login

With no interaction, getLocale() still equals the captured value when fetchMe resolves, so the server preference applies as before. The captured value can be a localStorage-restored locale, and the server value still overrides it, preserving the ADR 0069 login rule.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +4588 to +4594

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Info: Selecting the already-current locale is not treated as an interaction

The guard getLocale() === localeBeforeMemberFetch at App.tsx detects interaction by observing a locale change. Because setLocale short-circuits when the chosen value equals the current one (i18n.ts:1408), a member who explicitly re-selects the locale that already matches localeBeforeMemberFetch produces no observable change, so a late /api/me response with a different server preference will still overwrite the display. This is a narrow edge case (only when the user picks the current value while the fetch is pending and the server value differs) and likely acceptable, but worth noting since it is the one interaction path the guard cannot see.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

})
.catch(() => undefined);
return () => {
Expand Down
7 changes: 7 additions & 0 deletions frontend/src/PostBody.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,13 @@ describe("PostBody", () => {
expect(screen.getByText("Embedded image")).toBeInTheDocument();
});

it("renders decoded markup as text instead of executable HTML", () => {
render(<PostBody body="<p>&lt;script&gt;alert(1)&lt;/script&gt;</p>" />);

expect(screen.getByText("alert(1)")).toBeInTheDocument();
expect(document.querySelector("script")).not.toBeInTheDocument();
});

it("renders authoritative LLM structure levels for semantic list units", () => {
render(
<PostBody
Expand Down
1 change: 1 addition & 0 deletions frontend/src/PostBody.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@ function renderSegment(segment: PostBodySegment, index: number, imageContent?: P
: undefined
}
>
{/* Keep decoded source as a React text child; never render it as raw HTML. */}
{segment.text}
</p>
);
Expand Down
11 changes: 11 additions & 0 deletions frontend/src/oidcReturnUrl.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,4 +71,15 @@ describe("OIDC return URL handling", () => {
expect(restoreOidcReturnUrl(undefined)).toBe("/?post=from-local-storage");
expect(window.localStorage.getItem("lineageweave.oidc.returnUrl")).toBeNull();
});

it("preserves the post query and hash through the OIDC callback", () => {
const returnUrl = returnUrlFromLocation({
pathname: "/",
search: "?post=synthetic-post",
hash: "#evidence",
});

expect(returnUrl).toBe("/?post=synthetic-post#evidence");
expect(restoreOidcReturnUrl({ returnUrl })).toBe(returnUrl);
});
});