diff --git a/backend/account_v2/ReadMe.md b/backend/account_v2/ReadMe.md index 415a56e2f3..b01941a62c 100644 --- a/backend/account_v2/ReadMe.md +++ b/backend/account_v2/ReadMe.md @@ -54,7 +54,7 @@ |---|---|---| | `role` | The caller has a membership in the organization on this deployment. | The stored role: `admin` or `user` in OSS; `unstract_admin`, `unstract_user`, `unstract_supervisor` or `unstract_reviewer` in cloud. | | `member_count` | The organization exists on this deployment and, where the deployment sets `REGION`, its metadata doesn't name a different region. An organization created before the stamp and not back-filled carries no region, so a local copy of one from another region still sends the copy's count. | The people the members page lists: the organization's member records on this deployment. Platform API key accounts and pending invitations are not counted. | -| `region` | The deployment sets `REGION`, and either the organization's metadata names a region or the organization exists on this deployment. | The region code (`US`, `EU`; `STAGING`, `DEV` or `INTEGRATION` outside production), from the metadata trimmed and uppercased, else this deployment's `REGION`. The client maps it to a label. OSS and on-prem never send it: their settings don't read `REGION`, whatever the environment sets. Cloud stamps `region` on an organization's metadata where it is created. An organization from another region that the user once opened here has a local copy; its row shows the home region and no `member_count`, since this deployment counts only the people who opened the copy. Organizations created before the stamp and not back-filled show the region of the deployment that answered. | +| `region` | The deployment sets `REGION`, and either the organization's metadata names a region or the organization exists on this deployment. | The region code (`US`, `EU`; `STAGING`, `DEV` or `INTEGRATION` outside production), from the metadata trimmed and uppercased, else this deployment's `REGION`. The client shows the code as is. OSS and on-prem never send it: their settings don't read `REGION`, whatever the environment sets. Cloud stamps `region` on an organization's metadata where it is created. An organization from another region that the user once opened here has a local copy; its row shows the home region and no `member_count`, since this deployment counts only the people who opened the copy. Organizations created before the stamp and not back-filled show the region of the deployment that answered. | If working out these three fields fails, the list is returned without them and the failure is logged. 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..7589128690 --- /dev/null +++ b/frontend/src/v1/lib/organizations.test.ts @@ -0,0 +1,112 @@ +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", + ); + expect(landingPath("/otherorg/workflows", "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..c063baa888 --- /dev/null +++ b/frontend/src/v1/lib/organizations.ts @@ -0,0 +1,82 @@ +/** 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. + * v1 shows the region code as the backend sends it, with no label mapping. + */ +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..d246a51847 --- /dev/null +++ b/frontend/src/v1/services/organizations.ts @@ -0,0 +1,75 @@ +import { http } from "@v1/lib/http"; +import type { OrganizationDetails } from "@v1/lib/organizations"; +import axios from "axios"; + +/** 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; +} + +/** + * A refusal the backend gives a meaning to. `session-ended`: the list call + * answered 412 after logging the session out; `body` may name the cause. + * `access-removed`: the set call answered 403, so the user left that + * organisation after the list loaded. Any other failure is thrown as is. + */ +export class OrganizationError extends Error { + readonly kind: "session-ended" | "access-removed"; + readonly body: unknown; + + constructor(kind: OrganizationError["kind"], body?: unknown) { + super(`Organisation request refused: ${kind}`); + this.name = "OrganizationError"; + this.kind = kind; + this.body = body; + } +} + +function refusal( + error: unknown, + status: number, + kind: OrganizationError["kind"], +): unknown { + return axios.isAxiosError(error) && error.response?.status === status + ? new OrganizationError(kind, error.response.data) + : error; +} + +export async function listOrganizations(): Promise { + let data: { organizations?: unknown } | undefined; + try { + ({ data } = await http.get<{ organizations?: unknown }>( + "/api/v1/organization", + )); + } catch (error) { + throw refusal(error, 412, "session-ended"); + } + // A proxy or SPA fallback can answer 200 with some other body. + if (!Array.isArray(data?.organizations)) { + throw new Error("Organization list reply carried no organizations"); + } + return data.organizations as Organization[]; +} + +/** Makes the organisation the session's current one. */ +export async function switchOrganization(id: string): Promise { + try { + await http.post(`/api/v1/organization/${encodeURIComponent(id)}/set`); + } catch (error) { + throw refusal(error, 403, "access-removed"); + } +} + +/** + * Where to send the browser to switch when the identity provider picked the + * organisation at login (the session's `org_switch_via_login`): a new login + * naming it, so the provider records the choice for every product. `prompt=none` + * shows no screen, since the provider already has a session. + */ +export function organizationLoginUrl(id: string): string { + const query = new URLSearchParams({ organization: id, prompt: "none" }); + return `/api/v1/login?${query.toString()}`; +} diff --git a/frontend/src/v1/services/services.test.ts b/frontend/src/v1/services/services.test.ts new file mode 100644 index 0000000000..a2854250c9 --- /dev/null +++ b/frontend/src/v1/services/services.test.ts @@ -0,0 +1,211 @@ +import { http } from "@v1/lib/http"; +import { + AxiosError, + type AxiosRequestConfig, + type InternalAxiosRequestConfig, +} from "axios"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + listOrganizations, + OrganizationError, + organizationLoginUrl, + 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 new AxiosError( + `HTTP ${reply.status}`, + undefined, + config, + undefined, + 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("the login URL names the organisation and asks for no prompt", () => { + expect(organizationLoginUrl("org/1")).toBe( + "/api/v1/login?organization=org%2F1&prompt=none", + ); + }); + + test("a failed request rejects instead of resolving empty", async () => { + reply = { status: 500, data: {} }; + await expect(listOrganizations()).rejects.toThrow("HTTP 500"); + }); + + test.each([ + {}, + { organizations: null }, + "", + ])("a 200 without an organizations array rejects: %j", async (body) => { + reply.data = body; + await expect(listOrganizations()).rejects.toThrow( + "Organization list reply carried no organizations", + ); + }); + + test("a 412 on the list is a session-ended error carrying the body", async () => { + reply = { status: 412, data: { code: "INS" } }; + const error = await listOrganizations().catch((e: unknown) => e); + expect(error).toBeInstanceOf(OrganizationError); + expect(error).toMatchObject({ + kind: "session-ended", + body: { code: "INS" }, + }); + }); + + test("a 403 on the list is not given a meaning", async () => { + reply = { status: 403, data: {} }; + const error = await listOrganizations().catch((e: unknown) => e); + expect(error).not.toBeInstanceOf(OrganizationError); + }); + + test("a 403 on set is an access-removed error", async () => { + reply = { status: 403, data: {} }; + await expect(switchOrganization("org_1")).rejects.toMatchObject({ + kind: "access-removed", + }); + }); + + test.each([412, 500])("a %i on set is thrown as is", async (status) => { + reply = { status, data: {} }; + const error = await switchOrganization("org_1").catch((e: unknown) => e); + expect(error).not.toBeInstanceOf(OrganizationError); + expect(error).toMatchObject({ message: `HTTP ${status}` }); + }); +}); + +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, logs, and falls back to the GET logout", async () => { + const logged = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); + reply = { status: 403, data: {} }; + await signOutAndLeave(); + expect(reset).toHaveBeenCalledTimes(1); + expect(assign).toHaveBeenCalledWith("/api/v1/logout"); + expect(logged).toHaveBeenCalledTimes(1); + logged.mockRestore(); + }); + + 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..e6bb00faf9 --- /dev/null +++ b/frontend/src/v1/services/session.ts @@ -0,0 +1,36 @@ +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 (error) { + // Logged so a failing POST shows up: the backend drops GET logout once v0 goes. + console.error("Sign-out POST failed; falling back to GET", error); + // 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..81478c0924 --- /dev/null +++ b/frontend/src/v1/shell/AccountMenu.test.tsx @@ -0,0 +1,90 @@ +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(); + // The Unstract theme's sidebar is navy under dark text. + expect(screen.getByRole("menu")).toHaveClass("ui:bg-popover"); + 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"); + // The sr-only text promises a new tab, so the link must open one. + const help = screen.getByRole("menuitem", { + name: "Help & docs (opens in a new tab)", + }); + expect(help).toHaveAttribute("target", "_blank"); + expect(help).toHaveAttribute("rel", "noreferrer"); + 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("ui: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..ef457e634b --- /dev/null +++ b/frontend/src/v1/shell/AccountMenu.tsx @@ -0,0 +1,111 @@ +import { ACTION_ICONS, SHELL_ICONS } from "@v1/lib/icons"; +import { initial, initials } from "@v1/lib/organizations"; +import { Slot } from "@v1/lib/plugins"; +import { getPortalContainer } from "@v1/lib/portal"; +import { signOutAndLeave } from "@v1/services/session"; +import { CircleQuestionMark, CircleUserRound } from "lucide-react"; +import { DropdownMenu } from "radix-ui"; +import type { ReactNode } from "react"; + +const HELP_URL = "https://docs.unstract.com"; +const MenuIcon = SHELL_ICONS.accountMenu; +const ExternalIcon = ACTION_ICONS.openExternal; +const SignOutIcon = SHELL_ICONS.signOut; + +// Frame NEW09 (1638:44719). Colours are the design's token names. +const ITEM = + "ui:flex ui:h-8.5 ui:cursor-pointer ui:items-center ui:gap-2.5 ui:rounded-md ui:px-2.25 ui:text-body-small ui:font-medium ui:text-foreground ui:outline-none ui:data-highlighted:bg-muted"; +const ICON = "ui:size-4.5 ui:shrink-0 ui:text-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..498904be5f --- /dev/null +++ b/frontend/src/v1/shell/OrganizationSwitcher.test.tsx @@ -0,0 +1,494 @@ +import { render, screen, waitFor, within } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { + type Organization, + OrganizationError, +} from "@v1/services/organizations"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type CurrentOrganization, + OrganizationSwitcher, + sessionEndedPath, +} from "./OrganizationSwitcher"; + +const list = vi.fn<() => Promise>(); +const switchTo = vi.fn<(id: string) => Promise>(); +vi.mock("@v1/services/organizations", async (importOriginal) => ({ + ...(await importOriginal()), + 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(); + vi.restoreAllMocks(); +}); + +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(organization: CurrentOrganization = current) { + 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("ui:overflow-y-auto", "ui:min-h-0"); + expect( + scroller.closest("[data-radix-popper-content-wrapper] > *"), + ).toHaveClass("ui:max-h-(--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"); + // The Unstract theme's sidebar is navy under dark text. + expect(dialog).toHaveClass("ui:bg-popover"); + 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("when the identity provider picks the organisation, switching signs in again naming it", async () => { + list.mockResolvedValue([zipstack, personal]); + await open({ ...current, switchViaLogin: true }); + await userEvent.click( + await screen.findByRole("button", { + name: "Switch to Tanushree’s organisation", + }), + ); + await userEvent.click( + screen.getByRole("button", { name: "Switch organisation" }), + ); + expect(assign).toHaveBeenCalledWith( + "/api/v1/login?organization=org_td&prompt=none", + ); + expect(switchTo).not.toHaveBeenCalled(); + }); + + 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(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + expect(switchTo).not.toHaveBeenCalled(); + expect(assign).not.toHaveBeenCalled(); + }); + + // The Switch button that opened the dialog unmounted with the popover. + test.each([ + [ + "Cancel", + () => userEvent.click(screen.getByRole("button", { name: "Cancel" })), + ], + ["Escape", () => userEvent.keyboard("{Escape}")], + ])("closing the confirm dialog with %s puts focus back on the pill", async (_, close) => { + list.mockResolvedValue([zipstack, personal]); + await open(); + await userEvent.click( + await screen.findByRole("button", { + name: "Switch to Tanushree’s organisation", + }), + ); + await close(); + await waitFor(() => + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(), + ); + expect(document.activeElement).toBe( + screen.getByRole("button", { name: /Organisation: Zipstack/ }), + ); + }); + + test("a 403 says access was removed, disables the switch and reloads the list", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + // The reload never settles, so the list stays empty behind the dialog. + list + .mockResolvedValueOnce([zipstack, personal]) + .mockReturnValueOnce(new Promise(() => undefined)); + switchTo.mockRejectedValue(new OrganizationError("access-removed")); + await open(); + await userEvent.click( + await screen.findByRole("button", { + name: "Switch to Tanushree’s organisation", + }), + ); + const dialog = screen.getByRole("alertdialog"); + await userEvent.click( + within(dialog).getByRole("button", { name: "Switch organisation" }), + ); + expect( + await within(dialog).findByText( + "You no longer have access to Tanushree’s organisation.", + ), + ).toBeInTheDocument(); + expect( + within(dialog).getByRole("button", { name: "Switch organisation" }), + ).toBeDisabled(); + expect(list).toHaveBeenCalledTimes(2); + // The current organisation keeps its details while the list reloads. + expect(within(dialog).getByText("Admin · 40 members")).toBeInTheDocument(); + await userEvent.click( + within(dialog).getByRole("button", { name: "Cancel" }), + ); + expect(screen.queryByRole("alertdialog")).not.toBeInTheDocument(); + 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 500 offers a retry and enables the confirm button again", async () => { + switchTo.mockRejectedValue(new Error("HTTP 500")); + 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 with a known cause goes to the error page that explains it", async () => { + list.mockRejectedValue( + new OrganizationError("session-ended", { + code: "INS", + domain: "acme.com", + }), + ); + await open(); + await waitFor(() => + expect(assign).toHaveBeenCalledWith("/error?code=INS&domain=acme.com"), + ); + }); + + test("sessionEndedPath: code without domain, and junk bodies", () => { + expect(sessionEndedPath({ code: "USR" })).toBe("/error?code=USR"); + expect(sessionEndedPath({ code: "a&b", domain: "x y" })).toBe( + "/error?code=a%26b&domain=x+y", + ); + expect(sessionEndedPath(undefined)).toBe("/landing"); + expect(sessionEndedPath("")).toBe("/landing"); + expect(sessionEndedPath({ code: 7 })).toBe("/landing"); + }); + + test("a 412 with an empty body means the session is gone: go to sign-in", async () => { + list.mockRejectedValue(new OrganizationError("session-ended")); + 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 current organisation missing from a list of one other: a Switch row, no 'Nothing to switch to.'", async () => { + list.mockResolvedValue([personal]); + await open(); + expect( + await screen.findByRole("button", { + name: "Switch to Tanushree’s organisation", + }), + ).toBeInTheDocument(); + expect(screen.queryByText("Nothing to switch to.")).not.toBeInTheDocument(); + expect( + screen.getByText(/^you belong to 2 organisations$/i), + ).toBeInTheDocument(); + }); + + test("an empty list with no query: nothing to switch to, no 'No organisations match'", async () => { + list.mockResolvedValue([]); + await open(); + expect( + await screen.findByText("Nothing to switch to."), + ).toBeInTheDocument(); + expect( + screen.queryByText("No organisations match"), + ).not.toBeInTheDocument(); + }); + + test("a reply that fails the shape check shows the load error with retry", async () => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + list.mockRejectedValue( + new Error("Organization list reply carried no organizations"), + ); + await open(); + expect( + await screen.findByText("Could not load your organisations."), + ).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Retry" })).toBeInTheDocument(); + }); + + 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("loading shows a skeleton announced as a status", async () => { + list.mockReturnValue(new Promise(() => undefined)); + await open(); + const status = await screen.findByRole("status"); + expect(status).toHaveAttribute("data-slot", "skeleton"); + expect(status).toHaveTextContent("Loading organisations"); + }); + + 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-right-left")).not.toBeNull(); + }); +}); diff --git a/frontend/src/v1/shell/OrganizationSwitcher.tsx b/frontend/src/v1/shell/OrganizationSwitcher.tsx new file mode 100644 index 0000000000..cef70fb926 --- /dev/null +++ b/frontend/src/v1/shell/OrganizationSwitcher.tsx @@ -0,0 +1,429 @@ +import { + InlineAlert, + Skeleton, + SkeletonAvatar, + SkeletonText, +} from "@v1/components/ui/feedback"; +import { ACTION_ICONS, SHELL_ICONS } from "@v1/lib/icons"; +import { + describeOrganization, + initial, + landingPath, + organizationInitials, +} from "@v1/lib/organizations"; +import { getPortalContainer } from "@v1/lib/portal"; +import { + listOrganizations, + type Organization, + OrganizationError, + organizationLoginUrl, + switchOrganization, +} from "@v1/services/organizations"; +import { ArrowRightLeft } from "lucide-react"; +import { AlertDialog, Popover as RadixPopover } from "radix-ui"; +import { useCallback, useRef, useState } from "react"; + +// Frames NEW10 (1638:44855) and page 19, O01 to O04 (2259:182 to 2259:656). +// ponytail: one colour for every organisation avatar; the frames rotate five, +// which have no tokens yet. Rotate again once the tokens include an avatar palette. +const AVATAR_COLOUR = "ui:bg-primary-muted ui:text-primary"; +const SIGN_IN_PATH = "/landing"; +const PillIcon = SHELL_ICONS.orgSwitcher; +const SearchIcon = SHELL_ICONS.search; +const NextIcon = ACTION_ICONS.next; +const BUTTON = + "ui:inline-flex ui:h-8 ui:items-center ui:gap-1.5 ui:rounded-lg ui:border ui:border-border ui:bg-card ui:text-body ui:font-medium ui:text-foreground ui:hover:bg-muted ui:disabled:opacity-50"; + +export interface CurrentOrganization { + id: string; + /** The URL name: paths are //... */ + name: string; + displayName: string; + /** The session's `org_switch_via_login`: switch by signing in again. */ + switchViaLogin?: boolean; +} + +/** + * Where an ended session sends the user. A known cause carries `code` and + * `domain`, which v0's /error page explains (useSessionValid.js); signing in + * again would only fail the same way. An empty body goes back to sign-in. + * These are app routes, not API URLs, so they live here and not in services/. + */ +export function sessionEndedPath(body: unknown): string { + const { code, domain } = (body ?? {}) as { code?: unknown; domain?: unknown }; + if (typeof code !== "string" || code === "") { + return SIGN_IN_PATH; + } + const query = new URLSearchParams({ code }); + if (typeof domain === "string" && domain !== "") { + query.set("domain", domain); + } + return `/error?${query}`; +} + +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; + } + if ( + error instanceof OrganizationError && + error.kind === "session-ended" + ) { + window.location.assign(sessionEndedPath(error.body)); + return; + } + console.error("Could not load the organisations", error); + setState({ status: "error" }); + }, + ); + }, []); + return { state, load }; +} + +function OrgAvatar({ name, size }: { name: string; size: "md" | "sm" }) { + const box = + size === "md" + ? "ui:size-8 ui:rounded-lg ui:text-caption" + : "ui:size-7 ui:rounded-sm ui:text-overline"; + return ( + + {organizationInitials(name)} + + ); +} + +function OrgLine({ org }: { org: Organization }) { + return ( + + + {org.display_name} + + + {describeOrganization(org)} + + + ); +} + +function ConfirmSwitch({ + from, + to, + viaLogin, + onClose, + onAccessRemoved, + returnFocus, +}: { + from: Organization; + to: Organization; + viaLogin: boolean; + onClose: () => void; + /** The user lost access to `to`: the cached list is stale. */ + onAccessRemoved: () => void; + /** The pill: the button that opened this dialog unmounted with the popover. */ + returnFocus: () => void; +}) { + const [busy, setBusy] = useState(false); + const [failed, setFailed] = useState<"retry" | "removed">(); + + async function confirm() { + setBusy(true); + setFailed(undefined); + if (viaLogin) { + window.location.assign(organizationLoginUrl(to.id)); + return; + } + 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 (error) { + console.error("Could not switch organisation", error); + // Retrying cannot work once the user is out of the organisation. + if ( + error instanceof OrganizationError && + error.kind === "access-removed" + ) { + setFailed("removed"); + onAccessRemoved(); + return; + } + setFailed("retry"); + setBusy(false); + } + } + + return ( + + !open && (!busy || failed === "removed") && onClose() + } + > + + + { + event.preventDefault(); + returnFocus(); + }} + className="ui:fixed ui:top-1/2 ui:left-1/2 ui:z-50 ui:w-130 ui:max-w-[calc(100vw-var(--space-8))] ui:-translate-x-1/2 ui:-translate-y-1/2 ui:rounded-lg ui:border ui:border-border ui:bg-popover ui:shadow-xl" + > +
+ + + +
+ + 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 && ( + + )} +
+ + 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(""); + // `from` is captured on Switch, so a reload after a 403 (which empties the + // list) cannot blank the current organisation's details in the dialog. + const [pending, setPending] = useState<{ + from: Organization; + to: Organization; + }>(); + const pill = useRef(null); + + const organizations = state.status === "ready" ? state.organizations : []; + const shown = organizations.filter((org) => + org.display_name.toLowerCase().includes(query.trim().toLowerCase()), + ); + // The current organisation can be missing from the list; it still counts. + const count = + organizations.length + + (organizations.some((o) => o.id === current.id) ? 0 : 1); + const nothingToSwitchTo = organizations.every((o) => o.id === current.id); + + return ( + <> + { + if (open && state.status !== "ready") { + load(); + } + if (!open) { + setQuery(""); + } + }} + > + + + {initial(current.displayName)} + + + {current.displayName} + + + + + + + {(state.status === "loading" || state.status === "idle") && ( + + {[0, 1].map((row) => ( + + + + + ))} + + )} + {state.status === "error" && ( + + )} + {state.status === "ready" && ( + <> + {shown.length === 0 ? ( + query.trim() !== "" && ( +

+ No organisations match +

+ ) + ) : ( +

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

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

+ Nothing to switch to. +

+ )} + + )} +
+
+
+ {pending && ( + setPending(undefined)} + onAccessRemoved={load} + returnFocus={() => pill.current?.focus()} + /> + )} + + ); +}