From 0bd4a2c99ef28d3d3a46b342bb2dede8ec098b07 Mon Sep 17 00:00:00 2001
From: Athul
Date: Mon, 26 Oct 2026 16:33:12 +0530
Subject: [PATCH 1/6] UN-4290 [FEAT] Add the v1 account menu and organisation
switcher
P2-T02 (pages 01 and 19). The account menu from the avatar: identity,
Profile & preferences, the account-menu.plan-billing slot for cloud,
Help & docs and Sign out. Sign out POSTs /api/v1/logout with the CSRF
token, resets analytics and opens the returned URL; if the POST fails it
falls back to the GET logout. No "Switch to old version" item (D14).
The organisation switcher from the pill: search, a row per organisation
with role, member count and region (absent fields left out), the current
one marked, a confirmation before switching, the one-organisation and
member states, and a load error with retry. After a switch the app does a
full load of the same section in the new organisation, else its
dashboard. Create organisation is left for a decision (plan gap 6).
lib/http.ts is the shared client: session cookie, X-CSRFToken on unsafe
methods. Invisible until the shell (P2-T01) renders these components.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/v1/lib/http.ts | 32 ++
frontend/src/v1/lib/organizations.test.ts | 85 +++++
frontend/src/v1/lib/organizations.ts | 65 ++++
frontend/src/v1/services/organizations.ts | 22 ++
frontend/src/v1/services/services.test.ts | 79 +++++
frontend/src/v1/services/session.ts | 11 +
frontend/src/v1/shell/AccountMenu.test.tsx | 72 ++++
frontend/src/v1/shell/AccountMenu.tsx | 116 +++++++
.../v1/shell/OrganizationSwitcher.test.tsx | 188 ++++++++++
.../src/v1/shell/OrganizationSwitcher.tsx | 326 ++++++++++++++++++
10 files changed, 996 insertions(+)
create mode 100644 frontend/src/v1/lib/http.ts
create mode 100644 frontend/src/v1/lib/organizations.test.ts
create mode 100644 frontend/src/v1/lib/organizations.ts
create mode 100644 frontend/src/v1/services/organizations.ts
create mode 100644 frontend/src/v1/services/services.test.ts
create mode 100644 frontend/src/v1/services/session.ts
create mode 100644 frontend/src/v1/shell/AccountMenu.test.tsx
create mode 100644 frontend/src/v1/shell/AccountMenu.tsx
create mode 100644 frontend/src/v1/shell/OrganizationSwitcher.test.tsx
create mode 100644 frontend/src/v1/shell/OrganizationSwitcher.tsx
diff --git a/frontend/src/v1/lib/http.ts b/frontend/src/v1/lib/http.ts
new file mode 100644
index 0000000000..89750c26da
--- /dev/null
+++ b/frontend/src/v1/lib/http.ts
@@ -0,0 +1,32 @@
+import axios from "axios";
+
+const SAFE_METHODS = new Set(["get", "head", "options"]);
+
+function cookie(name: string): string | undefined {
+ const prefix = `${name}=`;
+ const entry = document.cookie
+ .split("; ")
+ .find((part) => part.startsWith(prefix));
+ return entry === undefined
+ ? undefined
+ : decodeURIComponent(entry.slice(prefix.length));
+}
+
+/**
+ * The one HTTP client v1 services use. Django's session cookie authenticates;
+ * unsafe methods carry the CSRF cookie back as X-CSRFToken. Set here rather
+ * than through axios's xsrf options, which only the browser adapter applies.
+ * ponytail: no 401 or error-toast handling yet; add it when a page needs it.
+ */
+export const http = axios.create({ withCredentials: true });
+
+http.interceptors.request.use((config) => {
+ const token = cookie("csrftoken");
+ if (
+ token !== undefined &&
+ !SAFE_METHODS.has((config.method ?? "get").toLowerCase())
+ ) {
+ config.headers.set("X-CSRFToken", token);
+ }
+ return config;
+});
diff --git a/frontend/src/v1/lib/organizations.test.ts b/frontend/src/v1/lib/organizations.test.ts
new file mode 100644
index 0000000000..e19167f518
--- /dev/null
+++ b/frontend/src/v1/lib/organizations.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, test } from "vitest";
+import {
+ describeOrganization,
+ initials,
+ landingPath,
+ roleLabel,
+} from "./organizations";
+
+describe("roleLabel", () => {
+ test.each([
+ ["admin", "Admin"],
+ ["unstract_admin", "Admin"],
+ ["user", "Member"],
+ ["unstract_user", "Member"],
+ ["unstract_supervisor", "Supervisor"],
+ ["unstract_reviewer", "Reviewer"],
+ ])("%s reads %s", (role, label) => {
+ expect(roleLabel(role)).toBe(label);
+ });
+
+ test("an unknown role is not guessed", () => {
+ expect(roleLabel("auditor")).toBeUndefined();
+ expect(roleLabel(undefined)).toBeUndefined();
+ });
+});
+
+describe("describeOrganization", () => {
+ test("role, member count and region, as frame NEW10 shows", () => {
+ expect(
+ describeOrganization({ role: "admin", member_count: 40, region: "US" }),
+ ).toBe("Admin · 40 members · US");
+ });
+
+ test("one member is singular", () => {
+ expect(describeOrganization({ role: "admin", member_count: 1 })).toBe(
+ "Admin · 1 member",
+ );
+ });
+
+ test("absent fields are left out, never guessed (OSS sends no region)", () => {
+ expect(describeOrganization({ role: "user" })).toBe("Member");
+ expect(describeOrganization({ member_count: 3 })).toBe("3 members");
+ expect(describeOrganization({})).toBe("");
+ });
+});
+
+describe("initials", () => {
+ test.each([
+ ["Zipstack", "Z"],
+ ["Tanushree Daga", "TD"],
+ ["Tanushree’s organisation", "TO"],
+ [" ", "?"],
+ ])("%s gives %s", (name, expected) => {
+ expect(initials(name)).toBe(expected);
+ });
+});
+
+describe("landingPath", () => {
+ test("keeps the section in the new organisation", () => {
+ expect(landingPath("/zipstack/workflows", "zipstack", "tanushree")).toBe(
+ "/tanushree/workflows",
+ );
+ });
+
+ test("drops anything under the section: that record may not exist there", () => {
+ expect(
+ landingPath("/zipstack/tools/123/settings", "zipstack", "tanushree"),
+ ).toBe("/tanushree/tools");
+ });
+
+ test("falls back to the dashboard when the path has no section", () => {
+ expect(landingPath("/zipstack", "zipstack", "tanushree")).toBe(
+ "/tanushree/dashboard",
+ );
+ expect(landingPath("/zipstack/", "zipstack", "tanushree")).toBe(
+ "/tanushree/dashboard",
+ );
+ });
+
+ test("falls back to the dashboard outside the current organisation's paths", () => {
+ expect(landingPath("/landing", "zipstack", "tanushree")).toBe(
+ "/tanushree/dashboard",
+ );
+ });
+});
diff --git a/frontend/src/v1/lib/organizations.ts b/frontend/src/v1/lib/organizations.ts
new file mode 100644
index 0000000000..bf8f774025
--- /dev/null
+++ b/frontend/src/v1/lib/organizations.ts
@@ -0,0 +1,65 @@
+/** Wording and navigation rules for the organisation switcher (page 19). */
+
+// Stored roles: `admin`/`user` in OSS, `unstract_*` in cloud (account_v2/ReadMe.md).
+const ROLE_LABELS: Record = {
+ admin: "Admin",
+ unstract_admin: "Admin",
+ user: "Member",
+ unstract_user: "Member",
+ unstract_supervisor: "Supervisor",
+ unstract_reviewer: "Reviewer",
+};
+
+export function roleLabel(role: string | undefined): string | undefined {
+ return role === undefined ? undefined : ROLE_LABELS[role];
+}
+
+export interface OrganizationDetails {
+ role?: string;
+ member_count?: number;
+ region?: string;
+}
+
+/**
+ * "Admin · 40 members · US". The list leaves a field out when this deployment
+ * cannot answer for it (OSS has no regions), so a missing part is skipped.
+ */
+export function describeOrganization({
+ role,
+ member_count,
+ region,
+}: OrganizationDetails): string {
+ const members =
+ member_count === undefined
+ ? undefined
+ : `${member_count} member${member_count === 1 ? "" : "s"}`;
+ return [roleLabel(role), members, region].filter(Boolean).join(" · ");
+}
+
+export function initials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) {
+ return "?";
+ }
+ return words
+ .slice(0, 2)
+ .map((word) => word[0].toUpperCase())
+ .join("");
+}
+
+/**
+ * Where the app lands after switching: the same section in the new
+ * organisation, else its dashboard. Paths are `///...`.
+ * ponytail: keeps only the section, since a record id below it belongs to the
+ * old organisation; ask the route registry which sections exist once it does.
+ */
+export function landingPath(
+ pathname: string,
+ fromOrg: string,
+ toOrg: string,
+): string {
+ const [org, section] = pathname.split("/").filter(Boolean);
+ return org === fromOrg && section
+ ? `/${toOrg}/${section}`
+ : `/${toOrg}/dashboard`;
+}
diff --git a/frontend/src/v1/services/organizations.ts b/frontend/src/v1/services/organizations.ts
new file mode 100644
index 0000000000..644b9a68c3
--- /dev/null
+++ b/frontend/src/v1/services/organizations.ts
@@ -0,0 +1,22 @@
+import { http } from "@v1/lib/http";
+import type { OrganizationDetails } from "@v1/lib/organizations";
+
+/** A row of GET /api/v1/organization. role, member_count and region may be absent. */
+export interface Organization extends OrganizationDetails {
+ id: string;
+ /** The URL name: paths are //... */
+ name: string;
+ display_name: string;
+}
+
+export async function listOrganizations(): Promise {
+ const { data } = await http.get<{ organizations: Organization[] }>(
+ "/api/v1/organization",
+ );
+ return data.organizations;
+}
+
+/** Makes the organisation the session's current one. */
+export async function switchOrganization(id: string): Promise {
+ await http.post(`/api/v1/organization/${encodeURIComponent(id)}/set`);
+}
diff --git a/frontend/src/v1/services/services.test.ts b/frontend/src/v1/services/services.test.ts
new file mode 100644
index 0000000000..d90a1e85c1
--- /dev/null
+++ b/frontend/src/v1/services/services.test.ts
@@ -0,0 +1,79 @@
+import { http } from "@v1/lib/http";
+import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
+import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { listOrganizations, switchOrganization } from "./organizations";
+import { signOut } from "./session";
+
+const sent: AxiosRequestConfig[] = [];
+let reply: { status: number; data: unknown };
+
+beforeEach(() => {
+ sent.length = 0;
+ reply = { status: 200, data: {} };
+ http.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
+ sent.push(config);
+ const response = { ...reply, statusText: "", headers: {}, config };
+ if (reply.status >= 400) {
+ throw Object.assign(new Error(`HTTP ${reply.status}`), { response });
+ }
+ return response;
+ };
+ // biome-ignore lint/suspicious/noDocumentCookie: the client reads this cookie
+ document.cookie = "csrftoken=tok123";
+});
+
+afterEach(() => {
+ // biome-ignore lint/suspicious/noDocumentCookie: clears the cookie set above
+ document.cookie = "csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+});
+
+describe("organizations", () => {
+ test("lists the user's organisations", async () => {
+ reply.data = {
+ organizations: [
+ {
+ id: "org_1",
+ name: "zipstack",
+ display_name: "Zipstack",
+ role: "admin",
+ },
+ ],
+ };
+ await expect(listOrganizations()).resolves.toEqual([
+ {
+ id: "org_1",
+ name: "zipstack",
+ display_name: "Zipstack",
+ role: "admin",
+ },
+ ]);
+ expect(sent[0]).toMatchObject({
+ method: "get",
+ url: "/api/v1/organization",
+ });
+ expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
+ });
+
+ test("switching posts to the organisation's set endpoint with the CSRF token", async () => {
+ await switchOrganization("org/1");
+ expect(sent[0]).toMatchObject({
+ method: "post",
+ url: "/api/v1/organization/org%2F1/set",
+ });
+ expect(sent[0].headers?.["X-CSRFToken"]).toBe("tok123");
+ });
+
+ test("a failed request rejects instead of resolving empty", async () => {
+ reply = { status: 500, data: {} };
+ await expect(listOrganizations()).rejects.toThrow("HTTP 500");
+ });
+});
+
+describe("signOut", () => {
+ test("posts to logout with the CSRF token and returns where to go", async () => {
+ reply.data = { redirect_url: "https://id.example.com/v2/logout" };
+ await expect(signOut()).resolves.toBe("https://id.example.com/v2/logout");
+ expect(sent[0]).toMatchObject({ method: "post", url: "/api/v1/logout" });
+ expect(sent[0].headers?.["X-CSRFToken"]).toBe("tok123");
+ });
+});
diff --git a/frontend/src/v1/services/session.ts b/frontend/src/v1/services/session.ts
new file mode 100644
index 0000000000..eaccdf1f86
--- /dev/null
+++ b/frontend/src/v1/services/session.ts
@@ -0,0 +1,11 @@
+import { http } from "@v1/lib/http";
+
+/**
+ * Ends the session. Returns the URL to open at top level: the identity
+ * provider's sign-out on cloud, the web app on OSS. A script cannot follow a
+ * cross-origin sign-out redirect, so the backend answers a POST with the URL.
+ */
+export async function signOut(): Promise {
+ const { data } = await http.post<{ redirect_url: string }>("/api/v1/logout");
+ return data.redirect_url;
+}
diff --git a/frontend/src/v1/shell/AccountMenu.test.tsx b/frontend/src/v1/shell/AccountMenu.test.tsx
new file mode 100644
index 0000000000..29aa817155
--- /dev/null
+++ b/frontend/src/v1/shell/AccountMenu.test.tsx
@@ -0,0 +1,72 @@
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { AccountMenu } from "./AccountMenu";
+
+const signOut = vi.fn<() => Promise>();
+vi.mock("@v1/services/session", () => ({ signOut: () => signOut() }));
+const reset = vi.fn();
+vi.mock("posthog-js", () => ({ default: { reset: () => reset() } }));
+
+const assign = vi.fn();
+beforeEach(() => {
+ signOut.mockReset();
+ reset.mockReset();
+ assign.mockReset();
+ vi.stubGlobal("location", { ...window.location, assign });
+});
+afterEach(() => vi.unstubAllGlobals());
+
+const user = { name: "Tanushree Daga", email: "tanushree@zipstack.com" };
+
+async function open() {
+ render( );
+ await userEvent.click(screen.getByRole("button", { name: "Account menu" }));
+}
+
+describe("AccountMenu", () => {
+ test("the trigger shows the user's initials", () => {
+ render( );
+ expect(
+ screen.getByRole("button", { name: "Account menu" }),
+ ).toHaveTextContent("TD");
+ });
+
+ test("lists identity, profile, help and sign out, as frame NEW09 shows", async () => {
+ await open();
+ expect(screen.getByText("Tanushree Daga")).toBeInTheDocument();
+ expect(screen.getByText("tanushree@zipstack.com")).toBeInTheDocument();
+ expect(
+ screen.getByRole("menuitem", { name: "Profile & preferences" }),
+ ).toHaveAttribute("href", "/zipstack/profile");
+ expect(
+ screen.getByRole("menuitem", { name: /Help & docs/ }),
+ ).toHaveAttribute("href", "https://docs.unstract.com");
+ expect(
+ screen.getByRole("menuitem", { name: "Sign out" }),
+ ).toBeInTheDocument();
+ });
+
+ test("OSS has no Plan & billing item and no version switch (D14)", async () => {
+ await open();
+ expect(screen.queryByText("Plan & billing")).not.toBeInTheDocument();
+ expect(screen.queryByText(/old version/i)).not.toBeInTheDocument();
+ });
+
+ test("sign out ends the session, resets analytics and opens the returned URL", async () => {
+ signOut.mockResolvedValue("https://id.example.com/v2/logout");
+ await open();
+ await userEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
+ await waitFor(() =>
+ expect(assign).toHaveBeenCalledWith("https://id.example.com/v2/logout"),
+ );
+ expect(reset).toHaveBeenCalled();
+ });
+
+ test("a failed sign-out falls back to the GET logout, which still ends the session", async () => {
+ signOut.mockRejectedValue(new Error("HTTP 403"));
+ await open();
+ await userEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
+ await waitFor(() => expect(assign).toHaveBeenCalledWith("/api/v1/logout"));
+ });
+});
diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx
new file mode 100644
index 0000000000..00b964280d
--- /dev/null
+++ b/frontend/src/v1/shell/AccountMenu.tsx
@@ -0,0 +1,116 @@
+import { initials } from "@v1/lib/organizations";
+import { Slot } from "@v1/lib/plugins";
+import { signOut } from "@v1/services/session";
+import {
+ ChevronDown,
+ CircleHelp,
+ CircleUserRound,
+ ExternalLink,
+ LogOut,
+} from "lucide-react";
+import posthog from "posthog-js";
+import { DropdownMenu } from "radix-ui";
+import type { ReactNode } from "react";
+
+const HELP_URL = "https://docs.unstract.com";
+
+// Frame NEW09 (1638:44719). Colours are the design's token names.
+const ITEM =
+ "flex h-[34px] cursor-pointer items-center gap-2.5 rounded-lg px-[9px] text-[13px] font-medium leading-5 text-[var(--foreground)] outline-none data-[highlighted]:bg-[var(--muted)]";
+const ICON = "size-[18px] shrink-0 text-[var(--muted-foreground)]";
+
+export interface AccountMenuProps {
+ user: { name: string; email: string };
+ /** The profile page (P3-T10) in the current organisation. */
+ profileHref: string;
+}
+
+async function leave(): Promise {
+ try {
+ const url = await signOut();
+ posthog.reset();
+ window.location.assign(url);
+ } catch {
+ // The GET logout ends the session too and redirects itself (v0's path).
+ window.location.assign("/api/v1/logout");
+ }
+}
+
+function Item({ children, href }: { children: ReactNode; href: string }) {
+ const external = href.startsWith("http");
+ return (
+
+
+ {children}
+
+
+ );
+}
+
+/**
+ * The avatar menu: identity, Profile & preferences, Plan & billing (a cloud
+ * plugin fills the slot, TRIAL badge included), Help & docs, Sign out. No
+ * "Switch to old version" item: that control lives only on Human Review (D14).
+ */
+export function AccountMenu({ user, profileHref }: AccountMenuProps) {
+ const mark = initials(user.name || user.email);
+ return (
+
+
+
+ {mark}
+
+
+
+
+
+
+
+ {mark}
+
+
+
+ {user.name}
+
+
+ {user.email}
+
+
+
+
+ -
+
+ Profile & preferences
+
+
+ -
+
+ Help & docs
+
+
+
+ void leave()}>
+
+ Sign out
+
+
+
+
+ );
+}
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
new file mode 100644
index 0000000000..347ce44bc8
--- /dev/null
+++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
@@ -0,0 +1,188 @@
+import { render, screen, waitFor, within } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import type { Organization } from "@v1/services/organizations";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { OrganizationSwitcher } from "./OrganizationSwitcher";
+
+const list = vi.fn<() => Promise>();
+const switchTo = vi.fn<(id: string) => Promise>();
+vi.mock("@v1/services/organizations", () => ({
+ listOrganizations: () => list(),
+ switchOrganization: (id: string) => switchTo(id),
+}));
+
+const assign = vi.fn();
+beforeEach(() => {
+ list.mockReset();
+ switchTo.mockReset();
+ assign.mockReset();
+ vi.stubGlobal("location", {
+ ...window.location,
+ pathname: "/zipstack/tools/42",
+ assign,
+ });
+});
+afterEach(() => vi.unstubAllGlobals());
+
+const zipstack: Organization = {
+ id: "org_zs",
+ name: "zipstack",
+ display_name: "Zipstack",
+ role: "admin",
+ member_count: 40,
+ region: "US",
+};
+const personal: Organization = {
+ id: "org_td",
+ name: "tanushree",
+ display_name: "Tanushree’s organisation",
+ role: "admin",
+ member_count: 1,
+ region: "US",
+};
+const current = { id: "org_zs", name: "zipstack", displayName: "Zipstack" };
+
+async function open() {
+ render( );
+ await userEvent.click(
+ screen.getByRole("button", { name: /Organisation: Zipstack/ }),
+ );
+}
+
+describe("OrganizationSwitcher", () => {
+ test("the pill names the current organisation before the list loads", () => {
+ list.mockReturnValue(new Promise(() => undefined));
+ render( );
+ expect(
+ screen.getByRole("button", { name: /Organisation: Zipstack/ }),
+ ).toHaveTextContent("Zipstack");
+ expect(list).not.toHaveBeenCalled();
+ });
+
+ test("lists organisations with role · members · region, current one marked (O01)", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ expect(
+ await screen.findByText(/^you belong to 2 organisations$/i),
+ ).toBeInTheDocument();
+ const rows = screen.getAllByRole("listitem");
+ expect(within(rows[0]).getByText("Zipstack")).toBeInTheDocument();
+ expect(
+ within(rows[0]).getByText("Admin · 40 members · US"),
+ ).toBeInTheDocument();
+ expect(within(rows[0]).getByText("Current")).toBeInTheDocument();
+ expect(
+ within(rows[0]).queryByRole("button", { name: /Switch/ }),
+ ).not.toBeInTheDocument();
+ expect(
+ within(rows[1]).getByText("Admin · 1 member · US"),
+ ).toBeInTheDocument();
+ expect(
+ within(rows[1]).getByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ ).toBeInTheDocument();
+ });
+
+ test("a member reads Member (O04)", async () => {
+ list.mockResolvedValue([{ ...zipstack, role: "user" }, personal]);
+ await open();
+ expect(
+ await screen.findByText("Member · 40 members · US"),
+ ).toBeInTheDocument();
+ });
+
+ test("one organisation: nothing to switch to (O03)", async () => {
+ list.mockResolvedValue([zipstack]);
+ await open();
+ expect(
+ await screen.findByText(/^you belong to 1 organisation$/i),
+ ).toBeInTheDocument();
+ expect(screen.getByText("Nothing to switch to.")).toBeInTheDocument();
+ });
+
+ test("search narrows the rows by name", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ await screen.findByText("Tanushree’s organisation");
+ await userEvent.type(
+ screen.getByRole("searchbox", { name: "Find an organisation" }),
+ "tanu",
+ );
+ expect(
+ screen.queryByText("Zipstack", { selector: "li *" }),
+ ).not.toBeInTheDocument();
+ expect(screen.getByText("Tanushree’s organisation")).toBeInTheDocument();
+ });
+
+ test("a failed load shows the error with retry; the pill keeps its name", async () => {
+ list
+ .mockRejectedValueOnce(new Error("HTTP 500"))
+ .mockResolvedValueOnce([zipstack, personal]);
+ await open();
+ expect(
+ await screen.findByText("Could not load your organisations."),
+ ).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Organisation: Zipstack/ }),
+ ).toBeInTheDocument();
+ await userEvent.click(screen.getByRole("button", { name: "Retry" }));
+ expect(
+ await screen.findByText("Tanushree’s organisation"),
+ ).toBeInTheDocument();
+ });
+
+ test("switching asks first (O02), then lands on the same section in the new organisation", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ switchTo.mockResolvedValue();
+ await open();
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ );
+ const dialog = screen.getByRole("alertdialog");
+ expect(
+ within(dialog).getByText("Switch to Tanushree’s organisation?"),
+ ).toBeInTheDocument();
+ expect(switchTo).not.toHaveBeenCalled();
+ await userEvent.click(
+ within(dialog).getByRole("button", { name: "Switch organisation" }),
+ );
+ await waitFor(() =>
+ expect(assign).toHaveBeenCalledWith("/tanushree/tools"),
+ );
+ expect(switchTo).toHaveBeenCalledWith("org_td");
+ });
+
+ test("cancel switches nothing", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Cancel" }));
+ expect(switchTo).not.toHaveBeenCalled();
+ expect(assign).not.toHaveBeenCalled();
+ });
+
+ test("a failed switch says so and stays put", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ switchTo.mockRejectedValue(new Error("HTTP 403"));
+ await open();
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ );
+ await userEvent.click(
+ screen.getByRole("button", { name: "Switch organisation" }),
+ );
+ expect(
+ await screen.findByText("Could not switch. Try again."),
+ ).toBeInTheDocument();
+ expect(assign).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx
new file mode 100644
index 0000000000..e963814384
--- /dev/null
+++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx
@@ -0,0 +1,326 @@
+import {
+ describeOrganization,
+ initials,
+ landingPath,
+} from "@v1/lib/organizations";
+import {
+ listOrganizations,
+ type Organization,
+ switchOrganization,
+} from "@v1/services/organizations";
+import {
+ ArrowRightLeft,
+ ChevronRight,
+ ChevronsUpDown,
+ Search,
+} from "lucide-react";
+import { AlertDialog, Popover } from "radix-ui";
+import { useCallback, useState } from "react";
+
+// Frames NEW10 (1638:44855) and page 19, O01 to O04 (2259:182 to 2259:656).
+const AVATAR_COLOURS = [
+ "bg-blue-100 text-blue-700",
+ "bg-teal-100 text-teal-700",
+ "bg-amber-100 text-amber-700",
+ "bg-rose-100 text-rose-700",
+ "bg-violet-100 text-violet-700",
+];
+const BUTTON =
+ "inline-flex h-8 items-center gap-1.5 rounded-[10px] border border-[var(--border)] bg-[var(--card)] px-3 text-[13px] font-medium text-[var(--foreground)] hover:bg-[var(--muted)] disabled:opacity-50";
+
+export interface CurrentOrganization {
+ id: string;
+ /** The URL name: paths are //... */
+ name: string;
+ displayName: string;
+}
+
+type ListState =
+ | { status: "idle" | "loading" | "error" }
+ | { status: "ready"; organizations: Organization[] };
+
+function useOrganizationList() {
+ const [state, setState] = useState({ status: "idle" });
+ const load = useCallback(() => {
+ setState({ status: "loading" });
+ listOrganizations().then(
+ (organizations) => setState({ status: "ready", organizations }),
+ () => setState({ status: "error" }),
+ );
+ }, []);
+ return { state, load };
+}
+
+function OrgAvatar({
+ id,
+ name,
+ size,
+}: {
+ id: string;
+ name: string;
+ size: "md" | "sm";
+}) {
+ const colour =
+ AVATAR_COLOURS[
+ [...id].reduce((sum, c) => sum + c.charCodeAt(0), 0) %
+ AVATAR_COLOURS.length
+ ];
+ const box =
+ size === "md"
+ ? "size-8 rounded-[10px] text-xs"
+ : "size-7 rounded-md text-[11px]";
+ return (
+
+ {initials(name)}
+
+ );
+}
+
+function OrgLine({ org }: { org: Organization }) {
+ return (
+
+
+ {org.display_name}
+
+
+ {describeOrganization(org)}
+
+
+ );
+}
+
+function ConfirmSwitch({
+ from,
+ to,
+ onClose,
+}: {
+ from: Organization | undefined;
+ to: Organization;
+ onClose: () => void;
+}) {
+ const [busy, setBusy] = useState(false);
+ const [failed, setFailed] = useState(false);
+
+ async function confirm() {
+ setBusy(true);
+ setFailed(false);
+ try {
+ await switchOrganization(to.id);
+ // A full load clears every client store, so nothing of the old organisation survives.
+ window.location.assign(
+ landingPath(window.location.pathname, from?.name ?? "", to.name),
+ );
+ } catch {
+ setFailed(true);
+ setBusy(false);
+ }
+ }
+
+ return (
+ !open && onClose()}>
+
+
+
+
+
+
+
+
+
+ Switch to {to.display_name}?
+
+
+ Every screen is replaced — projects, workflows, deployments,
+ logs, members and the plan. Nothing is lost; you can switch back
+ from here.
+
+
+
+
+ {from && (
+ <>
+
+
+
+ >
+ )}
+
+
+
+ {failed && (
+
+ Could not switch. Try again.
+
+ )}
+
+
+ Cancel
+
+
void confirm()}
+ className="inline-flex h-9 items-center gap-2 rounded-[10px] bg-[var(--primary)] px-3.5 text-sm font-medium text-[var(--primary-foreground)] disabled:opacity-60"
+ >
+
+ Switch organisation
+
+
+
+
+
+ );
+}
+
+/**
+ * The organisation pill and its switcher (page 19). The list loads when the
+ * switcher opens; until then, and if it fails, the pill shows the current name.
+ * ponytail: no Create organisation item yet; v0 has none and the plan rule that
+ * hides it is undecided (P2-T02 plan, gap 6).
+ */
+export function OrganizationSwitcher({
+ current,
+}: {
+ current: CurrentOrganization;
+}) {
+ const { state, load } = useOrganizationList();
+ const [query, setQuery] = useState("");
+ const [target, setTarget] = useState();
+
+ const organizations = state.status === "ready" ? state.organizations : [];
+ const shown = organizations.filter((org) =>
+ org.display_name.toLowerCase().includes(query.trim().toLowerCase()),
+ );
+ const count = organizations.length;
+
+ return (
+ <>
+ {
+ if (open && state.status !== "ready") {
+ load();
+ }
+ if (!open) {
+ setQuery("");
+ }
+ }}
+ >
+
+
+ {initials(current.displayName)}
+
+
+ {current.displayName}
+
+
+
+
+
+
+
+ setQuery(event.target.value)}
+ className="flex-1 bg-transparent text-[13px] text-[var(--foreground)] outline-none placeholder:text-[var(--muted-foreground)]"
+ />
+
+ {(state.status === "loading" || state.status === "idle") && (
+
+ Loading…
+
+ )}
+ {state.status === "error" && (
+
+
+ Could not load your organisations.
+
+
+ Retry
+
+
+ )}
+ {state.status === "ready" && (
+ <>
+
+ You belong to {count} organisation{count === 1 ? "" : "s"}
+
+
+ {shown.map((org) => {
+ const isCurrent = org.id === current.id;
+ return (
+
+
+
+ {isCurrent ? (
+
+ Current
+
+ ) : (
+ setTarget(org)}
+ >
+ Switch
+
+ )}
+
+ );
+ })}
+
+ {count === 1 && (
+
+ Nothing to switch to.
+
+ )}
+ >
+ )}
+
+
+
+ {target && (
+ org.id === current.id)}
+ to={target}
+ onClose={() => setTarget(undefined)}
+ />
+ )}
+ >
+ );
+}
From e7d4b5547910292d79f9937a7c5ab17739537eee Mon Sep 17 00:00:00 2001
From: Athul
Date: Mon, 26 Oct 2026 18:25:08 +0530
Subject: [PATCH 2/6] UN-4290 [FIX] Show the e-mail as the account menu title
when the user has no name
Found on the dev preview: a user without a name got an empty title line
above the e-mail.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/v1/shell/AccountMenu.test.tsx | 14 ++++++++++++++
frontend/src/v1/shell/AccountMenu.tsx | 10 ++++++----
2 files changed, 20 insertions(+), 4 deletions(-)
diff --git a/frontend/src/v1/shell/AccountMenu.test.tsx b/frontend/src/v1/shell/AccountMenu.test.tsx
index 29aa817155..4fd9b48a41 100644
--- a/frontend/src/v1/shell/AccountMenu.test.tsx
+++ b/frontend/src/v1/shell/AccountMenu.test.tsx
@@ -47,6 +47,20 @@ describe("AccountMenu", () => {
).toBeInTheDocument();
});
+ test("a user without a name shows the e-mail once, in the name's place", async () => {
+ render(
+ ,
+ );
+ await userEvent.click(screen.getByRole("button", { name: "Account menu" }));
+ const lines = screen.getAllByText("athul@zipstack.com");
+ expect(lines).toHaveLength(1);
+ // The bold title line, not an empty title above the e-mail.
+ expect(lines[0]).toHaveClass("font-semibold");
+ });
+
test("OSS has no Plan & billing item and no version switch (D14)", async () => {
await open();
expect(screen.queryByText("Plan & billing")).not.toBeInTheDocument();
diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx
index 00b964280d..8569d4f403 100644
--- a/frontend/src/v1/shell/AccountMenu.tsx
+++ b/frontend/src/v1/shell/AccountMenu.tsx
@@ -83,11 +83,13 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) {
- {user.name}
-
-
- {user.email}
+ {user.name || user.email}
+ {user.name && (
+
+ {user.email}
+
+ )}
From 014249a0ed18341363b2f6230891c67ae94e0029 Mon Sep 17 00:00:00 2001
From: Athul
Date: Wed, 28 Oct 2026 12:12:27 +0530
Subject: [PATCH 3/6] UN-4290 [FIX] Check the sign-out redirect, share the
sign-out sequence and guard the CSRF cookie
signOut now throws when the reply has no redirect URL so the GET logout
fallback runs. signOutAndLeave in services/session.ts owns the whole
sequence (POST, analytics reset, navigate, fallback) and AccountMenu calls
it. A malformed CSRF cookie sends no header instead of throwing. The
account trigger shows one letter, and the Help & docs link announces that
it opens in a new tab.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/v1/lib/http.ts | 12 +++--
frontend/src/v1/lib/organizations.test.ts | 24 +++++++++
frontend/src/v1/lib/organizations.ts | 16 ++++++
frontend/src/v1/services/services.test.ts | 62 +++++++++++++++++++++-
frontend/src/v1/services/session.ts | 21 +++++++-
frontend/src/v1/shell/AccountMenu.test.tsx | 57 ++++++++++----------
frontend/src/v1/shell/AccountMenu.tsx | 31 +++++------
7 files changed, 170 insertions(+), 53 deletions(-)
diff --git a/frontend/src/v1/lib/http.ts b/frontend/src/v1/lib/http.ts
index 89750c26da..25f3d8268f 100644
--- a/frontend/src/v1/lib/http.ts
+++ b/frontend/src/v1/lib/http.ts
@@ -7,9 +7,15 @@ function cookie(name: string): string | undefined {
const entry = document.cookie
.split("; ")
.find((part) => part.startsWith(prefix));
- return entry === undefined
- ? undefined
- : decodeURIComponent(entry.slice(prefix.length));
+ if (entry === undefined) {
+ return undefined;
+ }
+ try {
+ return decodeURIComponent(entry.slice(prefix.length));
+ } catch {
+ // A malformed value is no token: send no header and let the server refuse.
+ return undefined;
+ }
}
/**
diff --git a/frontend/src/v1/lib/organizations.test.ts b/frontend/src/v1/lib/organizations.test.ts
index e19167f518..64da6c47a8 100644
--- a/frontend/src/v1/lib/organizations.test.ts
+++ b/frontend/src/v1/lib/organizations.test.ts
@@ -1,8 +1,10 @@
import { describe, expect, test } from "vitest";
import {
describeOrganization,
+ initial,
initials,
landingPath,
+ organizationInitials,
roleLabel,
} from "./organizations";
@@ -55,6 +57,28 @@ describe("initials", () => {
});
});
+describe("initial", () => {
+ test.each([
+ ["Tanushree Daga", "T"],
+ ["athul@zipstack.com", "A"],
+ [" ", "?"],
+ ])("%s gives %s", (name, expected) => {
+ expect(initial(name)).toBe(expected);
+ });
+});
+
+describe("organizationInitials", () => {
+ test.each([
+ ["Zipstack", "ZI"],
+ ["Tanushree’s organisation", "TO"],
+ ["Acme Data Labs", "AD"],
+ ["X", "X"],
+ [" ", "?"],
+ ])("%s gives %s", (name, expected) => {
+ expect(organizationInitials(name)).toBe(expected);
+ });
+});
+
describe("landingPath", () => {
test("keeps the section in the new organisation", () => {
expect(landingPath("/zipstack/workflows", "zipstack", "tanushree")).toBe(
diff --git a/frontend/src/v1/lib/organizations.ts b/frontend/src/v1/lib/organizations.ts
index bf8f774025..d0ef176210 100644
--- a/frontend/src/v1/lib/organizations.ts
+++ b/frontend/src/v1/lib/organizations.ts
@@ -47,6 +47,22 @@ export function initials(name: string): string {
.join("");
}
+/** One letter, for the small marks (the account trigger, the organisation pill). */
+export function initial(name: string): string {
+ return name.trim()[0]?.toUpperCase() ?? "?";
+}
+
+/** Two letters for an organisation avatar; a one-word name gives its first two. */
+export function organizationInitials(name: string): string {
+ const words = name.trim().split(/\s+/).filter(Boolean);
+ if (words.length === 0) {
+ return "?";
+ }
+ const letters =
+ words.length === 1 ? [...words[0]].slice(0, 2) : words.map((w) => w[0]);
+ return letters.slice(0, 2).join("").toUpperCase();
+}
+
/**
* Where the app lands after switching: the same section in the new
* organisation, else its dashboard. Paths are `///...`.
diff --git a/frontend/src/v1/services/services.test.ts b/frontend/src/v1/services/services.test.ts
index d90a1e85c1..041c90c722 100644
--- a/frontend/src/v1/services/services.test.ts
+++ b/frontend/src/v1/services/services.test.ts
@@ -1,14 +1,18 @@
import { http } from "@v1/lib/http";
import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios";
-import { afterEach, beforeEach, describe, expect, test } from "vitest";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { listOrganizations, switchOrganization } from "./organizations";
-import { signOut } from "./session";
+import { signOut, signOutAndLeave } from "./session";
+
+const reset = vi.fn();
+vi.mock("posthog-js", () => ({ default: { reset: () => reset() } }));
const sent: AxiosRequestConfig[] = [];
let reply: { status: number; data: unknown };
beforeEach(() => {
sent.length = 0;
+ reset.mockReset();
reply = { status: 200, data: {} };
http.defaults.adapter = async (config: InternalAxiosRequestConfig) => {
sent.push(config);
@@ -25,6 +29,7 @@ beforeEach(() => {
afterEach(() => {
// biome-ignore lint/suspicious/noDocumentCookie: clears the cookie set above
document.cookie = "csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ vi.unstubAllGlobals();
});
describe("organizations", () => {
@@ -69,6 +74,22 @@ describe("organizations", () => {
});
});
+describe("http CSRF cookie", () => {
+ test("a malformed cookie sends no header instead of throwing", async () => {
+ // biome-ignore lint/suspicious/noDocumentCookie: sets a cookie that cannot be decoded
+ document.cookie = "csrftoken=%E0%A4%A";
+ await expect(switchOrganization("org_1")).resolves.toBeUndefined();
+ expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
+ });
+
+ test("a missing cookie sends no header", async () => {
+ // biome-ignore lint/suspicious/noDocumentCookie: clears the cookie set in beforeEach
+ document.cookie = "csrftoken=; expires=Thu, 01 Jan 1970 00:00:00 GMT";
+ await switchOrganization("org_1");
+ expect(sent[0].headers?.["X-CSRFToken"]).toBeUndefined();
+ });
+});
+
describe("signOut", () => {
test("posts to logout with the CSRF token and returns where to go", async () => {
reply.data = { redirect_url: "https://id.example.com/v2/logout" };
@@ -76,4 +97,41 @@ describe("signOut", () => {
expect(sent[0]).toMatchObject({ method: "post", url: "/api/v1/logout" });
expect(sent[0].headers?.["X-CSRFToken"]).toBe("tok123");
});
+
+ test("a reply without a redirect URL is an error, not /undefined", async () => {
+ reply.data = {};
+ await expect(signOut()).rejects.toThrow();
+ reply.data = { redirect_url: "" };
+ await expect(signOut()).rejects.toThrow();
+ });
+});
+
+describe("signOutAndLeave", () => {
+ const assign = vi.fn();
+ beforeEach(() => {
+ assign.mockReset();
+ vi.stubGlobal("location", { ...window.location, assign });
+ });
+
+ test("resets analytics and opens the returned URL", async () => {
+ reply.data = { redirect_url: "https://id.example.com/v2/logout" };
+ await signOutAndLeave();
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(assign).toHaveBeenCalledWith("https://id.example.com/v2/logout");
+ });
+
+ test("a failed POST resets analytics and falls back to the GET logout", async () => {
+ reply = { status: 403, data: {} };
+ await signOutAndLeave();
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(assign).toHaveBeenCalledWith("/api/v1/logout");
+ });
+
+ test("a reply without a redirect URL falls back to the GET logout", async () => {
+ reply.data = {};
+ await signOutAndLeave();
+ expect(reset).toHaveBeenCalledTimes(1);
+ expect(assign).toHaveBeenCalledWith("/api/v1/logout");
+ expect(assign).not.toHaveBeenCalledWith(undefined);
+ });
});
diff --git a/frontend/src/v1/services/session.ts b/frontend/src/v1/services/session.ts
index eaccdf1f86..7156836e6d 100644
--- a/frontend/src/v1/services/session.ts
+++ b/frontend/src/v1/services/session.ts
@@ -1,4 +1,5 @@
import { http } from "@v1/lib/http";
+import posthog from "posthog-js";
/**
* Ends the session. Returns the URL to open at top level: the identity
@@ -6,6 +7,24 @@ import { http } from "@v1/lib/http";
* cross-origin sign-out redirect, so the backend answers a POST with the URL.
*/
export async function signOut(): Promise {
- const { data } = await http.post<{ redirect_url: string }>("/api/v1/logout");
+ const { data } = await http.post<{ redirect_url?: unknown }>(
+ "/api/v1/logout",
+ );
+ if (typeof data?.redirect_url !== "string" || data.redirect_url === "") {
+ throw new Error("Sign-out reply carried no redirect URL");
+ }
return data.redirect_url;
}
+
+/** Signs out, forgets the analytics identity and leaves the app. */
+export async function signOutAndLeave(): Promise {
+ let destination: string;
+ try {
+ destination = await signOut();
+ } catch {
+ // The GET logout ends the session too and redirects itself (v0's path).
+ destination = "/api/v1/logout";
+ }
+ posthog.reset();
+ window.location.assign(destination);
+}
diff --git a/frontend/src/v1/shell/AccountMenu.test.tsx b/frontend/src/v1/shell/AccountMenu.test.tsx
index 4fd9b48a41..5058a56617 100644
--- a/frontend/src/v1/shell/AccountMenu.test.tsx
+++ b/frontend/src/v1/shell/AccountMenu.test.tsx
@@ -1,21 +1,14 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
-import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { beforeEach, describe, expect, test, vi } from "vitest";
import { AccountMenu } from "./AccountMenu";
-const signOut = vi.fn<() => Promise>();
-vi.mock("@v1/services/session", () => ({ signOut: () => signOut() }));
-const reset = vi.fn();
-vi.mock("posthog-js", () => ({ default: { reset: () => reset() } }));
+const signOutAndLeave = vi.fn<() => Promise>();
+vi.mock("@v1/services/session", () => ({
+ signOutAndLeave: () => signOutAndLeave(),
+}));
-const assign = vi.fn();
-beforeEach(() => {
- signOut.mockReset();
- reset.mockReset();
- assign.mockReset();
- vi.stubGlobal("location", { ...window.location, assign });
-});
-afterEach(() => vi.unstubAllGlobals());
+beforeEach(() => signOutAndLeave.mockReset());
const user = { name: "Tanushree Daga", email: "tanushree@zipstack.com" };
@@ -25,11 +18,24 @@ async function open() {
}
describe("AccountMenu", () => {
- test("the trigger shows the user's initials", () => {
+ test("the trigger shows one letter; the menu header keeps two-letter initials", async () => {
render( );
+ const trigger = screen.getByRole("button", { name: "Account menu" });
+ expect(trigger).toHaveTextContent(/^T$/);
+ await userEvent.click(trigger);
+ expect(screen.getByText("TD")).toBeInTheDocument();
+ });
+
+ test("a user without a name gets the e-mail's first letter on the trigger", () => {
+ render(
+ ,
+ );
expect(
screen.getByRole("button", { name: "Account menu" }),
- ).toHaveTextContent("TD");
+ ).toHaveTextContent(/^A$/);
});
test("lists identity, profile, help and sign out, as frame NEW09 shows", async () => {
@@ -42,6 +48,11 @@ describe("AccountMenu", () => {
expect(
screen.getByRole("menuitem", { name: /Help & docs/ }),
).toHaveAttribute("href", "https://docs.unstract.com");
+ expect(
+ screen.getByRole("menuitem", {
+ name: "Help & docs (opens in a new tab)",
+ }),
+ ).toBeInTheDocument();
expect(
screen.getByRole("menuitem", { name: "Sign out" }),
).toBeInTheDocument();
@@ -67,20 +78,10 @@ describe("AccountMenu", () => {
expect(screen.queryByText(/old version/i)).not.toBeInTheDocument();
});
- test("sign out ends the session, resets analytics and opens the returned URL", async () => {
- signOut.mockResolvedValue("https://id.example.com/v2/logout");
- await open();
- await userEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
- await waitFor(() =>
- expect(assign).toHaveBeenCalledWith("https://id.example.com/v2/logout"),
- );
- expect(reset).toHaveBeenCalled();
- });
-
- test("a failed sign-out falls back to the GET logout, which still ends the session", async () => {
- signOut.mockRejectedValue(new Error("HTTP 403"));
+ test("sign out hands over to the shared sign-out sequence", async () => {
+ signOutAndLeave.mockResolvedValue();
await open();
await userEvent.click(screen.getByRole("menuitem", { name: "Sign out" }));
- await waitFor(() => expect(assign).toHaveBeenCalledWith("/api/v1/logout"));
+ await waitFor(() => expect(signOutAndLeave).toHaveBeenCalledTimes(1));
});
});
diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx
index 8569d4f403..4e61fb99b3 100644
--- a/frontend/src/v1/shell/AccountMenu.tsx
+++ b/frontend/src/v1/shell/AccountMenu.tsx
@@ -1,6 +1,6 @@
-import { initials } from "@v1/lib/organizations";
+import { initial, initials } from "@v1/lib/organizations";
import { Slot } from "@v1/lib/plugins";
-import { signOut } from "@v1/services/session";
+import { signOutAndLeave } from "@v1/services/session";
import {
ChevronDown,
CircleHelp,
@@ -8,7 +8,6 @@ import {
ExternalLink,
LogOut,
} from "lucide-react";
-import posthog from "posthog-js";
import { DropdownMenu } from "radix-ui";
import type { ReactNode } from "react";
@@ -25,17 +24,6 @@ export interface AccountMenuProps {
profileHref: string;
}
-async function leave(): Promise {
- try {
- const url = await signOut();
- posthog.reset();
- window.location.assign(url);
- } catch {
- // The GET logout ends the session too and redirects itself (v0's path).
- window.location.assign("/api/v1/logout");
- }
-}
-
function Item({ children, href }: { children: ReactNode; href: string }) {
const external = href.startsWith("http");
return (
@@ -56,7 +44,7 @@ function Item({ children, href }: { children: ReactNode; href: string }) {
* "Switch to old version" item: that control lives only on Human Review (D14).
*/
export function AccountMenu({ user, profileHref }: AccountMenuProps) {
- const mark = initials(user.name || user.email);
+ const label = user.name || user.email;
return (
- {mark}
+ {initial(label)}
- {mark}
+ {initials(label)}
@@ -100,14 +88,19 @@ export function AccountMenu({ user, profileHref }: AccountMenuProps) {
-
- Help & docs
+
+ Help & docs (opens in a new tab)
+
- void leave()}>
+ void signOutAndLeave()}
+ >
Sign out
From 1c97ee39eda48faad4b3f7374baee7a4b3960861 Mon Sep 17 00:00:00 2001
From: Athul
Date: Wed, 28 Oct 2026 12:13:56 +0530
Subject: [PATCH 4/6] UN-4290 [FIX] Harden the organisation switcher and match
its Figma marks
A switch in flight can no longer be cancelled, a 412 from the list sends the
user to sign-in, an empty search says so, the dialog's from side comes from
the current organisation, and overlapping list loads keep only the latest.
The loading text is a live region and the current row carries aria-current.
The Switch button gets the arrows icon, and avatars show two letters for a
one-word name while the pill shows one.
Co-Authored-By: Claude Opus 5.5
---
.../v1/shell/OrganizationSwitcher.test.tsx | 144 ++++++++++++++++++
.../src/v1/shell/OrganizationSwitcher.tsx | 82 +++++++---
2 files changed, 201 insertions(+), 25 deletions(-)
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
index 347ce44bc8..e01a481481 100644
--- a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
+++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
@@ -185,4 +185,148 @@ describe("OrganizationSwitcher", () => {
).toBeInTheDocument();
expect(assign).not.toHaveBeenCalled();
});
+
+ async function openConfirm() {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ );
+ return screen.getByRole("alertdialog");
+ }
+
+ test("while the switch is in flight Cancel and Escape keep the dialog open", async () => {
+ let finish: () => void = () => undefined;
+ switchTo.mockReturnValue(
+ new Promise((resolve) => {
+ finish = resolve;
+ }),
+ );
+ const dialog = await openConfirm();
+ await userEvent.click(
+ within(dialog).getByRole("button", { name: "Switch organisation" }),
+ );
+ expect(
+ within(dialog).getByRole("button", { name: "Cancel" }),
+ ).toBeDisabled();
+ await userEvent.keyboard("{Escape}");
+ expect(screen.getByRole("alertdialog")).toBeInTheDocument();
+ finish();
+ await waitFor(() => expect(assign).toHaveBeenCalledTimes(1));
+ });
+
+ test("a failed switch enables the confirm button again", async () => {
+ switchTo.mockRejectedValue(new Error("HTTP 403"));
+ const dialog = await openConfirm();
+ const confirm = within(dialog).getByRole("button", {
+ name: "Switch organisation",
+ });
+ await userEvent.click(confirm);
+ await screen.findByText("Could not switch. Try again.");
+ expect(confirm).toBeEnabled();
+ expect(
+ within(dialog).getByRole("button", { name: "Cancel" }),
+ ).toBeEnabled();
+ });
+
+ test("a 412 from the list means the session is gone: go to sign-in", async () => {
+ list.mockRejectedValue({ isAxiosError: true, response: { status: 412 } });
+ await open();
+ await waitFor(() => expect(assign).toHaveBeenCalledWith("/landing"));
+ expect(
+ screen.queryByText("Could not load your organisations."),
+ ).not.toBeInTheDocument();
+ });
+
+ test("a search with no match says so instead of an empty list", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ await screen.findByText("Tanushree’s organisation");
+ await userEvent.type(
+ screen.getByRole("searchbox", { name: "Find an organisation" }),
+ "zzz",
+ );
+ expect(screen.getByText("No organisations match")).toBeInTheDocument();
+ expect(screen.queryByText(/you belong to/i)).not.toBeInTheDocument();
+ expect(screen.queryAllByRole("listitem")).toHaveLength(0);
+ });
+
+ test("the confirm dialog takes the from side from the current organisation, listed or not", async () => {
+ list.mockResolvedValue([personal]);
+ switchTo.mockResolvedValue();
+ await open();
+ await userEvent.click(
+ await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ }),
+ );
+ const dialog = screen.getByRole("alertdialog");
+ expect(within(dialog).getByText("Zipstack")).toBeInTheDocument();
+ await userEvent.click(
+ within(dialog).getByRole("button", { name: "Switch organisation" }),
+ );
+ await waitFor(() =>
+ expect(assign).toHaveBeenCalledWith("/tanushree/tools"),
+ );
+ });
+
+ test("marks the current row", async () => {
+ list.mockResolvedValueOnce([zipstack, personal]);
+ await open();
+ await screen.findByText("Tanushree’s organisation");
+ const rows = screen.getAllByRole("listitem");
+ expect(rows[0]).toHaveAttribute("aria-current", "true");
+ expect(rows[1]).not.toHaveAttribute("aria-current");
+ });
+
+ test("the loading text is a polite live region", async () => {
+ list.mockReturnValue(new Promise(() => undefined));
+ await open();
+ expect(await screen.findByText("Loading…")).toHaveAttribute(
+ "aria-live",
+ "polite",
+ );
+ });
+
+ test("ignores a list response that is not from the latest request", async () => {
+ let first: (orgs: Organization[]) => void = () => undefined;
+ list
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ first = resolve;
+ }),
+ )
+ .mockResolvedValueOnce([zipstack, personal]);
+ await open();
+ await userEvent.keyboard("{Escape}");
+ await userEvent.click(
+ screen.getByRole("button", { name: /Organisation: Zipstack/ }),
+ );
+ expect(await screen.findByText(/^you belong to 2/i)).toBeInTheDocument();
+ first([zipstack]);
+ await new Promise((r) => setTimeout(r, 0));
+ expect(screen.getByText(/^you belong to 2/i)).toBeInTheDocument();
+ });
+
+ test("avatars show two letters for a one-word name, the pill one", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ await screen.findByText("Tanushree’s organisation");
+ const rows = screen.getAllByRole("listitem");
+ expect(within(rows[0]).getByText("ZI")).toBeInTheDocument();
+ expect(
+ screen.getByRole("button", { name: /Organisation: Zipstack/ }),
+ ).toHaveTextContent(/^Z/);
+ });
+
+ test("the Switch button carries the arrows icon", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ const button = await screen.findByRole("button", {
+ name: "Switch to Tanushree’s organisation",
+ });
+ expect(button.querySelector("svg.lucide-arrow-left-right")).not.toBeNull();
+ });
});
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx
index e963814384..3688c33d7e 100644
--- a/frontend/src/v1/shell/OrganizationSwitcher.tsx
+++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx
@@ -1,21 +1,24 @@
import {
describeOrganization,
- initials,
+ initial,
landingPath,
+ organizationInitials,
} from "@v1/lib/organizations";
import {
listOrganizations,
type Organization,
switchOrganization,
} from "@v1/services/organizations";
+import axios from "axios";
import {
+ ArrowLeftRight,
ArrowRightLeft,
ChevronRight,
ChevronsUpDown,
Search,
} from "lucide-react";
import { AlertDialog, Popover } from "radix-ui";
-import { useCallback, useState } from "react";
+import { useCallback, useRef, useState } from "react";
// Frames NEW10 (1638:44855) and page 19, O01 to O04 (2259:182 to 2259:656).
const AVATAR_COLOURS = [
@@ -25,6 +28,7 @@ const AVATAR_COLOURS = [
"bg-rose-100 text-rose-700",
"bg-violet-100 text-violet-700",
];
+const SIGN_IN_PATH = "/landing";
const BUTTON =
"inline-flex h-8 items-center gap-1.5 rounded-[10px] border border-[var(--border)] bg-[var(--card)] px-3 text-[13px] font-medium text-[var(--foreground)] hover:bg-[var(--muted)] disabled:opacity-50";
@@ -41,11 +45,27 @@ type ListState =
function useOrganizationList() {
const [state, setState] = useState({ status: "idle" });
+ const latest = useRef(0);
const load = useCallback(() => {
+ const request = ++latest.current;
setState({ status: "loading" });
listOrganizations().then(
- (organizations) => setState({ status: "ready", organizations }),
- () => setState({ status: "error" }),
+ (organizations) => {
+ if (request === latest.current) {
+ setState({ status: "ready", organizations });
+ }
+ },
+ (error: unknown) => {
+ if (request !== latest.current) {
+ return;
+ }
+ // 412: the backend logged the session out while building the list.
+ if (axios.isAxiosError(error) && error.response?.status === 412) {
+ window.location.assign(SIGN_IN_PATH);
+ return;
+ }
+ setState({ status: "error" });
+ },
);
}, []);
return { state, load };
@@ -74,7 +94,7 @@ function OrgAvatar({
aria-hidden
className={`flex shrink-0 items-center justify-center font-semibold ${box} ${colour}`}
>
- {initials(name)}
+ {organizationInitials(name)}
);
}
@@ -97,7 +117,7 @@ function ConfirmSwitch({
to,
onClose,
}: {
- from: Organization | undefined;
+ from: Organization;
to: Organization;
onClose: () => void;
}) {
@@ -111,7 +131,7 @@ function ConfirmSwitch({
await switchOrganization(to.id);
// A full load clears every client store, so nothing of the old organisation survives.
window.location.assign(
- landingPath(window.location.pathname, from?.name ?? "", to.name),
+ landingPath(window.location.pathname, from.name, to.name),
);
} catch {
setFailed(true);
@@ -120,7 +140,7 @@ function ConfirmSwitch({
}
return (
- !open && onClose()}>
+ !open && !busy && onClose()}>
@@ -143,16 +163,12 @@ function ConfirmSwitch({
- {from && (
- <>
-
-
-
- >
- )}
+
+
+
@@ -165,7 +181,7 @@ function ConfirmSwitch({
)}
-
+
Cancel
- {initials(current.displayName)}
+ {initial(current.displayName)}
{current.displayName}
@@ -250,7 +266,10 @@ export function OrganizationSwitcher({
/>
{(state.status === "loading" || state.status === "idle") && (
-
+
Loading…
)}
@@ -269,15 +288,22 @@ export function OrganizationSwitcher({
)}
{state.status === "ready" && (
<>
-
- You belong to {count} organisation{count === 1 ? "" : "s"}
-
+ {shown.length === 0 ? (
+
+ No organisations match
+
+ ) : (
+
+ You belong to {count} organisation{count === 1 ? "" : "s"}
+
+ )}
{shown.map((org) => {
const isCurrent = org.id === current.id;
return (
setTarget(org)}
>
+
Switch
)}
@@ -316,7 +343,12 @@ export function OrganizationSwitcher({
{target && (
org.id === current.id)}
+ from={{
+ ...organizations.find((org) => org.id === current.id),
+ id: current.id,
+ name: current.name,
+ display_name: current.displayName,
+ }}
to={target}
onClose={() => setTarget(undefined)}
/>
From 3b11ca448b12676452bc64bd9eda1a8426ae65de Mon Sep 17 00:00:00 2001
From: Athul
Date: Wed, 28 Oct 2026 12:31:40 +0530
Subject: [PATCH 5/6] UN-4290 [FIX] Follow only an http(s) sign-out redirect
Co-Authored-By: Claude Opus 5.5
---
frontend/src/v1/services/services.test.ts | 7 +++++++
frontend/src/v1/services/session.ts | 6 +++++-
2 files changed, 12 insertions(+), 1 deletion(-)
diff --git a/frontend/src/v1/services/services.test.ts b/frontend/src/v1/services/services.test.ts
index 041c90c722..2b96064572 100644
--- a/frontend/src/v1/services/services.test.ts
+++ b/frontend/src/v1/services/services.test.ts
@@ -104,6 +104,13 @@ describe("signOut", () => {
reply.data = { redirect_url: "" };
await expect(signOut()).rejects.toThrow();
});
+
+ test("only an http(s) redirect is followed; a relative one resolves on this origin", async () => {
+ reply.data = { redirect_url: "javascript:alert(1)" };
+ await expect(signOut()).rejects.toThrow();
+ reply.data = { redirect_url: "/landing" };
+ await expect(signOut()).resolves.toBe(`${window.location.origin}/landing`);
+ });
});
describe("signOutAndLeave", () => {
diff --git a/frontend/src/v1/services/session.ts b/frontend/src/v1/services/session.ts
index 7156836e6d..3ae584ff73 100644
--- a/frontend/src/v1/services/session.ts
+++ b/frontend/src/v1/services/session.ts
@@ -13,7 +13,11 @@ export async function signOut(): Promise {
if (typeof data?.redirect_url !== "string" || data.redirect_url === "") {
throw new Error("Sign-out reply carried no redirect URL");
}
- return data.redirect_url;
+ const url = new URL(data.redirect_url, window.location.origin);
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
+ throw new Error("Sign-out reply carried a non-http redirect URL");
+ }
+ return url.href;
}
/** Signs out, forgets the analytics identity and leaves the app. */
From fbe0e6cf3a1e0af05f01f75d6568685ee66eb423 Mon Sep 17 00:00:00 2001
From: Athul
Date: Thu, 29 Oct 2026 10:49:49 +0530
Subject: [PATCH 6/6] UN-4290 [FIX] Scroll a long organisation list inside a
panel capped to the viewport
The popover had no height limit, so with many organisations the rows ran
past the bottom of a short window. The panel now takes Radix's measured
available height (8px collision padding) and only the list scrolls, so
the search box stays in view.
Co-Authored-By: Claude Opus 5.5
---
frontend/src/v1/shell/OrganizationSwitcher.test.tsx | 13 +++++++++++++
frontend/src/v1/shell/OrganizationSwitcher.tsx | 5 +++--
2 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
index e01a481481..a141eb1b2c 100644
--- a/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
+++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx
@@ -84,6 +84,19 @@ describe("OrganizationSwitcher", () => {
).toBeInTheDocument();
});
+ // happy-dom has no layout, so this pins the CSS contract: the panel is capped
+ // at the space Radix measures, and only the list scrolls under the search.
+ test("a long list scrolls inside a panel capped to the viewport", async () => {
+ list.mockResolvedValue([zipstack, personal]);
+ await open();
+ const rows = await screen.findAllByRole("listitem");
+ const scroller = rows[0].parentElement as HTMLElement;
+ expect(scroller).toHaveClass("overflow-y-auto", "min-h-0");
+ expect(
+ scroller.closest("[data-radix-popper-content-wrapper] > *"),
+ ).toHaveClass("max-h-[var(--radix-popover-content-available-height)]");
+ });
+
test("a member reads Member (O04)", async () => {
list.mockResolvedValue([{ ...zipstack, role: "user" }, personal]);
await open();
diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx
index 3688c33d7e..58f5e07c03 100644
--- a/frontend/src/v1/shell/OrganizationSwitcher.tsx
+++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx
@@ -252,7 +252,8 @@ export function OrganizationSwitcher({
@@ -297,7 +298,7 @@ export function OrganizationSwitcher({
You belong to {count} organisation{count === 1 ? "" : "s"}
)}
-
+
{shown.map((org) => {
const isCurrent = org.id === current.id;
return (