diff --git a/frontend/src/v1/lib/http.ts b/frontend/src/v1/lib/http.ts new file mode 100644 index 0000000000..25f3d8268f --- /dev/null +++ b/frontend/src/v1/lib/http.ts @@ -0,0 +1,38 @@ +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)); + 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; + } +} + +/** + * 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..64da6c47a8 --- /dev/null +++ b/frontend/src/v1/lib/organizations.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, test } from "vitest"; +import { + describeOrganization, + initial, + initials, + landingPath, + organizationInitials, + 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("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( + "/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..d0ef176210 --- /dev/null +++ b/frontend/src/v1/lib/organizations.ts @@ -0,0 +1,81 @@ +/** 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(""); +} + +/** 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 `//
/...`. + * 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..2b96064572 --- /dev/null +++ b/frontend/src/v1/services/services.test.ts @@ -0,0 +1,144 @@ +import { http } from "@v1/lib/http"; +import type { AxiosRequestConfig, InternalAxiosRequestConfig } from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { listOrganizations, switchOrganization } from "./organizations"; +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); + 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"; + vi.unstubAllGlobals(); +}); + +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("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" }; + 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"); + }); + + 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(); + }); + + 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", () => { + 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 new file mode 100644 index 0000000000..3ae584ff73 --- /dev/null +++ b/frontend/src/v1/services/session.ts @@ -0,0 +1,34 @@ +import { http } from "@v1/lib/http"; +import posthog from "posthog-js"; + +/** + * 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?: unknown }>( + "/api/v1/logout", + ); + if (typeof data?.redirect_url !== "string" || data.redirect_url === "") { + throw new Error("Sign-out reply carried no 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. */ +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 new file mode 100644 index 0000000000..5058a56617 --- /dev/null +++ b/frontend/src/v1/shell/AccountMenu.test.tsx @@ -0,0 +1,87 @@ +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { beforeEach, describe, expect, test, vi } from "vitest"; +import { AccountMenu } from "./AccountMenu"; + +const signOutAndLeave = vi.fn<() => Promise>(); +vi.mock("@v1/services/session", () => ({ + signOutAndLeave: () => signOutAndLeave(), +})); + +beforeEach(() => signOutAndLeave.mockReset()); + +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 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(/^A$/); + }); + + 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: "Help & docs (opens in a new tab)", + }), + ).toBeInTheDocument(); + expect( + screen.getByRole("menuitem", { name: "Sign out" }), + ).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(); + expect(screen.queryByText(/old version/i)).not.toBeInTheDocument(); + }); + + 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(signOutAndLeave).toHaveBeenCalledTimes(1)); + }); +}); diff --git a/frontend/src/v1/shell/AccountMenu.tsx b/frontend/src/v1/shell/AccountMenu.tsx new file mode 100644 index 0000000000..4e61fb99b3 --- /dev/null +++ b/frontend/src/v1/shell/AccountMenu.tsx @@ -0,0 +1,111 @@ +import { initial, initials } from "@v1/lib/organizations"; +import { Slot } from "@v1/lib/plugins"; +import { signOutAndLeave } from "@v1/services/session"; +import { + ChevronDown, + CircleHelp, + CircleUserRound, + ExternalLink, + LogOut, +} from "lucide-react"; +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; +} + +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 label = user.name || user.email; + return ( + + + + {initial(label)} + + + + + +
+ + {initials(label)} + + + + {user.name || user.email} + + {user.name && ( + + {user.email} + + )} + +
+ + + + Profile & preferences + + + + + + Help & docs (opens in a new tab) + + + + + void signOutAndLeave()} + > + + 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..a141eb1b2c --- /dev/null +++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx @@ -0,0 +1,345 @@ +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(); + }); + + // 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(); + 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(); + }); + + 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 new file mode 100644 index 0000000000..58f5e07c03 --- /dev/null +++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx @@ -0,0 +1,359 @@ +import { + describeOrganization, + 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, useRef, 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 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"; + +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 latest = useRef(0); + const load = useCallback(() => { + const request = ++latest.current; + setState({ status: "loading" }); + listOrganizations().then( + (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 }; +} + +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 ( + + {organizationInitials(name)} + + ); +} + +function OrgLine({ org }: { org: Organization }) { + return ( + + + {org.display_name} + + + {describeOrganization(org)} + + + ); +} + +function ConfirmSwitch({ + from, + to, + onClose, +}: { + from: Organization; + 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 && !busy && 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. + +
+
+
+ + + + + +
+ {failed && ( +

+ Could not switch. Try again. +

+ )} +
+ + Cancel + + +
+
+
+
+ ); +} + +/** + * 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(""); + } + }} + > + + + {initial(current.displayName)} + + + {current.displayName} + + + + + + + {(state.status === "loading" || state.status === "idle") && ( +

+ Loading… +

+ )} + {state.status === "error" && ( +
+ + Could not load your organisations. + + +
+ )} + {state.status === "ready" && ( + <> + {shown.length === 0 ? ( +

+ No organisations match +

+ ) : ( +

+ You belong to {count} organisation{count === 1 ? "" : "s"} +

+ )} +
    + {shown.map((org) => { + const isCurrent = org.id === current.id; + return ( +
  • + + + {isCurrent ? ( + + Current + + ) : ( + + )} +
  • + ); + })} +
+ {count === 1 && ( +

+ Nothing to switch to. +

+ )} + + )} +
+
+
+ {target && ( + org.id === current.id), + id: current.id, + name: current.name, + display_name: current.displayName, + }} + to={target} + onClose={() => setTarget(undefined)} + /> + )} + + ); +}