From 7d758d2963cfd549b325ba53a0a08af0780be5f8 Mon Sep 17 00:00:00 2001 From: Dotta Date: Tue, 18 Aug 2026 14:19:59 +0000 Subject: [PATCH] feat(apps): make connections account-aware Co-Authored-By: Paperclip --- .../components/AppConnectionSidebar.test.tsx | 14 +- ui/src/pages/apps/AppDetail.test.tsx | 56 +++++- ui/src/pages/apps/AppDetail.tsx | 88 +++++---- ui/src/pages/apps/AppNotConnected.test.tsx | 43 ++++- ui/src/pages/apps/AppNotConnected.tsx | 114 +++++------ ui/src/pages/apps/Browse.test.tsx | 72 +++++-- ui/src/pages/apps/Browse.tsx | 179 ++++++++++++++---- ui/src/pages/apps/Connections.test.tsx | 71 +++++-- ui/src/pages/apps/Connections.tsx | 136 +++++++------ ui/src/pages/apps/app-tabs.ts | 3 +- ui/src/pages/apps/connection-owner.tsx | 57 ++++++ 11 files changed, 603 insertions(+), 230 deletions(-) create mode 100644 ui/src/pages/apps/connection-owner.tsx diff --git a/ui/src/components/AppConnectionSidebar.test.tsx b/ui/src/components/AppConnectionSidebar.test.tsx index dd4da073b29..bf3a44a59df 100644 --- a/ui/src/components/AppConnectionSidebar.test.tsx +++ b/ui/src/components/AppConnectionSidebar.test.tsx @@ -164,18 +164,18 @@ describe("AppConnectionSidebar", () => { await flushReact(); } - it("renders a back link and the connected app tabs (including Test)", async () => { + it("renders a back link and the connected app tabs with Test after Setup", async () => { await renderSidebar(); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); expect(container.textContent).toContain("GitHub"); - expect(container.querySelectorAll("[data-to]").length).toBe(6); + expect(container.querySelectorAll("[data-to]").length).toBe(5); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/setup", label: "Setup", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/review", label: "Review", badge: 3, badgeTone: "danger" })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/permissions", label: "Permissions", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/test", label: "Test", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/activity", label: "Activity", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/conn-1/advanced", label: "Advanced", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); }); it("marks the current tab active through the nav item target", async () => { @@ -197,11 +197,11 @@ describe("AppConnectionSidebar", () => { expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/review", label: "Review", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/permissions", label: "Permissions", end: true })); expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/activity", label: "Activity", end: true })); - expect(sidebarNavItemMock).toHaveBeenCalledWith(expect.objectContaining({ to: "/apps/app/app-1/advanced", label: "Advanced", end: true })); + expect(sidebarNavItemMock).not.toHaveBeenCalledWith(expect.objectContaining({ label: "Advanced" })); expect(container.querySelector('[data-to="/apps/app/app-1/review"]')?.getAttribute("data-active")).toBe("true"); // The Test tab needs a live connection, so it is hidden in application mode. expect(container.querySelector('[data-to="/apps/app/app-1/test"]')).toBeNull(); - expect(container.querySelectorAll("[data-to]").length).toBe(5); + expect(container.querySelectorAll("[data-to]").length).toBe(4); }); it("keeps rendering a connection sidebar when its connection is unavailable", async () => { @@ -213,7 +213,7 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); - expect(container.querySelectorAll("[data-to]").length).toBe(6); + expect(container.querySelectorAll("[data-to]").length).toBe(5); }); it("keeps rendering an application sidebar when its application is unavailable", async () => { @@ -226,6 +226,6 @@ describe("AppConnectionSidebar", () => { expect(container.textContent).toContain("App"); expect(container.querySelector('a[href="/apps/connections"]')?.textContent).toContain("All apps"); - expect(container.querySelectorAll("[data-to]").length).toBe(5); + expect(container.querySelectorAll("[data-to]").length).toBe(4); }); }); diff --git a/ui/src/pages/apps/AppDetail.test.tsx b/ui/src/pages/apps/AppDetail.test.tsx index d57cf81a3ff..123d9f84371 100644 --- a/ui/src/pages/apps/AppDetail.test.tsx +++ b/ui/src/pages/apps/AppDetail.test.tsx @@ -21,6 +21,8 @@ const finishAppMock = vi.hoisted(() => vi.fn()); const putConnectionInstallsMock = vi.hoisted(() => vi.fn()); const refreshCatalogMock = vi.hoisted(() => vi.fn()); const startOAuthMock = vi.hoisted(() => vi.fn()); +const listUserDirectoryMock = vi.hoisted(() => vi.fn()); +const getSessionMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); const mockParams = vi.hoisted(() => ({ connectionId: "conn-1", tab: "setup" as string | undefined })); const navigateComponentMock = vi.hoisted(() => vi.fn()); @@ -51,6 +53,18 @@ vi.mock("@/api/tools", () => ({ }, })); +vi.mock("@/api/access", () => ({ + accessApi: { + listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId), + }, +})); + +vi.mock("@/api/auth", () => ({ + authApi: { + getSession: () => getSessionMock(), + }, +})); + vi.mock("@/api/agents", () => ({ agentsApi: { list: vi.fn().mockResolvedValue([ @@ -249,6 +263,11 @@ describe("AppDetail", () => { authorizationUrl: "https://example.test/oauth", expiresAt: "2026-07-10T00:00:00.000Z", }); + listUserDirectoryMock.mockResolvedValue({ users: [] }); + getSessionMock.mockResolvedValue({ + user: { id: "user-1", name: "Dotta", image: null }, + session: { userId: "user-1" }, + }); }); afterEach(() => { @@ -277,7 +296,6 @@ describe("AppDetail", () => { "review", "permissions", "activity", - "advanced", ]); }); @@ -327,7 +345,6 @@ describe("AppDetail", () => { ["review", "Review 1 new action", true], ["permissions", "Action permissions", true], ["activity", "No activity yet.", false], - ["advanced", "Technical details", false], ])("renders the %s tab panel", async (tab, expectedText, showsActionCount) => { mockParams.tab = tab; @@ -339,6 +356,17 @@ describe("AppDetail", () => { expect(container.querySelector("section.bg-card")).toBeNull(); }); + it("redirects the legacy Advanced route to Setup", async () => { + mockParams.tab = "advanced"; + + await renderAppDetail(); + + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/apps/conn-1/setup", + replace: true, + }); + }); + it("renders setup without waiting for tool discovery", async () => { listCatalogMock.mockImplementation(() => new Promise(() => undefined)); @@ -360,8 +388,8 @@ describe("AppDetail", () => { expect(container.textContent).not.toContain("Action permissions"); }); - it("hides secret URL parameters in advanced technical details", async () => { - mockParams.tab = "advanced"; + it("hides secret URL parameters in setup technical details", async () => { + mockParams.tab = "setup"; getConnectionMock.mockResolvedValue( connection({ config: { @@ -446,13 +474,15 @@ describe("AppDetail", () => { expect(finishInput.enabledCatalogEntryIds).not.toContain("catalog-quarantined-block"); }); - it("keeps setup focused on description and lifecycle", async () => { + it("keeps setup focused while including technical details and the danger zone", async () => { mockParams.tab = "setup"; await renderAppDetail(); expect(container.textContent).toContain("Give agents a governed way to inspect repositories and pull requests."); expect(container.textContent).toContain("Agents can use this app"); + expect(container.textContent).toContain("Technical details"); + expect(container.textContent).toContain("Danger zone"); expect(container.textContent).not.toContain("Read repo"); expect(container.textContent).not.toContain("Action permissions"); expect(container.querySelector("section.bg-card")).toBeNull(); @@ -484,6 +514,7 @@ describe("AppDetail", () => { it("matches connected Notion guidance to the reconnect action", async () => { getConnectionMock.mockResolvedValue(connection({ name: "Notion", + createdByUserId: "user-1", config: { sourceTemplateKey: "notion", oauth: { @@ -506,9 +537,24 @@ describe("AppDetail", () => { urlPatterns: [], }], }); + listUserDirectoryMock.mockResolvedValue({ + users: [{ + principalId: "user-1", + status: "active", + user: { + id: "user-1", + name: "Dotta", + email: "dotta@example.com", + image: "https://example.com/dotta.png", + }, + }], + }); await renderAppDetail(); + expect(container.textContent).toContain("Dotta’s Notion"); + expect(container.textContent).toContain("Connected by"); + expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy(); expect(container.textContent).toContain( "Your workspace authorization is active. Reconnect any time to replace it.", ); diff --git a/ui/src/pages/apps/AppDetail.tsx b/ui/src/pages/apps/AppDetail.tsx index 143dd668a01..dab37d3d5dd 100644 --- a/ui/src/pages/apps/AppDetail.tsx +++ b/ui/src/pages/apps/AppDetail.tsx @@ -20,7 +20,7 @@ import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; import { accessApi } from "@/api/access"; import { authApi } from "@/api/auth"; -import { buildCompanyUserLabelMap } from "@/lib/company-members"; +import { buildCompanyUserLabelMap, buildCompanyUserProfileMap } from "@/lib/company-members"; import { installPayload, installStateFrom, type InstallState } from "@/lib/tool-installs"; import { resolveAuthorizationTarget } from "@/lib/authorizationUrl"; import { navigateTopLevel } from "@/lib/browserNavigation"; @@ -50,6 +50,12 @@ import { connectionTransportLabel, } from "./app-detail/AdvancedPanel"; import type { AccessDraft } from "./app-detail/types"; +import { + ConnectionOwnerIdentity, + connectionDisplayNameForOwner, + connectionOwnerProfile, + type ConnectionOwnerProfile, +} from "./connection-owner"; export { DangerZone, connectionAddress, connectionTransportLabel }; @@ -108,7 +114,7 @@ export function AppDetail() { const userDirectoryQuery = useQuery({ queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), - enabled: !!selectedCompanyId && activeTab === "activity", + enabled: !!selectedCompanyId && !!activeTab, }); const sessionQuery = useQuery({ queryKey: queryKeys.auth.session, @@ -117,7 +123,21 @@ export function AppDetail() { }); const connection = connectionQuery.data; - const appName = connection ? humanizeConnectionDisplayName(connection) : "App"; + const logoEntry = useMemo( + () => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection), + [galleryQuery.data, connection], + ); + const userProfileById = useMemo( + () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), + [userDirectoryQuery.data], + ); + const owner = connection ? connectionOwnerProfile(connection, userProfileById) : null; + const baseAppName = connection + ? logoEntry ? appDefinitionName(logoEntry) : humanizeConnectionDisplayName(connection) + : "App"; + const appName = connection + ? connectionDisplayNameForOwner(connection, baseAppName, owner) + : "App"; useEffect(() => { if (!activeTab) return; @@ -155,11 +175,6 @@ export function AppDetail() { } return labels; }, [userDirectoryQuery.data, sessionQuery.data]); - const logoEntry = useMemo( - () => galleryEntryFor((galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[], connection), - [galleryQuery.data, connection], - ); - const [pending, setPending] = useState(false); const persist = useMutation({ mutationFn: (next: { @@ -400,6 +415,7 @@ export function AppDetail() { logoEntry={logoEntry} status={status} actionCount={actionCount} + owner={owner} renaming={renaming} nameDraft={nameDraft} renamePending={rename.isPending} @@ -428,16 +444,30 @@ export function AppDetail() { )} {activeTab === "setup" && ( - toggleEnabled.mutate()} - configUpdateDisabled={updateConfig.isPending} - onUpdateConfig={(config) => updateConfig.mutate(config)} - oauthStartDisabled={startOAuth.isPending} - onStartOAuth={() => startOAuth.mutate()} - /> +
+ toggleEnabled.mutate()} + configUpdateDisabled={updateConfig.isPending} + onUpdateConfig={(config) => updateConfig.mutate(config)} + oauthStartDisabled={startOAuth.isPending} + onStartOAuth={() => startOAuth.mutate()} + /> + removeApp.mutate()} + onReplaced={() => { + queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) }); + queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) }); + }} + /> +
)} {activeTab === "review" && ( reviewFailed @@ -506,20 +536,6 @@ export function AppDetail() { userLabelById={userLabelById} /> )} - {activeTab === "advanced" && ( - removeApp.mutate()} - onReplaced={() => { - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) }); - queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) }); - }} - /> - )} ); } @@ -530,6 +546,7 @@ function AppDetailHeader({ logoEntry, status, actionCount, + owner, renaming, nameDraft, renamePending, @@ -543,6 +560,7 @@ function AppDetailHeader({ logoEntry: AppGalleryDisplayEntry | null; status: StatusInfo; actionCount: number | null; + owner: ConnectionOwnerProfile | null; renaming: boolean; nameDraft: string; renamePending: boolean; @@ -597,6 +615,12 @@ function AppDetailHeader({ {connectionDisplaySecondaryHint(connection) && (

{connectionDisplaySecondaryHint(connection)}

)} + {owner && ( +
+ Connected by + +
+ )} {unverifiedHost ? : null}
diff --git a/ui/src/pages/apps/AppNotConnected.test.tsx b/ui/src/pages/apps/AppNotConnected.test.tsx index 684cb2283a2..7bf088d3807 100644 --- a/ui/src/pages/apps/AppNotConnected.test.tsx +++ b/ui/src/pages/apps/AppNotConnected.test.tsx @@ -11,6 +11,7 @@ const listConnectionsMock = vi.hoisted(() => vi.fn()); const listGalleryMock = vi.hoisted(() => vi.fn()); const listConnectionActivityMock = vi.hoisted(() => vi.fn()); const listActionRequestsMock = vi.hoisted(() => vi.fn()); +const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const updateApplicationMock = vi.hoisted(() => vi.fn()); const mockAgentsList = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); @@ -34,6 +35,12 @@ vi.mock("@/api/tools", () => ({ }, })); +vi.mock("@/api/access", () => ({ + accessApi: { + listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId), + }, +})); + vi.mock("@/api/agents", () => ({ agentsApi: { list: (companyId: string) => mockAgentsList(companyId), @@ -150,6 +157,7 @@ describe("AppNotConnected", () => { }); listConnectionActivityMock.mockResolvedValue({ events: [], issues: {}, actionRequests: {} }); listActionRequestsMock.mockResolvedValue({ actionRequests: [] }); + listUserDirectoryMock.mockResolvedValue({ users: [] }); mockAgentsList.mockResolvedValue([]); updateApplicationMock.mockResolvedValue(application({ status: "archived" })); }); @@ -213,17 +221,37 @@ describe("AppNotConnected", () => { }); listConnectionsMock.mockResolvedValue({ connections: [ - connection({ id: "conn-one", applicationId: "app-1", name: "Notion", status: "active" }), + connection({ + id: "conn-one", + applicationId: "app-1", + name: "Notion", + status: "active", + createdByUserId: "user-1", + }), connection({ id: "conn-two", applicationId: "app-2", name: "Notion team", status: "active" }), ], }); + listUserDirectoryMock.mockResolvedValue({ + users: [{ + principalId: "user-1", + status: "active", + user: { + id: "user-1", + name: "Dotta", + email: "dotta@example.com", + image: "https://example.com/dotta.png", + }, + }], + }); await renderPage(); expect(navigateComponentMock).not.toHaveBeenCalled(); expect(container.textContent).toContain("2 connected"); expect(container.textContent).toContain("Already connected to Notion"); + expect(container.textContent).toContain("Dotta’s Notion"); expect(container.textContent).toContain("Notion team"); + expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy(); expect(container.textContent).toContain("Connect another"); const editRows = Array.from(container.querySelectorAll("button")).filter((button) => @@ -251,7 +279,6 @@ describe("AppNotConnected", () => { ["permissions", "Permissions paused"], ["test", "Reconnect to test this app."], ["activity", "No activity yet."], - ["advanced", "Danger zone"], ])("renders the %s tab with persistent app identity", async (tab, expectedText) => { mockParams.tab = tab; @@ -262,6 +289,17 @@ describe("AppNotConnected", () => { expect(container.textContent).toContain(expectedText); }); + it("redirects the legacy Advanced route to Setup", async () => { + mockParams.tab = "advanced"; + + await renderPage(); + + expect(navigateComponentMock).toHaveBeenCalledWith({ + to: "/apps/app/app-1/setup", + replace: true, + }); + }); + it("keeps previous setup context on reconnect tabs", async () => { mockParams.tab = "setup"; @@ -270,5 +308,6 @@ describe("AppNotConnected", () => { expect(container.textContent).toContain("Previous setup"); expect(container.textContent).toContain("Last error: Token expired."); expect(container.textContent).toContain("https://github.example/mcp"); + expect(container.textContent).toContain("Danger zone"); }); }); diff --git a/ui/src/pages/apps/AppNotConnected.tsx b/ui/src/pages/apps/AppNotConnected.tsx index d49da9cb48d..d2092f61429 100644 --- a/ui/src/pages/apps/AppNotConnected.tsx +++ b/ui/src/pages/apps/AppNotConnected.tsx @@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import type { ToolConnection } from "@paperclipai/shared"; import { connectionDisplaySecondaryHint, - humanizeConnectionDisplayName, isToolConnectionAttentionHealth, } from "@paperclipai/shared"; import { Navigate, useNavigate, useParams } from "@/lib/router"; @@ -14,8 +13,10 @@ import { queryKeys } from "@/lib/queryKeys"; import { timeAgo } from "@/lib/timeAgo"; import { toolsApi } from "@/api/tools"; import { agentsApi } from "@/api/agents"; +import { accessApi } from "@/api/access"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { buildCompanyUserProfileMap, type CompanyUserProfile } from "@/lib/company-members"; import { AppLogo } from "./AppLogo"; import { appApplicationSourceSlug, @@ -29,6 +30,11 @@ import { connectionAddress, connectionTransportLabel, DangerZone } from "./AppDe import { ActivityPanel } from "./app-detail/ActivityPanel"; import { ReviewPanel } from "./app-detail/ReviewPanel"; import { appApplicationTabHref, appTabHref, appTabLabel, isAppTabKey, type AppTabKey } from "./app-tabs"; +import { + ConnectionOwnerIdentity, + connectionDisplayNameForOwner, + connectionOwnerProfile, +} from "./connection-owner"; export function AppNotConnected() { const { applicationId = "", tab } = useParams<{ applicationId: string; tab?: string }>(); @@ -54,6 +60,11 @@ export function AppNotConnected() { queryFn: () => toolsApi.listGallery(selectedCompanyId!), enabled: !!selectedCompanyId && !!activeTab, }); + const userDirectoryQuery = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId && !!activeTab, + }); const application = useMemo( () => (applicationsQuery.data?.applications ?? []).find((app) => app.id === applicationId), @@ -79,6 +90,10 @@ export function AppNotConnected() { ); const activeConnection = activeConnections[0] ?? null; const previousConnection = useMemo(() => latestArchivedConnection(appConnections), [appConnections]); + const userProfileById = useMemo( + () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), + [userDirectoryQuery.data], + ); const activityQuery = useQuery({ queryKey: queryKeys.tools.connectionActivity(previousConnection?.id ?? "__none__"), queryFn: () => toolsApi.listConnectionActivity(previousConnection!.id, 20), @@ -175,14 +190,22 @@ export function AppNotConnected() { /> {activeTab === "setup" && ( - navigate(connectHref)} - onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))} - /> +
+ navigate(connectHref)} + onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))} + /> + remove.mutate()} + /> +
)} {activeTab === "review" && ( previousConnection ? ( @@ -228,15 +251,6 @@ export function AppNotConnected() { /> ) )} - {activeTab === "advanced" && ( - remove.mutate()} - /> - )}
); } @@ -275,6 +289,7 @@ function SetupTab({ activeConnections, previousConnection, previousAddress, + userProfileById, onConnect, onEdit, }: { @@ -282,6 +297,7 @@ function SetupTab({ activeConnections: ToolConnection[]; previousConnection: ToolConnection | null; previousAddress: string | null; + userProfileById: ReadonlyMap; onConnect: () => void; onEdit: (connectionId: string) => void; }) { @@ -292,11 +308,12 @@ function SetupTab({

Already connected to {applicationName}

- Open a connection to edit it, or add another account. + Edit an existing connection, or deliberately add another account below.

-
+
{activeConnections.map((connection) => { + const owner = connectionOwnerProfile(connection, userProfileById); const secondary = connectionDisplaySecondaryHint(connection) ?? (connection.lastUsedAt ? `Last used ${timeAgo(connection.lastUsedAt)}` : "Not used yet"); const status = connection.enabled === false || connection.status === "disabled" @@ -309,14 +326,15 @@ function SetupTab({ key={connection.id} type="button" onClick={() => onEdit(connection.id)} - className="flex w-full items-center gap-3 border-b border-border px-4 py-3 text-left transition-colors last:border-0 hover:bg-muted/30" + className="flex w-full items-center gap-3 py-3 text-left transition-colors hover:bg-muted/30" >
- {humanizeConnectionDisplayName(connection)} + {connectionDisplayNameForOwner(connection, applicationName, owner)}
{secondary}
+ {status} Edit → @@ -325,7 +343,7 @@ function SetupTab({
-
+

Connect another

@@ -342,7 +360,7 @@ function SetupTab({ return (
-
+

@@ -361,7 +379,11 @@ function SetupTab({

{previousConnection && ( - + )}
); @@ -370,13 +392,21 @@ function SetupTab({ function PreviousSetup({ connection, previousAddress, + owner, }: { connection: ToolConnection; previousAddress: string | null; + owner: CompanyUserProfile | null; }) { return ( -
+

Previous setup

+ {owner && ( +
+ Connected by + +
+ )} {connection.healthMessage && (

Last error: {connection.healthMessage} @@ -398,7 +428,7 @@ function PreviousSetup({ function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) { return ( -

+

Permissions paused

Reconnect this app to edit who can use it and which actions need a human first. @@ -412,37 +442,9 @@ function PermissionsTab({ previousConnection }: { previousConnection: ToolConnec ); } -function AdvancedTab({ - appName, - previousConnection, - previousAddress, - removing, - onRemove, -}: { - appName: string; - previousConnection: ToolConnection | null; - previousAddress: string | null; - removing: boolean; - onRemove: () => void; -}) { - return ( -

- {previousConnection ? ( - - ) : ( - - )} - -
- ); -} - function EmptyTab({ title, body }: { title: string; body: string }) { return ( -
+

{title}

{body}

diff --git a/ui/src/pages/apps/Browse.test.tsx b/ui/src/pages/apps/Browse.test.tsx index 1696a5d4e5a..1465cbcff23 100644 --- a/ui/src/pages/apps/Browse.test.tsx +++ b/ui/src/pages/apps/Browse.test.tsx @@ -9,6 +9,7 @@ import { Browse } from "./Browse"; const listGalleryMock = vi.hoisted(() => vi.fn()); const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); +const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const navigateMock = vi.hoisted(() => vi.fn()); vi.mock("@/api/tools", () => ({ @@ -19,6 +20,12 @@ vi.mock("@/api/tools", () => ({ }, })); +vi.mock("@/api/access", () => ({ + accessApi: { + listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId), + }, +})); + vi.mock("@/lib/router", () => ({ Link: ({ children, to }: { children: React.ReactNode; to: string }) => {children}, useNavigate: () => navigateMock, @@ -86,6 +93,7 @@ describe("Browse store door (PAP-13254 door 1)", () => { }); listApplicationsMock.mockResolvedValue({ applications: [] }); listConnectionsMock.mockResolvedValue({ connections: [] }); + listUserDirectoryMock.mockResolvedValue({ users: [] }); container = document.createElement("div"); document.body.appendChild(container); }); @@ -129,17 +137,17 @@ describe("Browse store door (PAP-13254 door 1)", () => { it("enables Notion, Zapier, and custom URLs while fading unfinished integrations", async () => { await renderBrowse(); - const zapierTiles = Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.includes("Zapier"), + const zapierTiles = Array.from( + container.querySelectorAll('button[aria-label="Connect for Zapier"]'), ); - const githubTiles = Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.includes("GitHub"), + const githubTiles = Array.from( + container.querySelectorAll('button[aria-label="Connect for GitHub"]'), ); - const notionTiles = Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.includes("Notion"), + const notionTiles = Array.from( + container.querySelectorAll('button[aria-label="Connect for Notion"]'), ); - const tile = Array.from(container.querySelectorAll("button")).find((button) => - button.textContent?.includes("Acme CRM"), + const tile = container.querySelector( + 'button[aria-label="Coming soon for Acme CRM"]', ); const byoCard = Array.from(container.querySelectorAll("button")).find((button) => button.textContent?.includes("Connect your own tool"), @@ -194,7 +202,7 @@ describe("Browse store door (PAP-13254 door 1)", () => { expect(text).not.toContain("Popular"); }); - it("shows existing connection counts and opens the provider landing page", async () => { + it("shows the connected owner, edits existing connections, and offers a deliberate second account", async () => { listApplicationsMock.mockResolvedValue({ applications: [ { id: "app-notion", status: "active", applicationKey: "app-gallery:notion:one", metadata: {} }, @@ -202,24 +210,58 @@ describe("Browse store door (PAP-13254 door 1)", () => { }); listConnectionsMock.mockResolvedValue({ connections: [ - { id: "conn-one", applicationId: "app-notion", status: "active" }, + { + id: "conn-one", + applicationId: "app-notion", + name: "Notion", + status: "active", + createdByUserId: "user-1", + }, { id: "conn-two", applicationId: "app-notion", status: "disabled" }, { id: "conn-draft", applicationId: "app-notion", status: "draft" }, ], }); + listUserDirectoryMock.mockResolvedValue({ + users: [{ + principalId: "user-1", + status: "active", + user: { + id: "user-1", + name: "Dotta", + email: "dotta@example.com", + image: "https://example.com/dotta.png", + }, + }], + }); await renderBrowse(); - const notionTiles = Array.from(container.querySelectorAll("button")).filter((button) => - button.textContent?.includes("Notion"), + const editButtons = Array.from( + container.querySelectorAll( + 'button[aria-label="Edit connections for Notion"]', + ), ); - expect(notionTiles).toHaveLength(2); - expect(notionTiles.every((button) => button.textContent?.includes("2 connected already"))).toBe(true); + expect(editButtons).toHaveLength(2); + expect(editButtons.every((button) => button.textContent?.includes("Edit connections"))).toBe(true); + expect(container.textContent).toContain("2 connected"); + expect(container.textContent).toContain("Dotta’s Notion"); + expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy(); await act(async () => { - notionTiles[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + editButtons[0]?.dispatchEvent(new MouseEvent("click", { bubbles: true })); }); expect(navigateMock).toHaveBeenCalledWith("/apps/app/app-notion/setup"); + + const addAnother = container.querySelector( + 'button[aria-label="Add another Notion account"]', + ); + expect(addAnother?.textContent).toContain("Add another account"); + await act(async () => { + addAnother?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(navigateMock).toHaveBeenCalledWith( + "/apps/connect?source=notion&applicationId=app-notion&name=Notion&new=1", + ); }); it("keeps the custom URL option available when gallery search has no matches", async () => { diff --git a/ui/src/pages/apps/Browse.tsx b/ui/src/pages/apps/Browse.tsx index b980fc7dc67..1898df0fb22 100644 --- a/ui/src/pages/apps/Browse.tsx +++ b/ui/src/pages/apps/Browse.tsx @@ -1,12 +1,15 @@ import { useEffect, useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Link2, Search } from "lucide-react"; +import { Check, Link2, Search } from "lucide-react"; import { useNavigate } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; +import { accessApi } from "@/api/access"; +import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; +import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { AppLogo } from "./AppLogo"; import { appApplicationSourceSlug, @@ -24,6 +27,12 @@ import { POPULAR_KEYS, ZAPIER_CONNECT_HREF, } from "./store-cards"; +import { + ConnectionOwnerIdentity, + connectionDisplayNameForOwner, + connectionOwnerProfile, + type ConnectionOwnerProfile, +} from "./connection-owner"; function connectHrefFor(entry: AppGalleryDisplayEntry): string | null { const slug = appDefinitionSlug(entry); @@ -33,6 +42,20 @@ function connectHrefFor(entry: AppGalleryDisplayEntry): string | null { return null; } +function additionalConnectionHref( + entry: AppGalleryDisplayEntry, + applicationId: string, +): string | null { + const baseHref = connectHrefFor(entry); + if (!baseHref) return null; + const [path, rawQuery = ""] = baseHref.split("?"); + const params = new URLSearchParams(rawQuery); + params.set("applicationId", applicationId); + params.set("name", appDefinitionName(entry)); + params.set("new", "1"); + return `${path}?${params.toString()}`; +} + /** * Door 1 — Browse (the store) (PAP-13254 / U3 §4). * @@ -70,6 +93,11 @@ export function Browse() { queryFn: () => toolsApi.listConnections(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const userDirectoryQuery = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const gallery = (galleryQuery.data?.apps ?? []) as AppGalleryDisplayEntry[]; const popular = useMemo( @@ -91,29 +119,38 @@ export function Browse() { }, [gallery, trimmed]); const connectionSummaryBySlug = useMemo(() => { const connections = connectionsQuery.data?.connections ?? []; - const connectedCountByApplicationId = new Map(); + const connectionsByApplicationId = new Map(); for (const connection of connections) { if (connection.status === "archived" || connection.status === "draft") continue; - connectedCountByApplicationId.set( + connectionsByApplicationId.set( connection.applicationId, - (connectedCountByApplicationId.get(connection.applicationId) ?? 0) + 1, + [...(connectionsByApplicationId.get(connection.applicationId) ?? []), connection], ); } - const summaries = new Map(); + const summaries = new Map(); for (const application of applicationsQuery.data?.applications ?? []) { if (application.status === "archived") continue; const slug = appApplicationSourceSlug(application); if (!slug) continue; - const count = connectedCountByApplicationId.get(application.id) ?? 0; + const appConnections = connectionsByApplicationId.get(application.id) ?? []; const current = summaries.get(slug); summaries.set(slug, { applicationId: current?.applicationId ?? application.id, - count: (current?.count ?? 0) + count, + count: (current?.count ?? 0) + appConnections.length, + primaryConnection: current?.primaryConnection ?? appConnections[0] ?? null, }); } return summaries; }, [applicationsQuery.data, connectionsQuery.data]); + const userProfileById = useMemo( + () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), + [userDirectoryQuery.data], + ); if (!selectedCompanyId) { return
Select a company to browse apps.
; @@ -124,13 +161,25 @@ export function Browse() { const tileProps = (entry: AppGalleryDisplayEntry) => { const summary = connectionSummaryBySlug.get(appDefinitionSlug(entry)); const connectHref = connectHrefFor(entry); + const primaryConnection = summary?.primaryConnection ?? null; + const owner = primaryConnection ? connectionOwnerProfile(primaryConnection, userProfileById) : null; + const addAnotherHref = summary + ? additionalConnectionHref(entry, summary.applicationId) + : null; return { connectedCount: summary?.count ?? 0, - onOpen: summary && summary.count > 0 - ? () => navigate(`/apps/app/${summary.applicationId}/setup`) + connectionName: primaryConnection + ? connectionDisplayNameForOwner(primaryConnection, appDefinitionName(entry), owner) + : null, + owner, + onPrimary: primaryConnection + ? () => navigate(summary && summary.count > 1 + ? `/apps/app/${summary.applicationId}/setup` + : `/apps/${primaryConnection.id}/setup`) : connectHref ? () => navigate(connectHref) : undefined, + onAddAnother: addAnotherHref ? () => navigate(addAnotherHref) : undefined, }; }; @@ -216,56 +265,110 @@ export function Browse() { function AppTile({ entry, - onOpen, + onPrimary, + onAddAnother, connectedCount, + connectionName, + owner, compact = false, }: { entry: AppGalleryDisplayEntry; - onOpen?: () => void; + onPrimary?: () => void; + onAddAnother?: () => void; connectedCount: number; + connectionName: string | null; + owner: ConnectionOwnerProfile | null; compact?: boolean; }) { - const disabled = !onOpen; - const actionLabel = connectedCount > 0 - ? `${connectedCount} connected already` - : disabled - ? "Coming soon" - : "Connect →"; + const disabled = !onPrimary; + const connected = connectedCount > 0; + const appName = appDefinitionName(entry); + const actionLabel = connected + ? connectedCount > 1 ? "Edit connections" : "Edit connection" + : disabled ? "Coming soon" : "Connect"; + const connectedActionClass = connected + ? "border-emerald-500/50 text-emerald-700 hover:bg-emerald-500/10 dark:text-emerald-300" + : undefined; if (compact) { return ( - + + {connected && onAddAnother && ( + + )} +
); } return ( - +
+ {connected && connectionName && ( + {connectionName} + )} + + {connected && onAddAnother && ( + + )} +
+
); } diff --git a/ui/src/pages/apps/Connections.test.tsx b/ui/src/pages/apps/Connections.test.tsx index 80e333c3bad..1c73d0fa63c 100644 --- a/ui/src/pages/apps/Connections.test.tsx +++ b/ui/src/pages/apps/Connections.test.tsx @@ -11,6 +11,7 @@ const listApplicationsMock = vi.hoisted(() => vi.fn()); const listConnectionsMock = vi.hoisted(() => vi.fn()); const listAppsAttentionMock = vi.hoisted(() => vi.fn()); const listProfilesMock = vi.hoisted(() => vi.fn()); +const listUserDirectoryMock = vi.hoisted(() => vi.fn()); const archiveConnectionMock = vi.hoisted(() => vi.fn()); const pushToastMock = vi.hoisted(() => vi.fn()); const mockNavigate = vi.hoisted(() => vi.fn()); @@ -26,6 +27,12 @@ vi.mock("@/api/tools", () => ({ }, })); +vi.mock("@/api/access", () => ({ + accessApi: { + listUserDirectory: (companyId: string) => listUserDirectoryMock(companyId), + }, +})); + vi.mock("@/lib/router", () => ({ useNavigate: () => mockNavigate, Link: ({ children, to }: { children: React.ReactNode; to: string }) => ( @@ -154,6 +161,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { listApplicationsMock.mockResolvedValue({ applications: [] }); listConnectionsMock.mockResolvedValue({ connections: [] }); listProfilesMock.mockResolvedValue({ profiles: [] }); + listUserDirectoryMock.mockResolvedValue({ users: [] }); archiveConnectionMock.mockResolvedValue(connection({ id: "c-deleted", status: "archived" })); container = document.createElement("div"); document.body.appendChild(container); @@ -206,7 +214,7 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup"); }); - it("rolls up multi-connection status, attention count, actions, and navigation by application", async () => { + it("renders every account with its owner, status, actions, and direct edit navigation", async () => { listApplicationsMock.mockResolvedValue({ applications: [ application({ id: "app-github", name: "GitHub" }), @@ -216,7 +224,13 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { }); listConnectionsMock.mockResolvedValue({ connections: [ - connection({ id: "c-connected", applicationId: "app-github", name: "GitHub", healthStatus: "healthy" }), + connection({ + id: "c-connected", + applicationId: "app-github", + name: "GitHub", + healthStatus: "healthy", + createdByUserId: "user-1", + }), connection({ id: "c-attention", applicationId: "app-slack", @@ -253,6 +267,18 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { profile("c-attention-2", ["b", "c"]), ], }); + listUserDirectoryMock.mockResolvedValue({ + users: [{ + principalId: "user-1", + status: "active", + user: { + id: "user-1", + name: "Dotta", + email: "dotta@example.com", + image: "https://example.com/dotta.png", + }, + }], + }); await renderApps(); @@ -262,41 +288,46 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { // 2. Attention + Paused rows keep their explanatory hint. expect(text).toContain("The key stopped working"); expect(text).toContain("Paused — agents can"); - // 3. Filter chips and attention banner are application-counted, not connection-counted. - expect(text).toContain("All (3)"); + // 3. Every account has its own filterable row and health signal. + expect(text).toContain("All (4)"); expect(text).toContain("Needs attention (1)"); - expect(text).toContain("1 app needs attention"); + expect(text).toContain("1 connection needs attention"); // 3. New header columns are present. const headers = Array.from(container.querySelectorAll("th")).map((th) => th.textContent?.trim()); - expect(headers).toEqual(["App", "Status", "Actions", "Last used", ""]); - // 4. Actions column reflects enabled catalog entries rolled up by application; missing profile => 0 on. + expect(headers).toEqual(["Connection", "Connected by", "Status", "Actions", "Last used", ""]); + // 4. Actions column reflects enabled catalog entries per account; missing profile => 0 on. expect(text).toContain("3 on"); expect(text).toContain("0 on"); // 5. Last used renders a relative timestamp when present, dash when absent. expect(text).toContain("—"); - // 6. Multi-connection app appears once and opens its provider landing page. - expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(1); + // 6. Multi-account apps appear once per connection and edit the selected account directly. + expect(Array.from(container.querySelectorAll("tbody tr")).filter((tr) => tr.textContent?.includes("Slack"))).toHaveLength(2); const slackRow = Array.from(container.querySelectorAll("tbody tr")).find((tr) => tr.textContent?.includes("Slack"), ); slackRow?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-slack/setup"); - // 7. Button labels are honest: broken health says Reconnect, healthy/paused say Open. - const rowButtonLabel = (name: string) => + expect(mockNavigate).toHaveBeenCalledWith("/apps/c-attention/setup"); + // 7. Button labels are honest: broken health says Reconnect, healthy/paused say Edit. + const rowButtonLabel = (name: string, exact = false) => Array.from(container.querySelectorAll("tbody tr")) - .find((tr) => tr.textContent?.includes(name)) + .find((tr) => exact ? tr.textContent?.includes(name) && !tr.textContent?.includes("Slack Team") : tr.textContent?.includes(name)) ?.querySelector("td:last-child button")?.textContent; - expect(rowButtonLabel("GitHub")).toBe("Open"); - expect(rowButtonLabel("Slack")).toBe("Reconnect"); - expect(rowButtonLabel("Notion")).toBe("Open"); + expect(rowButtonLabel("GitHub")).toBe("Edit"); + expect(rowButtonLabel("Slack", true)).toBe("Reconnect"); + expect(rowButtonLabel("Notion")).toBe("Edit"); + // 8. Generic connection names inherit the originating user's first name. + expect(text).toContain("Dotta’s GitHub"); + expect(container.querySelector('[title="Dotta"] [data-slot="avatar"]')).toBeTruthy(); + // Custom account labels remain untouched. + expect(text).toContain("Slack Team"); }); // F6 (PAP-13254 §4): the row highlight and the Status pill derive from ONE // health signal, so they can never disagree. A healthy connection stays - // "Healthy" / "Open" and is not amber-highlighted, even if the broader + // "Healthy" / "Edit" and is not amber-highlighted, even if the broader // attention endpoint would once have flagged it (quarantine/new-tools review // now live on the app detail + Review door, not the Connections highlight). - it("keeps a healthy app un-highlighted and Open — pill and highlight agree (F6)", async () => { + it("keeps a healthy app un-highlighted and editable — pill and highlight agree (F6)", async () => { listApplicationsMock.mockResolvedValue({ applications: [application({ id: "app-github", name: "GitHub" })], }); @@ -326,9 +357,9 @@ describe("Connections table (M1b / PAP-13254 door 2)", () => { ); expect(row?.className).not.toContain("amber"); const button = row?.querySelector("td:last-child button"); - expect(button?.textContent).toBe("Open"); + expect(button?.textContent).toBe("Edit"); button?.dispatchEvent(new MouseEvent("click", { bubbles: true })); - expect(mockNavigate).toHaveBeenCalledWith("/apps/app/app-github/setup"); + expect(mockNavigate).toHaveBeenCalledWith("/apps/c-healthy/setup"); }); it("deletes a connection only after trash-can confirmation", async () => { diff --git a/ui/src/pages/apps/Connections.tsx b/ui/src/pages/apps/Connections.tsx index 2f69fadce3b..bf8d73ab684 100644 --- a/ui/src/pages/apps/Connections.tsx +++ b/ui/src/pages/apps/Connections.tsx @@ -16,6 +16,8 @@ import { useBreadcrumbs } from "@/context/BreadcrumbContext"; import { useToast } from "@/context/ToastContext"; import { queryKeys } from "@/lib/queryKeys"; import { toolsApi } from "@/api/tools"; +import { accessApi } from "@/api/access"; +import { buildCompanyUserProfileMap } from "@/lib/company-members"; import { AlertDialog, AlertDialogAction, @@ -32,6 +34,7 @@ import { cn } from "@/lib/utils"; import { timeAgo } from "@/lib/timeAgo"; import { AppLogo } from "./AppLogo"; import { + appApplicationSourceSlug, appDefinitionLogoUrl, appDefinitionName, appDefinitionSlug, @@ -39,6 +42,12 @@ import { } from "./app-definition-display"; import { useReviewCount } from "./useReviewCount"; import { AdvancedToolsLink } from "./store-cards"; +import { + ConnectionOwnerIdentity, + connectionDisplayNameForOwner, + connectionOwnerProfile, + type ConnectionOwnerProfile, +} from "./connection-owner"; const BROWSE_HREF = "/apps"; @@ -51,9 +60,10 @@ type AppStatus = { type AppRow = { application: ToolApplication; - primaryConnection: ToolConnection | null; - connectionCount: number; - agentAvailableConnectionCount: number; + connection: ToolConnection | null; + displayName: string; + owner: ConnectionOwnerProfile | null; + remainingAgentAvailableConnectionCount: number; status: AppStatus; actionCount: number; lastUsedAt: Date | string | null; @@ -137,6 +147,11 @@ export function Connections() { queryFn: () => toolsApi.listProfiles(selectedCompanyId!), enabled: !!selectedCompanyId, }); + const userDirectoryQuery = useQuery({ + queryKey: queryKeys.access.companyUserDirectory(selectedCompanyId ?? "__none__"), + queryFn: () => accessApi.listUserDirectory(selectedCompanyId!), + enabled: !!selectedCompanyId, + }); const deleteConnection = useMutation({ mutationFn: (target: { id: string; appName: string; remainingConnectionCount: number }) => @@ -197,40 +212,53 @@ export function Connections() { } return map; }, [connections]); + const userProfileById = useMemo( + () => buildCompanyUserProfileMap(userDirectoryQuery.data?.users), + [userDirectoryQuery.data], + ); const rows = useMemo(() => { - return applications.map((application) => { + return applications.flatMap((application): AppRow[] => { const appConnections = connectionsByApplication.get(application.id) ?? []; - const primaryConnection = appConnections[0] ?? null; - const actionCount = appConnections.reduce( - (sum, connection) => sum + (actionCountByConnection.get(`app:${connection.id}`) ?? 0), - 0, - ); - const lastUsedAt = appConnections.reduce((latest, connection) => { - if (!connection.lastUsedAt) return latest; - if (!latest) return connection.lastUsedAt; - return new Date(connection.lastUsedAt).getTime() > new Date(latest).getTime() - ? connection.lastUsedAt - : latest; - }, null); - const galleryEntry = application.applicationKey - ? logoByKey.get(application.applicationKey) - : undefined; - return { - application, - primaryConnection, - connectionCount: appConnections.length, - agentAvailableConnectionCount: appConnections.filter( - (connection) => connection.status === "active" && connection.enabled, - ).length, - status: statusFor(application, appConnections), - actionCount, - lastUsedAt, - logoUrl: appDefinitionLogoUrl(galleryEntry) ?? - appDefinitionLogoUrl(logoByName.get(application.name.toLowerCase())), - }; + const galleryEntry = logoByKey.get(appApplicationSourceSlug(application) ?? ""); + const logoUrl = appDefinitionLogoUrl(galleryEntry) ?? + appDefinitionLogoUrl(logoByName.get(application.name.toLowerCase())); + const agentAvailableConnectionCount = appConnections.filter( + (connection) => connection.status === "active" && connection.enabled, + ).length; + if (appConnections.length === 0) { + return [{ + application, + connection: null, + displayName: application.name, + owner: null, + remainingAgentAvailableConnectionCount: 0, + status: statusFor(application, []), + actionCount: 0, + lastUsedAt: null, + logoUrl, + }]; + } + return appConnections.map((connection) => { + const owner = connectionOwnerProfile(connection, userProfileById); + return { + application, + connection, + displayName: connectionDisplayNameForOwner(connection, application.name, owner), + owner, + remainingAgentAvailableConnectionCount: Math.max( + 0, + agentAvailableConnectionCount - + (connection.status === "active" && connection.enabled ? 1 : 0), + ), + status: statusFor(application, [connection]), + actionCount: actionCountByConnection.get(`app:${connection.id}`) ?? 0, + lastUsedAt: connection.lastUsedAt ?? null, + logoUrl, + }; + }); }); - }, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]); + }, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName, userProfileById]); const rowsNeedingAttention = rows.filter(rowNeedsAttention); const visibleRows = filter === "attention" ? rowsNeedingAttention : rows; @@ -304,7 +332,7 @@ export function Connections() {
- {rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention + {rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "connection needs" : "connections need"} attention
{floatSummary(rowsNeedingAttention)} @@ -318,7 +346,8 @@ export function Connections() { - + + @@ -327,29 +356,31 @@ export function Connections() { {visibleRows.map((row) => { - const { application, primaryConnection, status } = row; + const { application, connection, status } = row; const attention = rowNeedsAttention(row); const hint = status.tone === "attention" - ? primaryConnection?.authKind === "oauth" + ? connection?.authKind === "oauth" ? "Reconnect required — sign in again to restore access." : "The key stopped working — reconnect to fix." : status.tone === "paused" ? "Paused — agents can’t use it right now." : status.tone === "not_connected" ? "Connect it so agents can use it." - : row.connectionCount > 1 - ? `${row.connectionCount} connections` + : row.displayName !== application.name + ? application.name : null; - const appHref = `/apps/app/${application.id}/setup`; - const actionLabel = !primaryConnection + const appHref = connection + ? `/apps/${connection.id}/setup` + : `/apps/app/${application.id}/setup`; + const actionLabel = !connection ? "Connect" : status.tone === "attention" ? "Reconnect" - : "Open"; + : "Edit"; return ( navigate(appHref)} className={cn( "cursor-pointer border-b border-border transition-colors last:border-0 hover:bg-muted/30", @@ -359,13 +390,13 @@ export function Connections() { +
AppConnectionConnected by Status Actions Last used
- {application.name} + {row.displayName}
{hint && (
{hint}
@@ -373,6 +404,9 @@ export function Connections() {
+ + {actionLabel} - {primaryConnection && ( + {connection && (