feat(apps): make connections account-aware

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-20 21:20:02 +00:00
1 parent ca94577206
commit 7d758d2963
11 files changed
+603 -230

No files matched your search

@@ -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);
});
});
+51 -5
View File
@@ -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.",
);
+56 -32
View File
@@ -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" && (
<SetupPanel
connection={connection}
galleryEntry={logoEntry}
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
onToggleApp={() => toggleEnabled.mutate()}
configUpdateDisabled={updateConfig.isPending}
onUpdateConfig={(config) => updateConfig.mutate(config)}
oauthStartDisabled={startOAuth.isPending}
onStartOAuth={() => startOAuth.mutate()}
/>
<div className="space-y-8">
<SetupPanel
connection={connection}
galleryEntry={logoEntry}
appToggleDisabled={toggleEnabled.isPending || removeApp.isPending}
onToggleApp={() => toggleEnabled.mutate()}
configUpdateDisabled={updateConfig.isPending}
onUpdateConfig={(config) => updateConfig.mutate(config)}
oauthStartDisabled={startOAuth.isPending}
onStartOAuth={() => startOAuth.mutate()}
/>
<AdvancedPanel
connection={connection}
appName={appName}
galleryEntry={logoEntry}
removing={removeApp.isPending}
onRemove={() => removeApp.mutate()}
onReplaced={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
</div>
)}
{activeTab === "review" && (
reviewFailed
@@ -506,20 +536,6 @@ export function AppDetail() {
userLabelById={userLabelById}
/>
)}
{activeTab === "advanced" && (
<AdvancedPanel
connection={connection}
appName={appName}
galleryEntry={logoEntry}
removing={removeApp.isPending}
onRemove={() => removeApp.mutate()}
onReplaced={() => {
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connection(connectionId) });
queryClient.invalidateQueries({ queryKey: queryKeys.tools.connections(selectedCompanyId) });
queryClient.invalidateQueries({ queryKey: queryKeys.apps.attention(selectedCompanyId) });
}}
/>
)}
</div>
);
}
@@ -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) && (
<p className="text-xs text-muted-foreground">{connectionDisplaySecondaryHint(connection)}</p>
)}
{owner && (
<div className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Connected by</span>
<ConnectionOwnerIdentity owner={owner} />
</div>
)}
{unverifiedHost ? <UnverifiedServerBadge host={unverifiedHost} className="mt-1" /> : null}
<div className="mt-1 flex items-center gap-2">
<StatusBadge status={status} />
+41 -2
View File
@@ -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");
});
});
+58 -56
View File
@@ -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" && (
<SetupTab
applicationName={application.name}
activeConnections={activeConnections}
previousConnection={previousConnection}
previousAddress={previousAddress}
onConnect={() => navigate(connectHref)}
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
/>
<div className="space-y-8">
<SetupTab
applicationName={application.name}
activeConnections={activeConnections}
previousConnection={previousConnection}
previousAddress={previousAddress}
userProfileById={userProfileById}
onConnect={() => navigate(connectHref)}
onEdit={(connectionId) => navigate(appTabHref(connectionId, "setup"))}
/>
<DangerZone
appName={application.name}
removing={remove.isPending}
onRemove={() => remove.mutate()}
/>
</div>
)}
{activeTab === "review" && (
previousConnection ? (
@@ -228,15 +251,6 @@ export function AppNotConnected() {
/>
)
)}
{activeTab === "advanced" && (
<AdvancedTab
appName={application.name}
previousConnection={previousConnection}
previousAddress={previousAddress}
removing={remove.isPending}
onRemove={() => remove.mutate()}
/>
)}
</div>
);
}
@@ -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<string, CompanyUserProfile>;
onConnect: () => void;
onEdit: (connectionId: string) => void;
}) {
@@ -292,11 +308,12 @@ function SetupTab({
<div>
<h2 className="text-sm font-bold text-foreground">Already connected to {applicationName}</h2>
<p className="mt-0.5 text-sm text-muted-foreground">
Open a connection to edit it, or add another account.
Edit an existing connection, or deliberately add another account below.
</p>
</div>
<div className="overflow-hidden rounded-lg border border-border">
<div className="divide-y divide-border">
{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"
>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-medium text-foreground">
{humanizeConnectionDisplayName(connection)}
{connectionDisplayNameForOwner(connection, applicationName, owner)}
</div>
<div className="truncate text-xs text-muted-foreground">{secondary}</div>
</div>
<ConnectionOwnerIdentity owner={owner} />
<span className="text-xs text-muted-foreground">{status}</span>
<span className="text-xs font-semibold text-primary">Edit →</span>
</button>
@@ -325,7 +343,7 @@ function SetupTab({
</div>
</section>
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-foreground">Connect another</h2>
@@ -342,7 +360,7 @@ function SetupTab({
return (
<div className="space-y-6">
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 className="text-sm font-bold text-foreground">
@@ -361,7 +379,11 @@ function SetupTab({
</section>
{previousConnection && (
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
<PreviousSetup
connection={previousConnection}
previousAddress={previousAddress}
owner={connectionOwnerProfile(previousConnection, userProfileById)}
/>
)}
</div>
);
@@ -370,13 +392,21 @@ function SetupTab({
function PreviousSetup({
connection,
previousAddress,
owner,
}: {
connection: ToolConnection;
previousAddress: string | null;
owner: CompanyUserProfile | null;
}) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<h2 className="text-sm font-bold text-foreground">Previous setup</h2>
{owner && (
<div className="mt-2 flex items-center gap-1.5 text-xs text-muted-foreground">
<span>Connected by</span>
<ConnectionOwnerIdentity owner={owner} />
</div>
)}
{connection.healthMessage && (
<p className="mt-2 rounded-lg border border-amber-500/40 bg-amber-500/10 px-3 py-2 text-xs text-amber-700 dark:text-amber-300">
Last error: {connection.healthMessage}
@@ -398,7 +428,7 @@ function PreviousSetup({
function PermissionsTab({ previousConnection }: { previousConnection: ToolConnection | null }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<h2 className="text-sm font-bold text-foreground">Permissions paused</h2>
<p className="mt-1 text-sm text-muted-foreground">
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 (
<div className="space-y-6">
{previousConnection ? (
<PreviousSetup connection={previousConnection} previousAddress={previousAddress} />
) : (
<EmptyTab
title="No previous connection details"
body="Technical details will appear here after this app is connected."
/>
)}
<DangerZone appName={appName} removing={removing} onRemove={onRemove} />
</div>
);
}
function EmptyTab({ title, body }: { title: string; body: string }) {
return (
<section className="rounded-xl border border-border bg-card px-5 py-4">
<section>
<h2 className="text-sm font-bold text-foreground">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{body}</p>
</section>
+57 -15
View File
@@ -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 }) => <a href={to}>{children}</a>,
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<HTMLButtonElement>('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<HTMLButtonElement>('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<HTMLButtonElement>('button[aria-label="Connect for Notion"]'),
);
const tile = Array.from(container.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Acme CRM"),
const tile = container.querySelector<HTMLButtonElement>(
'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<HTMLButtonElement>(
'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<HTMLButtonElement>(
'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 () => {
+141 -38
View File
@@ -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<string, number>();
const connectionsByApplicationId = new Map<string, typeof connections>();
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<string, { applicationId: string; count: number }>();
const summaries = new Map<string, {
applicationId: string;
count: number;
primaryConnection: (typeof connections)[number] | null;
}>();
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 <div className="p-6 text-sm text-muted-foreground">Select a company to browse apps.</div>;
@@ -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 (
<button
type="button"
disabled={disabled}
onClick={onOpen}
<div
className={disabled
? "flex cursor-not-allowed flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center opacity-60"
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center transition-colors hover:border-foreground/30 hover:bg-accent/40"}
: "flex flex-col items-center gap-2 rounded-xl border border-border bg-background px-3 py-4 text-center"}
>
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<span className="text-xs font-medium text-foreground">{appDefinitionName(entry)}</span>
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<span className="text-xs font-medium text-foreground">{appName}</span>
{connected && (
<span className="inline-flex items-center gap-1 text-xs font-medium text-emerald-700 dark:text-emerald-300">
<Check className="h-3 w-3" /> {connectedCount} connected
</span>
)}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={connectedActionClass}
aria-label={`${actionLabel} for ${appName}`}
>
{actionLabel}
</span>
</button>
</Button>
{connected && onAddAnother && (
<button
type="button"
onClick={onAddAnother}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Add another ${appName} account`}
>
Add another account
</button>
)}
</div>
);
}
return (
<button
type="button"
disabled={disabled}
onClick={onOpen}
<div
className={disabled
? "flex h-full cursor-not-allowed items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left opacity-60"
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left transition-colors hover:border-foreground/30 hover:bg-accent/40"}
: "flex h-full items-start gap-3 rounded-xl border border-border bg-card px-4 py-4 text-left"}
>
<AppLogo name={appDefinitionName(entry)} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<AppLogo name={appName} logoUrl={appDefinitionLogoUrl(entry)} size={36} />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-foreground">{appDefinitionName(entry)}</div>
<div className="text-sm font-semibold text-foreground">{appName}</div>
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{appDefinitionDescription(entry)}</div>
{connected && owner && (
<div className="mt-2">
<ConnectionOwnerIdentity owner={owner} />
</div>
)}
</div>
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
{actionLabel}
</span>
</button>
<div className="flex shrink-0 flex-col items-end gap-1.5">
{connected && connectionName && (
<span className="max-w-40 truncate text-xs text-muted-foreground">{connectionName}</span>
)}
<Button
type="button"
size="sm"
variant="outline"
disabled={disabled}
onClick={onPrimary}
className={connectedActionClass}
aria-label={`${actionLabel} for ${appName}`}
>
{actionLabel}
</Button>
{connected && onAddAnother && (
<button
type="button"
onClick={onAddAnother}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
aria-label={`Add another ${appName} account`}
>
Add another account
</button>
)}
</div>
</div>
);
}
+51 -20
View File
@@ -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 () => {
+83 -53
View File
@@ -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<AppRow[]>(() => {
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<Date | string | null>((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() {
<ShieldAlert className="h-5 w-5 shrink-0 text-red-600 dark:text-red-400" />
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-red-900 dark:text-red-100">
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "app needs" : "apps need"} attention
{rowsNeedingAttention.length} {rowsNeedingAttention.length === 1 ? "connection needs" : "connections need"} attention
</div>
<div className="truncate text-xs text-red-700 dark:text-red-300">
{floatSummary(rowsNeedingAttention)}
@@ -318,7 +346,8 @@ export function Connections() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/40 text-left text-(length:--text-micro) font-semibold uppercase tracking-wide text-muted-foreground">
<th className="px-4 py-2.5">App</th>
<th className="px-4 py-2.5">Connection</th>
<th className="px-4 py-2.5">Connected by</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">Actions</th>
<th className="px-4 py-2.5">Last used</th>
@@ -327,29 +356,31 @@ export function Connections() {
</thead>
<tbody>
{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 (
<tr
key={application.id}
key={connection?.id ?? application.id}
onClick={() => 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() {
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<AppLogo
name={application.name}
name={row.displayName}
logoUrl={row.logoUrl}
size={32}
/>
<div className="min-w-0">
<div className="font-medium text-foreground">
{application.name}
{row.displayName}
</div>
{hint && (
<div className="truncate text-xs text-muted-foreground">{hint}</div>
@@ -373,6 +404,9 @@ export function Connections() {
</div>
</div>
</td>
<td className="px-4 py-3">
<ConnectionOwnerIdentity owner={row.owner} />
</td>
<td className="px-4 py-3">
<span
className={cn(
@@ -403,22 +437,18 @@ export function Connections() {
>
{actionLabel}
</Button>
{primaryConnection && (
{connection && (
<Button
variant="ghost"
size="icon-sm"
className="text-muted-foreground hover:text-destructive"
aria-label={`Delete ${application.name} connection`}
aria-label={`Delete ${row.displayName} connection`}
onClick={(event) => {
event.stopPropagation();
setConnectionToDelete({
id: primaryConnection.id,
id: connection.id,
appName: application.name,
remainingConnectionCount: Math.max(
0,
row.agentAvailableConnectionCount -
(primaryConnection.status === "active" && primaryConnection.enabled ? 1 : 0),
),
remainingConnectionCount: row.remainingAgentAvailableConnectionCount,
});
}}
>
+1 -2
View File
@@ -1,4 +1,4 @@
import { Activity, Beaker, Inbox, Settings2, ShieldCheck, Wrench } from "lucide-react";
import { Activity, Beaker, Inbox, Settings2, ShieldCheck } from "lucide-react";
export const APP_TABS = [
{ key: "setup", label: "Setup", icon: Settings2 },
@@ -6,7 +6,6 @@ export const APP_TABS = [
{ key: "review", label: "Review", icon: Inbox },
{ key: "permissions", label: "Permissions", icon: ShieldCheck },
{ key: "activity", label: "Activity", icon: Activity },
{ key: "advanced", label: "Advanced", icon: Wrench },
] as const;
export type AppTabKey = (typeof APP_TABS)[number]["key"];
+57
View File
@@ -0,0 +1,57 @@
import type { ToolConnection } from "@paperclipai/shared";
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
import { Identity } from "@/components/Identity";
import type { CompanyUserProfile } from "@/lib/company-members";
export type ConnectionOwnerProfile = CompanyUserProfile;
export function connectionOwnerProfile(
connection: Pick<ToolConnection, "createdByUserId">,
profiles: ReadonlyMap<string, CompanyUserProfile>,
): ConnectionOwnerProfile | null {
if (!connection.createdByUserId) return null;
return profiles.get(connection.createdByUserId) ?? {
label: connection.createdByUserId === "local-board" ? "Board" : "Board member",
image: null,
};
}
function ownerGivenName(label: string): string {
const trimmed = label.trim();
if (!trimmed) return "Board";
const first = trimmed.split(/\s+/)[0] ?? trimmed;
return first.includes("@") ? first.split("@")[0] || "Board" : first;
}
function possessive(label: string): string {
return label.toLowerCase().endsWith("s") ? `${label}’` : `${label}’s`;
}
/**
* Keep intentionally customized account names, while making the default app
* name useful in a multi-user company ("Dotta’s Notion", "Sam’s Gmail").
*/
export function connectionDisplayNameForOwner(
connection: Pick<ToolConnection, "name">,
applicationName: string,
owner: ConnectionOwnerProfile | null,
): string {
const connectionName = humanizeConnectionDisplayName(connection);
if (!owner) return connectionName;
if (connectionName.trim().toLocaleLowerCase() !== applicationName.trim().toLocaleLowerCase()) {
return connectionName;
}
return `${possessive(ownerGivenName(owner.label))} ${applicationName}`;
}
export function ConnectionOwnerIdentity({ owner }: { owner: ConnectionOwnerProfile | null }) {
if (!owner) return <span className="text-xs text-muted-foreground">Unknown</span>;
return (
<Identity
name={owner.label}
avatarUrl={owner.image}
size="sm"
className="text-muted-foreground"
/>
);
}