mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(apps): make connections account-aware
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
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);
|
||||
});
|
||||
});
|
||||
@@ -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.",
|
||||
);
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,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"];
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user