diff --git a/canvas/src/components/settings/OrgInfoTab.tsx b/canvas/src/components/settings/OrgInfoTab.tsx new file mode 100644 index 000000000..caabc0512 --- /dev/null +++ b/canvas/src/components/settings/OrgInfoTab.tsx @@ -0,0 +1,181 @@ +'use client'; + +import { useCallback, useEffect, useState } from 'react'; +import { api } from '@/lib/api'; +import { fetchSession, type Session } from '@/lib/auth'; +import { getTenantSlug } from '@/lib/tenant'; +import { Spinner } from '@/components/Spinner'; + +/** + * Organization-identity surface inside SettingsPanel. + * + * Closes a chronic UX gap where users (and our own AI agents) had to + * call /cp/auth/me or /cp/orgs from browser devtools to read their + * org_id UUID. Now: a copy-buttoned view of name + slug + UUID for the + * currently-active org, plus a switcher list when the user belongs to + * multiple orgs. + * + * Data path: + * 1. fetchSession() → /cp/auth/me → current org_id + * 2. api.get('/cp/orgs') → list of all orgs the user belongs to + * 3. Match by id === session.org_id; fall back to host-slug match + * if the session probe loses the race. + * + * Read-only — this tab never mutates. Org creation/switching lives at + * /orgs (the post-signup landing page). + */ + +interface Org { + id: string; + slug: string; + name: string; + status?: string; +} + +// /cp/orgs may return a bare array or {orgs: []} — see orgs/page.tsx +// for the same defensive unwrap. +type OrgsResponse = Org[] | { orgs?: Org[] }; + +export function OrgInfoTab() { + const [orgs, setOrgs] = useState(null); + const [session, setSession] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + (async () => { + try { + const [sess, body] = await Promise.all([ + fetchSession().catch(() => null), + api.get('/cp/orgs'), + ]); + if (cancelled) return; + setSession(sess); + setOrgs(Array.isArray(body) ? body : body.orgs ?? []); + } catch (e) { + if (!cancelled) setError(e instanceof Error ? e.message : 'Failed to load org info'); + } finally { + if (!cancelled) setLoading(false); + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const tenantSlug = getTenantSlug(); + const currentOrg = + orgs?.find((o) => session && o.id === session.org_id) ?? + orgs?.find((o) => tenantSlug && o.slug === tenantSlug) ?? + null; + const otherOrgs = orgs?.filter((o) => o.id !== currentOrg?.id) ?? []; + + if (loading) { + return ( +
+ Loading organization… +
+ ); + } + if (error) { + return ( +
+
+ {error} +
+
+ ); + } + if (!currentOrg) { + return ( +
+

+ No organization found for this session. If this is unexpected, sign out and back in, or visit{' '} + /orgs. +

+
+ ); + } + + return ( +
+
+

Current Organization

+

+ IDs you can paste into API calls, support tickets, or CLI arguments. The UUID never changes; + the slug is the URL subdomain. +

+
+ + {otherOrgs.length > 0 && ( +
+

+ Your other organizations ({otherOrgs.length}) +

+ {otherOrgs.map((o) => ( + + ))} +
+ )} +
+ ); +} + +function OrgIdentityCard({ org, highlighted }: { org: Org; highlighted?: boolean }) { + return ( +
+
+ {org.name} + {org.status && ( + {org.status} + )} +
+ + +
+ ); +} + +function IdentityRow({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + const [copied, setCopied] = useState(false); + const onCopy = useCallback(() => { + // Best-effort: jsdom + old Safari throw synchronously on writeText. + try { + navigator.clipboard.writeText(value); + } catch { + /* user can still triple-click select */ + } + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }, [value]); + return ( +
+ {label} + + {value} + + +
+ ); +} diff --git a/canvas/src/components/settings/SettingsPanel.tsx b/canvas/src/components/settings/SettingsPanel.tsx index 6d21bec97..3e528ec45 100644 --- a/canvas/src/components/settings/SettingsPanel.tsx +++ b/canvas/src/components/settings/SettingsPanel.tsx @@ -8,6 +8,7 @@ import { useKeyboardShortcut } from '@/hooks/use-keyboard-shortcut'; import { SecretsTab } from './SecretsTab'; import { TokensTab } from './TokensTab'; import { OrgTokensTab } from './OrgTokensTab'; +import { OrgInfoTab } from './OrgInfoTab'; import { UnsavedChangesGuard } from './UnsavedChangesGuard'; /** Module-level ref so TopBar's SettingsButton can receive focus back on close. */ @@ -116,6 +117,9 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) { Org API Keys + + Organization + @@ -129,6 +133,10 @@ export function SettingsPanel({ workspaceId }: SettingsPanelProps) { + + + +
diff --git a/canvas/src/components/settings/__tests__/OrgInfoTab.test.tsx b/canvas/src/components/settings/__tests__/OrgInfoTab.test.tsx new file mode 100644 index 000000000..6dd0c21c1 --- /dev/null +++ b/canvas/src/components/settings/__tests__/OrgInfoTab.test.tsx @@ -0,0 +1,207 @@ +// @vitest-environment jsdom +/** + * Tests for OrgInfoTab — surfaces current org name/slug/UUID with copy + * buttons, plus a list of the user's other orgs when applicable. + * + * Covers (≥3 cases per the closing-the-UX-gap brief): + * - Loading state (spinner + aria-live) + * - Renders current org matched by session.org_id, with UUID + slug + name + * - Copy button writes the UUID to navigator.clipboard + * - Falls back to host-slug match when session lookup fails + * - Lists other orgs when user belongs to multiple + * - Error banner when /cp/orgs throws + * - Empty/no-match state renders the recovery hint, not a crash + */ +import React from "react"; +import { render, screen, fireEvent, cleanup, act, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { OrgInfoTab } from "../OrgInfoTab"; + +const mockGet = vi.fn(); +const mockFetchSession = vi.fn(); +const mockGetTenantSlug = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { get: (...args: unknown[]) => mockGet(...args) }, +})); +vi.mock("@/lib/auth", () => ({ + fetchSession: (...args: unknown[]) => mockFetchSession(...args), +})); +vi.mock("@/lib/tenant", () => ({ + getTenantSlug: (...args: unknown[]) => mockGetTenantSlug(...args), +})); + +// Stub clipboard +vi.stubGlobal("navigator", { + clipboard: { writeText: vi.fn().mockResolvedValue(undefined) }, +}); + +beforeEach(() => { + vi.useRealTimers(); + mockGet.mockReset(); + mockFetchSession.mockReset(); + mockGetTenantSlug.mockReset(); + mockGetTenantSlug.mockReturnValue(""); + vi.mocked(navigator.clipboard.writeText).mockReset(); +}); + +afterEach(() => { + cleanup(); +}); + +async function flush() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +const AGENTS_TEAM = { + id: "2355b568-0799-4cc7-9e7f-806747f9958c", + slug: "agents-team", + name: "Agents Team", + status: "running", +}; +const OTHER_ORG = { + id: "11111111-1111-4111-8111-111111111111", + slug: "skunkworks", + name: "Skunkworks", + status: "running", +}; + +// ─── Loading ───────────────────────────────────────────────────────────────── + +describe("OrgInfoTab — loading", () => { + it("shows spinner while fetching", () => { + mockGet.mockImplementation(() => new Promise(() => {})); + mockFetchSession.mockImplementation(() => new Promise(() => {})); + render(); + const status = screen.getByRole("status"); + expect(status).toBeTruthy(); + expect(status.getAttribute("aria-live")).toBe("polite"); + expect(status.textContent).toContain("Loading organization"); + }); +}); + +// ─── Current org renders + copy ────────────────────────────────────────────── + +describe("OrgInfoTab — current org", () => { + it("renders the org matched by session.org_id with name, slug, UUID", async () => { + mockFetchSession.mockResolvedValue({ + user_id: "u-1", + org_id: AGENTS_TEAM.id, + email: "hongming@moleculesai.app", + }); + mockGet.mockResolvedValue([AGENTS_TEAM, OTHER_ORG]); + + render(); + await flush(); + await waitFor(() => screen.getByText("Current Organization")); + + // Name shown + expect(screen.getByText("Agents Team")).toBeTruthy(); + // Slug shown + expect(screen.getByText("agents-team")).toBeTruthy(); + // UUID shown + expect(screen.getByText(AGENTS_TEAM.id)).toBeTruthy(); + }); + + it("copy-UUID button writes the UUID to navigator.clipboard", async () => { + mockFetchSession.mockResolvedValue({ + user_id: "u-1", + org_id: AGENTS_TEAM.id, + email: "hongming@moleculesai.app", + }); + mockGet.mockResolvedValue([AGENTS_TEAM]); + + render(); + await flush(); + await waitFor(() => screen.getByText(AGENTS_TEAM.id)); + + const copyUuid = screen.getByRole("button", { name: /Copy UUID/i }); + fireEvent.click(copyUuid); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(AGENTS_TEAM.id); + // Optimistic "Copied" label flip + await waitFor(() => + expect( + screen.getByRole("button", { name: /Copy UUID/i }).textContent, + ).toContain("Copied"), + ); + }); + + it("copy-Slug button writes the slug to navigator.clipboard", async () => { + mockFetchSession.mockResolvedValue({ + user_id: "u-1", + org_id: AGENTS_TEAM.id, + email: "hongming@moleculesai.app", + }); + mockGet.mockResolvedValue([AGENTS_TEAM]); + + render(); + await flush(); + await waitFor(() => screen.getByText(AGENTS_TEAM.slug)); + + fireEvent.click(screen.getByRole("button", { name: /Copy Slug/i })); + expect(navigator.clipboard.writeText).toHaveBeenCalledWith(AGENTS_TEAM.slug); + }); +}); + +// ─── Fallback: host-slug match when session fails ──────────────────────────── + +describe("OrgInfoTab — fallbacks", () => { + it("falls back to host-slug match when fetchSession rejects", async () => { + mockFetchSession.mockRejectedValue(new Error("session probe failed")); + mockGetTenantSlug.mockReturnValue("agents-team"); + mockGet.mockResolvedValue({ orgs: [AGENTS_TEAM, OTHER_ORG] }); // wrapped shape + + render(); + await flush(); + await waitFor(() => screen.getByText("Current Organization")); + + expect(screen.getByText("Agents Team")).toBeTruthy(); + expect(screen.getByText(AGENTS_TEAM.id)).toBeTruthy(); + }); + + it("lists other orgs the user belongs to under a separate header", async () => { + mockFetchSession.mockResolvedValue({ + user_id: "u-1", + org_id: AGENTS_TEAM.id, + email: "hongming@moleculesai.app", + }); + mockGet.mockResolvedValue([AGENTS_TEAM, OTHER_ORG]); + + render(); + await flush(); + await waitFor(() => screen.getByText(/Your other organizations/)); + + expect(screen.getByText("Skunkworks")).toBeTruthy(); + expect(screen.getByText(OTHER_ORG.id)).toBeTruthy(); + }); +}); + +// ─── Error + empty handling ────────────────────────────────────────────────── + +describe("OrgInfoTab — error + empty", () => { + it("renders an error banner when /cp/orgs throws", async () => { + mockFetchSession.mockResolvedValue(null); + mockGet.mockRejectedValue(new Error("API GET /cp/orgs: 500 boom")); + + render(); + await flush(); + await waitFor(() => screen.getByText(/500 boom/)); + expect(screen.queryByText("Current Organization")).toBeNull(); + }); + + it("renders the recovery hint when no org matches (no crash)", async () => { + mockFetchSession.mockResolvedValue(null); + mockGetTenantSlug.mockReturnValue(""); + mockGet.mockResolvedValue([]); + + render(); + await flush(); + await waitFor(() => + screen.getByText(/No organization found for this session/), + ); + }); +}); diff --git a/canvas/src/components/settings/index.ts b/canvas/src/components/settings/index.ts index c1f86afba..f099625af 100644 --- a/canvas/src/components/settings/index.ts +++ b/canvas/src/components/settings/index.ts @@ -8,3 +8,4 @@ export { SearchBar } from './SearchBar'; export { EmptyState } from './EmptyState'; export { DeleteConfirmDialog } from './DeleteConfirmDialog'; export { UnsavedChangesGuard } from './UnsavedChangesGuard'; +export { OrgInfoTab } from './OrgInfoTab';