mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
feat(connections): cut gallery over to AppDefinition
This commit is contained in:
1 parent
9f9eb444e1
commit
c2dc729769
23 files changed
+295
-474
No files matched your search
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getAppDefinitionForUrl,
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
} from "./app-definitions.js";
|
||||
|
||||
describe("tool app gallery URL matching", () => {
|
||||
it("matches pasted links against gallery URL patterns", () => {
|
||||
expect(getAppDefinitionForUrl("https://mcp.zapier.com/api/mcp")?.slug).toBe("zapier");
|
||||
expect(getAppDefinitionForUrl("https://api.githubcopilot.com/mcp/")?.slug).toBe("github");
|
||||
expect(getAppDefinitionForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.slug).toBe("google-sheets");
|
||||
});
|
||||
|
||||
it("returns null for invalid or unknown links", () => {
|
||||
expect(getAppDefinitionForUrl("not a url")).toBeNull();
|
||||
expect(getAppDefinitionForUrl("https://example.com/mcp")).toBeNull();
|
||||
expect(getAppDefinitionForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not list Google Drive until its OAuth client flow is supported", () => {
|
||||
expect(CONNECTABLE_APP_DEFINITIONS.map((app) => app.slug)).not.toContain("google-drive");
|
||||
expect(getAppDefinitionForUrl("https://mcp.google.com/drive")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every gallery entry reachable through at least one pattern", () => {
|
||||
for (const app of CONNECTABLE_APP_DEFINITIONS) {
|
||||
const example = app.urlPatterns[0]?.replace("*", "example");
|
||||
expect(example, `${app.slug} has a pattern`).toBeTruthy();
|
||||
expect(getAppDefinitionForUrl(example!)?.slug).toBe(app.slug);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,67 @@
|
||||
import { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
||||
import type { AppDefinition, ConnectionMethodDef, FieldDef } from "./types/app-definition.js";
|
||||
import type { ToolConnectionOwnership } from "./types/tool-access.js";
|
||||
|
||||
const CONNECTABLE_APP_SLUGS = new Set([
|
||||
"zapier",
|
||||
"github",
|
||||
"slack",
|
||||
"notion",
|
||||
"linear",
|
||||
"google-sheets",
|
||||
"context7",
|
||||
]);
|
||||
|
||||
export const CONNECTABLE_APP_DEFINITIONS = APP_DEFINITIONS.filter((app) =>
|
||||
CONNECTABLE_APP_SLUGS.has(app.slug)
|
||||
);
|
||||
|
||||
export const DEFAULT_OWNERSHIP_AVAILABILITY: Record<ToolConnectionOwnership, boolean> = {
|
||||
platform_shared: false,
|
||||
platform_provisioned: false,
|
||||
customer: true,
|
||||
dcr: true,
|
||||
};
|
||||
|
||||
export function getConnectableAppDefinition(slug: string): AppDefinition | null {
|
||||
return CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === slug) ?? null;
|
||||
}
|
||||
|
||||
function wildcardPatternToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
export function getAppDefinitionForUrl(
|
||||
link: string,
|
||||
definitions: readonly AppDefinition[] = CONNECTABLE_APP_DEFINITIONS,
|
||||
): AppDefinition | null {
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = new URL(link.trim()).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return definitions.find((app) =>
|
||||
app.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function getAvailableConnectionMethod(app: AppDefinition): ConnectionMethodDef | null {
|
||||
const availability = app.ownershipAvailability ?? DEFAULT_OWNERSHIP_AVAILABILITY;
|
||||
return app.methods.find((method) =>
|
||||
method.ownershipModes.some((ownership) => availability[ownership] !== false)
|
||||
) ?? null;
|
||||
}
|
||||
|
||||
export function credentialConfigPath(field: FieldDef): string {
|
||||
return `credentials.${field.key}`;
|
||||
}
|
||||
|
||||
export function recommendedDefaultsForApp(app: AppDefinition): Record<string, unknown> {
|
||||
const method = getAvailableConnectionMethod(app);
|
||||
return {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: method?.riskTier === "S1" ? [] : ["write", "destructive"],
|
||||
};
|
||||
}
|
||||
@@ -30,7 +30,7 @@
|
||||
"credentialFields": [
|
||||
{
|
||||
"key": "authorization",
|
||||
"label": "Zapier token",
|
||||
"label": "Zapier MCP token",
|
||||
"type": "password",
|
||||
"required": true,
|
||||
"placeholder": "Paste your Zapier token",
|
||||
|
||||
@@ -143,15 +143,14 @@ export {
|
||||
type SourceTrustMetadata,
|
||||
} from "./trust-policy.js";
|
||||
export {
|
||||
TOOL_APP_GALLERY,
|
||||
getToolAppGalleryEntry,
|
||||
getToolAppGalleryEntryForUrl,
|
||||
type AppGalleryAuthKind,
|
||||
type AppGalleryCredentialField,
|
||||
type AppGalleryEntry,
|
||||
type AppGalleryKey,
|
||||
type AppGalleryTransportTemplate,
|
||||
} from "./tool-app-gallery.js";
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
DEFAULT_OWNERSHIP_AVAILABILITY,
|
||||
credentialConfigPath,
|
||||
getAppDefinitionForUrl,
|
||||
getAvailableConnectionMethod,
|
||||
getConnectableAppDefinition,
|
||||
recommendedDefaultsForApp,
|
||||
} from "./app-definitions.js";
|
||||
export { APP_DEFINITIONS } from "./app-definitions.generated.js";
|
||||
export { appDefinitionSchema, appDefinitionsSchema, connectionMethodDefSchema } from "./validators/app-definition.js";
|
||||
export {
|
||||
@@ -1259,6 +1258,9 @@ export type {
|
||||
PluginJobRecord,
|
||||
PluginJobRunRecord,
|
||||
PluginWebhookDeliveryRecord,
|
||||
AppDefinition,
|
||||
ConnectionMethodDef,
|
||||
FieldDef,
|
||||
QuotaWindow,
|
||||
ProviderQuotaResult,
|
||||
} from "./types/index.js";
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
getToolAppGalleryEntryForUrl,
|
||||
TOOL_APP_GALLERY,
|
||||
} from "./tool-app-gallery.js";
|
||||
|
||||
describe("tool app gallery URL matching", () => {
|
||||
it("matches pasted links against gallery URL patterns", () => {
|
||||
expect(getToolAppGalleryEntryForUrl("https://mcp.zapier.com/api/mcp")?.key).toBe("zapier");
|
||||
expect(getToolAppGalleryEntryForUrl("https://api.githubcopilot.com/mcp/")?.key).toBe("github");
|
||||
expect(getToolAppGalleryEntryForUrl("https://docs.google.com/spreadsheets/d/sheet_123/edit")?.key).toBe("google-sheets");
|
||||
});
|
||||
|
||||
it("returns null for invalid or unknown links", () => {
|
||||
expect(getToolAppGalleryEntryForUrl("not a url")).toBeNull();
|
||||
expect(getToolAppGalleryEntryForUrl("https://example.com/mcp")).toBeNull();
|
||||
expect(getToolAppGalleryEntryForUrl("https://docs.googleapis.com/drive/v3/files")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not list Google Drive until its OAuth client flow is supported", () => {
|
||||
expect(TOOL_APP_GALLERY.map((entry) => entry.key)).not.toContain("google-drive");
|
||||
expect(getToolAppGalleryEntryForUrl("https://mcp.google.com/drive")).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps every gallery entry reachable through at least one pattern", () => {
|
||||
for (const entry of TOOL_APP_GALLERY) {
|
||||
const example = entry.urlPatterns[0]?.replace("*", "example");
|
||||
expect(example, `${entry.key} has a pattern`).toBeTruthy();
|
||||
expect(getToolAppGalleryEntryForUrl(example!)?.key).toBe(entry.key);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,243 +0,0 @@
|
||||
import type { ToolConnectionTransport } from "./types/tool-access.js";
|
||||
|
||||
export type AppGalleryAuthKind = "oauth" | "api_key" | "none";
|
||||
|
||||
export interface AppGalleryCredentialField {
|
||||
label: string;
|
||||
configPath: string;
|
||||
helpUrl: string;
|
||||
required?: boolean;
|
||||
placement?: "header" | "env";
|
||||
key?: string;
|
||||
prefix?: string | null;
|
||||
}
|
||||
|
||||
export type AppGalleryTransportTemplate =
|
||||
| {
|
||||
transport: Extract<ToolConnectionTransport, "mcp_remote">;
|
||||
url: string;
|
||||
}
|
||||
| {
|
||||
transport: Extract<ToolConnectionTransport, "local_stdio">;
|
||||
templateKey: string;
|
||||
};
|
||||
|
||||
export interface AppGalleryEntry {
|
||||
key: string;
|
||||
name: string;
|
||||
logoUrl: string;
|
||||
tagline: string;
|
||||
description?: string;
|
||||
authKind: AppGalleryAuthKind;
|
||||
transportTemplate: AppGalleryTransportTemplate;
|
||||
credentialFields: AppGalleryCredentialField[];
|
||||
recommendedDefaults: Record<string, unknown>;
|
||||
urlPatterns: string[];
|
||||
availability?: {
|
||||
available: boolean;
|
||||
reason?: string | null;
|
||||
robotEmail?: string | null;
|
||||
};
|
||||
oauth?: {
|
||||
provider: string;
|
||||
scopes: string[];
|
||||
tokenUrl?: string | null;
|
||||
metadataUrl?: string | null;
|
||||
authorizationUrl?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
const favicon = (domain: string) => `https://www.google.com/s2/favicons?domain=${domain}&sz=64`;
|
||||
|
||||
export const TOOL_APP_GALLERY = [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
logoUrl: favicon("zapier.com"),
|
||||
tagline: "Connect Zapier-hosted actions to your Paperclip agents.",
|
||||
description: "Let agents use Zapier automations across the apps your business already runs. Good for handoffs, lightweight operations, and cross-app updates that should stay visible in Paperclip.",
|
||||
authKind: "api_key",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://mcp.zapier.com/api/mcp",
|
||||
},
|
||||
credentialFields: [
|
||||
{
|
||||
label: "Zapier MCP token",
|
||||
configPath: "credentials.authorization",
|
||||
helpUrl: "https://zapier.com/app/settings/authorizations",
|
||||
required: true,
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.zapier.com/*"],
|
||||
},
|
||||
{
|
||||
key: "github",
|
||||
name: "GitHub",
|
||||
logoUrl: favicon("github.com"),
|
||||
tagline: "Read and manage GitHub issues and pull requests.",
|
||||
description: "Give agents a governed way to inspect repositories, issues, and pull requests. Useful when engineering work needs GitHub context or small updates without leaving Paperclip.",
|
||||
authKind: "api_key",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://api.githubcopilot.com/mcp/",
|
||||
},
|
||||
credentialFields: [
|
||||
{
|
||||
label: "GitHub token",
|
||||
configPath: "credentials.authorization",
|
||||
helpUrl: "https://github.com/settings/tokens",
|
||||
required: true,
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
prefix: "Bearer ",
|
||||
},
|
||||
],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://api.githubcopilot.com/mcp/*"],
|
||||
},
|
||||
{
|
||||
key: "slack",
|
||||
name: "Slack",
|
||||
logoUrl: favicon("slack.com"),
|
||||
tagline: "Search channels and coordinate Slack actions.",
|
||||
description: "Let agents search workspace conversations and coordinate in Slack when work needs team context. Message-sending actions can still ask a human first.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://mcp.slack.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.slack.com/*"],
|
||||
oauth: {
|
||||
provider: "slack",
|
||||
scopes: ["channels:read", "chat:write", "search:read"],
|
||||
authorizationUrl: "https://slack.com/oauth/v2/authorize",
|
||||
tokenUrl: "https://slack.com/api/oauth.v2.access",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "notion",
|
||||
name: "Notion",
|
||||
logoUrl: favicon("notion.so"),
|
||||
tagline: "Search and update Notion workspace content.",
|
||||
description: "Connect Notion so agents can find docs, read project notes, and update workspace pages. Use it for company memory that lives outside Paperclip.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://mcp.notion.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.notion.com/*"],
|
||||
oauth: {
|
||||
provider: "notion",
|
||||
scopes: ["read_content", "update_content"],
|
||||
authorizationUrl: "https://api.notion.com/v1/oauth/authorize",
|
||||
tokenUrl: "https://api.notion.com/v1/oauth/token",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "linear",
|
||||
name: "Linear",
|
||||
logoUrl: favicon("linear.app"),
|
||||
tagline: "Read and update Linear issues from agent workflows.",
|
||||
description: "Let agents look up Linear work and make issue updates when their Paperclip tasks depend on your existing product queue.",
|
||||
authKind: "oauth",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://mcp.linear.app/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://mcp.linear.app/*"],
|
||||
oauth: {
|
||||
provider: "linear",
|
||||
scopes: ["read", "write"],
|
||||
authorizationUrl: "https://linear.app/oauth/authorize",
|
||||
tokenUrl: "https://api.linear.app/oauth/token",
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
logoUrl: favicon("sheets.google.com"),
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
description: "Let agents read and update only the spreadsheets you choose. Share each sheet with the robot email, then paste the sheet links here.",
|
||||
authKind: "none",
|
||||
transportTemplate: {
|
||||
transport: "local_stdio",
|
||||
templateKey: "paperclip.google-sheets",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*", "https://sheets.google.com/*"],
|
||||
},
|
||||
{
|
||||
key: "context7",
|
||||
name: "Context7",
|
||||
logoUrl: favicon("context7.com"),
|
||||
tagline: "Fetch up-to-date library documentation with Context7.",
|
||||
description: "Let agents pull current library documentation while they work. It is a low-risk reference app for coding and research tasks.",
|
||||
authKind: "none",
|
||||
transportTemplate: {
|
||||
transport: "mcp_remote",
|
||||
url: "https://mcp.context7.com/mcp",
|
||||
},
|
||||
credentialFields: [],
|
||||
recommendedDefaults: {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: [],
|
||||
},
|
||||
urlPatterns: ["https://mcp.context7.com/*"],
|
||||
},
|
||||
] satisfies AppGalleryEntry[];
|
||||
|
||||
export type AppGalleryKey = (typeof TOOL_APP_GALLERY)[number]["key"];
|
||||
|
||||
export function getToolAppGalleryEntry(key: string): AppGalleryEntry | null {
|
||||
return TOOL_APP_GALLERY.find((entry) => entry.key === key) ?? null;
|
||||
}
|
||||
|
||||
function wildcardPatternToRegExp(pattern: string): RegExp {
|
||||
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*");
|
||||
return new RegExp(`^${escaped}$`, "i");
|
||||
}
|
||||
|
||||
export function getToolAppGalleryEntryForUrl(
|
||||
link: string,
|
||||
entries: readonly AppGalleryEntry[] = TOOL_APP_GALLERY,
|
||||
): AppGalleryEntry | null {
|
||||
let normalized: string;
|
||||
try {
|
||||
normalized = new URL(link.trim()).toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return entries.find((entry) =>
|
||||
entry.urlPatterns.some((pattern) => wildcardPatternToRegExp(pattern).test(normalized))
|
||||
) ?? null;
|
||||
}
|
||||
@@ -2,5 +2,5 @@ import { z } from "zod";
|
||||
import { toolConnectionOwnershipSchema, toolConnectionTransportSchema } from "./tool-access.js";
|
||||
const field=z.object({key:z.string().min(1),label:z.string().min(1),type:z.enum(["text","password","textarea","datetime","select","checkbox"]),required:z.boolean().optional(),placeholder:z.string().optional(),helperMd:z.string().optional(),secret:z.boolean().optional(),prefix:z.string().optional()}).superRefine((v,c)=>{if(v.required&&v.type!=="checkbox"&&!v.placeholder)c.addIssue({code:"custom",message:"Required fields need placeholders",path:["placeholder"]})});
|
||||
export const connectionMethodDefSchema=z.object({key:z.string().min(1),transport:toolConnectionTransportSchema,auth:z.enum(["oauth","api_key","none"]),ownershipModes:z.array(toolConnectionOwnershipSchema).min(1),whenToUse:z.string().min(1),defaults:z.object({serverUrl:z.string().url().optional(),discoveryUrl:z.string().url().nullable().optional(),serviceHost:z.string().optional(),templateKey:z.string().optional(),authorizationEndpoint:z.string().url().optional(),tokenEndpoint:z.string().url().optional(),metadataUrl:z.string().url().optional(),scopesHint:z.array(z.string()).optional()}).optional(),tenantFields:z.array(field).optional(),extensionFields:z.array(field).optional(),credentialFields:z.array(field).optional(),keyPlacement:z.object({location:z.enum(["header","query","body_json","env"]),name:z.string().min(1),prefix:z.string().nullable().optional()}).optional(),guidanceMd:z.string().min(1),consoleLinks:z.object({register:z.string().url().optional(),keys:z.string().url().optional(),settings:z.string().url().optional(),docs:z.string().url().optional()}).optional(),warnings:z.array(z.string()).optional(),variants:z.array(z.object({key:z.string(),label:z.string(),whenToUse:z.string(),tenantFields:z.array(field).optional()})).optional(),riskTier:z.enum(["S1","S2","S3","S4"]),requiredResourceFilters:z.array(z.string()).optional()}).superRefine((v,c)=>{if(v.auth==="api_key"&&!v.keyPlacement)c.addIssue({code:"custom",message:"API-key methods require keyPlacement",path:["keyPlacement"]})});
|
||||
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional()});
|
||||
export const appDefinitionSchema=z.object({schemaVersion:z.literal(1),slug:z.string().regex(/^[a-z0-9]+(?:-[a-z0-9]+)*$/),name:z.string().min(1),description:z.string().min(1),categories:z.array(z.enum(["ai","analytics","commerce","communication","content","data","developer","productivity","other"])).min(1),featured:z.boolean().optional(),branding:z.object({logoUrl:z.string().url(),darkLogoUrl:z.string().url().optional(),backgroundColor:z.string().optional(),accentColor:z.string().optional()}),urlPatterns:z.array(z.string()),docsUrl:z.string().url().optional(),methods:z.array(connectionMethodDefSchema).min(1),suggestable:z.boolean().optional(),availability:z.object({available:z.boolean(),reason:z.string().optional(),robotEmail:z.string().optional()}).optional(),ownershipAvailability:z.object({platform_shared:z.boolean().optional(),platform_provisioned:z.boolean().optional(),customer:z.boolean().optional(),dcr:z.boolean().optional()}).optional()});
|
||||
export const appDefinitionsSchema=z.array(appDefinitionSchema).superRefine((v,c)=>{const s=new Set<string>();v.forEach((a,i)=>{if(s.has(a.slug))c.addIssue({code:"custom",message:"Duplicate slug",path:[i,"slug"]});s.add(a.slug)})});
|
||||
@@ -2285,7 +2285,7 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
const res = await request(app).get(`/api/companies/${company.id}/tools/gallery`);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.apps.map((entry: { key: string }) => entry.key)).toEqual([
|
||||
expect(res.body.apps.map((app: { slug: string }) => app.slug)).toEqual([
|
||||
"zapier",
|
||||
"github",
|
||||
"slack",
|
||||
@@ -2294,23 +2294,36 @@ describeEmbeddedPostgres("tool access service", () => {
|
||||
"google-sheets",
|
||||
"context7",
|
||||
]);
|
||||
expect(res.body.apps.map((entry: { key: string }) => entry.key)).not.toContain("google-drive");
|
||||
expect(res.body.apps.map((app: { slug: string }) => app.slug)).not.toContain("google-drive");
|
||||
expect(res.body.apps).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: "slack",
|
||||
authKind: "oauth",
|
||||
oauth: expect.objectContaining({ provider: "slack" }),
|
||||
slug: "slack",
|
||||
methods: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
auth: "oauth",
|
||||
defaults: expect.objectContaining({ authorizationEndpoint: "https://slack.com/oauth/v2/authorize" }),
|
||||
}),
|
||||
]),
|
||||
ownershipAvailability: expect.objectContaining({
|
||||
platform_shared: false,
|
||||
platform_provisioned: false,
|
||||
customer: true,
|
||||
dcr: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: "zapier",
|
||||
credentialFields: [
|
||||
slug: "zapier",
|
||||
methods: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
configPath: "credentials.authorization",
|
||||
placement: "header",
|
||||
key: "Authorization",
|
||||
credentialFields: [expect.objectContaining({ key: "authorization" })],
|
||||
keyPlacement: expect.objectContaining({ location: "header", name: "Authorization" }),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
slug: "google-sheets",
|
||||
availability: expect.objectContaining({ available: false }),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -3,7 +3,8 @@ import type { Db } from "@paperclipai/db";
|
||||
import { agents, companies } from "@paperclipai/db";
|
||||
import { eq } from "drizzle-orm";
|
||||
import {
|
||||
TOOL_APP_GALLERY,
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
DEFAULT_OWNERSHIP_AVAILABILITY,
|
||||
TOOL_ACTION_REQUEST_STATUSES,
|
||||
type DeploymentExposure,
|
||||
type DeploymentMode,
|
||||
@@ -218,15 +219,16 @@ export function toolAccessRoutes(
|
||||
assertCompanyAccess(req, companyId);
|
||||
const googleSheetsAvailability = googleSheetsRobotEmailFromEnv();
|
||||
res.json({
|
||||
apps: TOOL_APP_GALLERY.map((entry) =>
|
||||
entry.key === "google-sheets"
|
||||
apps: CONNECTABLE_APP_DEFINITIONS.map((app) =>
|
||||
app.slug === "google-sheets"
|
||||
? {
|
||||
...entry,
|
||||
...app,
|
||||
ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY,
|
||||
availability: googleSheetsAvailability.available
|
||||
? { available: true, robotEmail: googleSheetsAvailability.robotEmail }
|
||||
: { available: false, reason: googleSheetsAvailability.reason },
|
||||
}
|
||||
: entry,
|
||||
: { ...app, ownershipAvailability: DEFAULT_OWNERSHIP_AVAILABILITY },
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
toolRuntimeSlots,
|
||||
} from "@paperclipai/db";
|
||||
import type {
|
||||
AppDefinition,
|
||||
ConnectionTokenIssuanceOutcome,
|
||||
ConnectionTokenIssuancePath,
|
||||
ConnectionTokenRequest,
|
||||
@@ -104,7 +105,7 @@ import type {
|
||||
UpdateToolProfileWithEntries,
|
||||
UnbindToolProfileBinding,
|
||||
} from "@paperclipai/shared";
|
||||
import { CLASS3_STATIC_LEASE_ALLOWLIST, getToolAppGalleryEntry, isToolConnectionAttentionHealth } from "@paperclipai/shared";
|
||||
import { CLASS3_STATIC_LEASE_ALLOWLIST, credentialConfigPath, getAvailableConnectionMethod, getConnectableAppDefinition, isToolConnectionAttentionHealth, recommendedDefaultsForApp } from "@paperclipai/shared";
|
||||
import { badRequest, conflict, forbidden, HttpError, notFound, unprocessable } from "../errors.js";
|
||||
import { logActivity } from "./activity-log.js";
|
||||
import { mcpHttpRequestHeaders, parseMcpHttpResponseBody } from "./mcp-http.js";
|
||||
@@ -423,6 +424,25 @@ export function googleSheetsRobotEmailFromEnv(
|
||||
return { available: false, reason: "Google Sheets is not available on this instance yet." };
|
||||
}
|
||||
|
||||
function connectionMethodFor(app: AppDefinition) {
|
||||
const method = getAvailableConnectionMethod(app);
|
||||
if (!method) throw unprocessable("This app does not have an available connection method");
|
||||
return method;
|
||||
}
|
||||
|
||||
function credentialFieldsFor(app: AppDefinition) {
|
||||
const method = connectionMethodFor(app);
|
||||
return (method.credentialFields ?? []).map((field) => ({
|
||||
label: field.label,
|
||||
configPath: credentialConfigPath(field),
|
||||
helpUrl: method.consoleLinks?.keys ?? method.consoleLinks?.docs ?? "",
|
||||
required: field.required,
|
||||
placement: method.keyPlacement?.location === "header" ? "header" as const : undefined,
|
||||
key: method.keyPlacement?.name,
|
||||
prefix: method.keyPlacement?.prefix,
|
||||
}));
|
||||
}
|
||||
|
||||
function googleSheetsAllowedSpreadsheetIds(configValues: Record<string, unknown> | undefined): string[] {
|
||||
const raw = configValues?.allowedSpreadsheetIds;
|
||||
const values = Array.isArray(raw) ? raw : typeof raw === "string" ? raw.split(/[\n,]/g) : [];
|
||||
@@ -3833,13 +3853,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function oauthProviderEndpoints(galleryEntry: NonNullable<ReturnType<typeof getToolAppGalleryEntry>>): Promise<OAuthProviderEndpoints> {
|
||||
const oauth = galleryEntry.oauth;
|
||||
if (!oauth) throw unprocessable("This app does not support sign in");
|
||||
let authorizationUrl = oauth.authorizationUrl ?? null;
|
||||
let tokenUrl = oauth.tokenUrl ?? null;
|
||||
if ((!authorizationUrl || !tokenUrl) && oauth.metadataUrl) {
|
||||
const response = await fetchRemoteHttpUrl(oauth.metadataUrl);
|
||||
async function oauthProviderEndpoints(app: AppDefinition): Promise<OAuthProviderEndpoints> {
|
||||
const method = connectionMethodFor(app);
|
||||
if (method.auth !== "oauth") throw unprocessable("This app does not support sign in");
|
||||
let authorizationUrl = method.defaults?.authorizationEndpoint ?? null;
|
||||
let tokenUrl = method.defaults?.tokenEndpoint ?? null;
|
||||
const metadataUrl = method.defaults?.metadataUrl ?? null;
|
||||
if ((!authorizationUrl || !tokenUrl) && metadataUrl) {
|
||||
const response = await fetchRemoteHttpUrl(metadataUrl);
|
||||
if (!response.ok) throw new HttpError(502, "OAuth provider metadata could not be loaded", { code: "oauth_metadata_failed" });
|
||||
const metadata = asRecord(await response.json() as unknown);
|
||||
authorizationUrl = authorizationUrl ?? (typeof metadata.authorization_endpoint === "string" ? metadata.authorization_endpoint : null);
|
||||
@@ -3848,7 +3869,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
if (!authorizationUrl || !tokenUrl) {
|
||||
throw unprocessable("OAuth provider endpoints are not configured for this app");
|
||||
}
|
||||
return { provider: oauth.provider, scopes: oauth.scopes, authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl: oauth.metadataUrl ?? null };
|
||||
return { provider: app.slug, scopes: method.defaults?.scopesHint ?? [], authorizationUrl, tokenUrl, grantType: "authorization_code", metadataUrl };
|
||||
}
|
||||
|
||||
async function oauthEndpointsForConnection(
|
||||
@@ -3858,9 +3879,9 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
): Promise<OAuthProviderEndpoints> {
|
||||
const smokeLabEndpoints = smokeLabOAuthEndpoints(connection, redirectUri);
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
|
||||
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
const endpoints = smokeLabEndpoints
|
||||
?? (galleryEntry?.authKind === "oauth" && galleryEntry.oauth
|
||||
?? (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth"
|
||||
? await oauthProviderEndpoints(galleryEntry)
|
||||
: await discoverOAuthEndpoints(connection, challenge));
|
||||
if (!endpoints) throw unprocessable("This app connection does not advertise OAuth sign in");
|
||||
@@ -3871,8 +3892,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
async function oauthGalleryEntryForConnection(connection: typeof toolConnections.$inferSelect) {
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
if (!sourceTemplateKey) throw unprocessable("This app connection was not created from the app gallery");
|
||||
const galleryEntry = getToolAppGalleryEntry(sourceTemplateKey);
|
||||
if (!galleryEntry || galleryEntry.authKind !== "oauth" || !galleryEntry.oauth) {
|
||||
const galleryEntry = getConnectableAppDefinition(sourceTemplateKey);
|
||||
if (!galleryEntry || connectionMethodFor(galleryEntry).auth !== "oauth") {
|
||||
throw unprocessable("This app connection does not use sign in");
|
||||
}
|
||||
return galleryEntry;
|
||||
@@ -4087,7 +4108,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
input: ConnectToolApp,
|
||||
actor?: ActorInfo,
|
||||
): Promise<ConnectToolAppResult> {
|
||||
const galleryEntry = input.galleryKey ? getToolAppGalleryEntry(input.galleryKey) : null;
|
||||
const galleryEntry = input.galleryKey ? getConnectableAppDefinition(input.galleryKey) : null;
|
||||
if (input.galleryKey && !galleryEntry) throw notFound("Tool app gallery entry not found");
|
||||
|
||||
let existingApplication: typeof toolApplications.$inferSelect | null = null;
|
||||
@@ -4101,18 +4122,15 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
}
|
||||
|
||||
const name = input.name ?? existingApplication?.name ?? galleryEntry?.name ?? defaultLinkName(input.link ?? "");
|
||||
const transportTemplate = galleryEntry?.transportTemplate ?? {
|
||||
transport: "mcp_remote" as const,
|
||||
url: input.link ?? "",
|
||||
};
|
||||
const transport = transportTemplate.transport;
|
||||
const method = galleryEntry ? connectionMethodFor(galleryEntry) : null;
|
||||
const transport = method?.transport ?? "mcp_remote";
|
||||
const baseConfig = transport === "mcp_remote"
|
||||
? { url: transportTemplate.url }
|
||||
: { templateId: transportTemplate.templateKey };
|
||||
? { url: method?.defaults?.serverUrl ?? input.link ?? "" }
|
||||
: { templateId: method?.defaults?.templateKey };
|
||||
let config: Record<string, unknown> = galleryEntry
|
||||
? { ...baseConfig, sourceTemplateKey: galleryEntry.key, quarantineNewEntries: true }
|
||||
? { ...baseConfig, sourceTemplateKey: galleryEntry.slug, quarantineNewEntries: true }
|
||||
: { ...baseConfig, quarantineNewEntries: true };
|
||||
if (galleryEntry?.key === GOOGLE_SHEETS_GALLERY_KEY) {
|
||||
if (galleryEntry?.slug === GOOGLE_SHEETS_GALLERY_KEY) {
|
||||
const availability = googleSheetsRobotEmailFromEnv();
|
||||
if (!availability.available) {
|
||||
throw unprocessable(availability.reason, { code: "google_sheets_unavailable" });
|
||||
@@ -4142,7 +4160,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
let revivedConnectionPrevious: typeof toolConnections.$inferSelect | null = null;
|
||||
|
||||
try {
|
||||
const credentialFields = galleryEntry?.credentialFields ?? linkCredentialFields(credentialValues);
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : linkCredentialFields(credentialValues);
|
||||
for (const field of credentialFields) {
|
||||
const value = credentialValues[field.configPath];
|
||||
if (!value && field.required !== false) {
|
||||
@@ -4188,12 +4206,12 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
} else {
|
||||
[applicationRow] = await db.insert(toolApplications).values({
|
||||
companyId,
|
||||
applicationKey: `app-gallery:${galleryEntry?.key ?? "link"}:${randomUUID()}`,
|
||||
applicationKey: `app-gallery:${galleryEntry?.slug ?? "link"}:${randomUUID()}`,
|
||||
name,
|
||||
description: galleryEntry?.tagline ?? `Connected app at ${input.link}`,
|
||||
description: galleryEntry?.description ?? `Connected app at ${input.link}`,
|
||||
type: transport === "mcp_remote" ? "mcp_http" : "mcp_stdio",
|
||||
status: "draft",
|
||||
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.key, galleryKey: galleryEntry.key } : { source: "link" },
|
||||
metadata: galleryEntry ? { sourceTemplateKey: galleryEntry.slug, galleryKey: galleryEntry.slug } : { source: "link" },
|
||||
}).returning();
|
||||
}
|
||||
|
||||
@@ -4235,7 +4253,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
name,
|
||||
uid: connectionUid(applicationRow.applicationKey ?? applicationRow.name, name, connectionId),
|
||||
connectionKind: "managed",
|
||||
authKind: galleryEntry?.authKind ?? "none",
|
||||
authKind: galleryEntry ? connectionMethodFor(galleryEntry).auth : "none",
|
||||
transport,
|
||||
status: "draft",
|
||||
enabled: false,
|
||||
@@ -4250,14 +4268,14 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
await syncCredentialBindings(connectionRow);
|
||||
await ensureRuntimeSlot(connectionRow);
|
||||
|
||||
if (galleryEntry?.authKind === "oauth") {
|
||||
if (galleryEntry && connectionMethodFor(galleryEntry).auth === "oauth") {
|
||||
return {
|
||||
connectionId: connectionRow.id,
|
||||
application: toApplication(applicationRow),
|
||||
connection: toConnection(connectionRow),
|
||||
catalog: [],
|
||||
actions: { readOnly: [], canMakeChanges: [] },
|
||||
suggestedDefaults: galleryEntry.recommendedDefaults,
|
||||
suggestedDefaults: recommendedDefaultsForApp(galleryEntry),
|
||||
auth: { kind: "oauth", startUrl: null },
|
||||
};
|
||||
}
|
||||
@@ -4292,7 +4310,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
connection: refresh.connection,
|
||||
catalog: refresh.catalog,
|
||||
actions: groupedActions(refresh.catalog),
|
||||
suggestedDefaults: galleryEntry?.recommendedDefaults ?? {
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
@@ -4619,8 +4637,8 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
if (connection.status === "archived") throw conflict("Archived app connections cannot be reconnected");
|
||||
const sourceTemplateKey =
|
||||
typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
|
||||
const credentialFields = galleryEntry?.credentialFields ?? [
|
||||
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
const credentialFields = galleryEntry ? credentialFieldsFor(galleryEntry) : [
|
||||
{
|
||||
label: "App key",
|
||||
configPath: "credentials.authorization",
|
||||
@@ -4784,7 +4802,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
|
||||
let connection = await getConnectionRow(stateRow.connectionId, stateRow.companyId);
|
||||
const sourceTemplateKey = typeof connection.config.sourceTemplateKey === "string" ? connection.config.sourceTemplateKey : null;
|
||||
const galleryEntry = sourceTemplateKey ? getToolAppGalleryEntry(sourceTemplateKey) : null;
|
||||
const galleryEntry = sourceTemplateKey ? getConnectableAppDefinition(sourceTemplateKey) : null;
|
||||
const endpoints = await oauthEndpointsForConnection(connection, null, input.redirectUri);
|
||||
const client = oauthClientForConnection(connection, endpoints.provider);
|
||||
if (!client.clientId) throw unprocessable(`OAuth client id is not configured for ${endpoints.provider}`);
|
||||
@@ -4884,7 +4902,7 @@ export function toolAccessService(db: Db, options: ToolAccessServiceOptions = {}
|
||||
connection: refresh.connection,
|
||||
catalog: refresh.catalog,
|
||||
actions: groupedActions(refresh.catalog),
|
||||
suggestedDefaults: galleryEntry?.recommendedDefaults ?? {
|
||||
suggestedDefaults: galleryEntry ? recommendedDefaultsForApp(galleryEntry) : {
|
||||
access: "all_agents",
|
||||
askFirstRiskLevels: ["write", "destructive"],
|
||||
},
|
||||
|
||||
+2
-2
@@ -34,7 +34,7 @@ import type {
|
||||
ToolRiskLevel,
|
||||
UpdateToolPolicy,
|
||||
ReorderToolPolicies,
|
||||
AppGalleryEntry,
|
||||
AppDefinition,
|
||||
ToolAppsAttentionResponse,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionTestAgentsResponse,
|
||||
@@ -70,7 +70,7 @@ export type ToolRuntimeHealthResponse = ToolRuntimeHealthSummary;
|
||||
export type ToolTrustRulesResponse = { trustRules: ToolPolicy[] };
|
||||
export type ToolPoliciesResponse = { policies: ToolPolicy[] };
|
||||
export type ToolProfilesResponse = { profiles: ToolProfileWithDetails[] };
|
||||
export type ToolGalleryResponse = { apps: AppGalleryEntry[] };
|
||||
export type ToolGalleryResponse = { apps: AppDefinition[] };
|
||||
export type ToolMcpGatewaysResponse = { gateways: ToolMcpGatewayWithTokens[] };
|
||||
export type CreateGatewayTokenInput = Omit<CreateToolMcpGatewayToken, "expiresAt"> & {
|
||||
expiresAt?: string | Date | null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import type { AppGalleryEntry, ToolApplication, ToolConnection } from "@paperclipai/shared";
|
||||
import type { AppDefinition, ToolApplication, ToolConnection } from "@paperclipai/shared";
|
||||
import { Link } from "@/lib/router";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
@@ -83,7 +83,7 @@ export function AppDetailSidebar(props: AppDetailSidebarProps) {
|
||||
<span className="truncate">All apps</span>
|
||||
</Link>
|
||||
<div className="flex min-w-0 items-center gap-2 px-2 py-1">
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={28} />
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.branding.logoUrl} size={28} />
|
||||
<span className="flex-1 truncate text-sm font-bold text-foreground">{appName}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -117,17 +117,17 @@ function tabHref(props: AppDetailSidebarProps, tab: AppTabKey): string {
|
||||
}
|
||||
|
||||
function galleryEntryFor(
|
||||
apps: AppGalleryEntry[],
|
||||
apps: AppDefinition[],
|
||||
connection: ToolConnection | undefined,
|
||||
application: ToolApplication | undefined,
|
||||
): AppGalleryEntry | null {
|
||||
): AppDefinition | null {
|
||||
if (application?.applicationKey) {
|
||||
const keyed = apps.find((app) => app.key === application.applicationKey);
|
||||
const keyed = apps.find((app) => app.slug === application.applicationKey);
|
||||
if (keyed) return keyed;
|
||||
}
|
||||
const name = (connection?.name ?? application?.name)?.toLowerCase();
|
||||
if (!name) return null;
|
||||
return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.key === name) ?? null;
|
||||
return apps.find((app) => app.name.toLowerCase() === name) ?? apps.find((app) => app.slug === name) ?? null;
|
||||
}
|
||||
|
||||
function latestArchivedConnection(connections: ToolConnection[]): ToolConnection | null {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Check, Loader2, Pencil } from "lucide-react";
|
||||
import type {
|
||||
AppGalleryEntry,
|
||||
AppDefinition,
|
||||
ToolConnection,
|
||||
ToolPolicy,
|
||||
ToolProfileWithDetails,
|
||||
@@ -479,7 +479,7 @@ function AppDetailHeader({
|
||||
}: {
|
||||
appName: string;
|
||||
connection: ToolConnection;
|
||||
logoEntry: AppGalleryEntry | null;
|
||||
logoEntry: AppDefinition | null;
|
||||
status: StatusInfo;
|
||||
actionCount: number;
|
||||
renaming: boolean;
|
||||
@@ -493,7 +493,7 @@ function AppDetailHeader({
|
||||
return (
|
||||
<header className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.logoUrl} size={44} />
|
||||
<AppLogo name={appName} logoUrl={logoEntry?.branding.logoUrl} size={44} />
|
||||
<div>
|
||||
{renaming ? (
|
||||
<form
|
||||
@@ -607,10 +607,10 @@ function accessFrom(profile: ToolProfileWithDetails | undefined): AccessDraft {
|
||||
return { mode: "specific", agentIds };
|
||||
}
|
||||
|
||||
function galleryEntryFor(apps: AppGalleryEntry[], connection: ToolConnection | undefined): AppGalleryEntry | null {
|
||||
function galleryEntryFor(apps: AppDefinition[], connection: ToolConnection | undefined): AppDefinition | null {
|
||||
if (!connection) return null;
|
||||
const name = connection.name.toLowerCase();
|
||||
return apps.find((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.key === name) ?? null;
|
||||
return apps.find((a) => a.name.toLowerCase() === name) ?? apps.find((a) => a.slug === name) ?? null;
|
||||
}
|
||||
|
||||
function addAll(set: Set<string>, ids: string[]): Set<string> {
|
||||
|
||||
@@ -123,8 +123,8 @@ export function AppNotConnected() {
|
||||
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const logoUrl =
|
||||
(application.applicationKey ? gallery.find((entry) => entry.key === application.applicationKey)?.logoUrl : undefined) ??
|
||||
gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.logoUrl;
|
||||
(application.applicationKey ? gallery.find((entry) => entry.slug === application.applicationKey)?.branding.logoUrl : undefined) ??
|
||||
gallery.find((entry) => entry.name.toLowerCase() === application.name.toLowerCase())?.branding.logoUrl;
|
||||
|
||||
const previousAddress = previousConnection ? connectionAddress(previousConnection) : null;
|
||||
const connectHref = reconnectHref({
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { flushSync } from "react-dom";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { CONNECTABLE_APP_DEFINITIONS } from "@paperclipai/shared";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { AppsConnect } from "./AppsConnect";
|
||||
|
||||
@@ -15,6 +16,9 @@ const mockNavigate = vi.hoisted(() => vi.fn());
|
||||
const mockSearch = vi.hoisted(() => ({ value: "" }));
|
||||
const mockParams = vi.hoisted(() => ({ appKey: undefined as string | undefined }));
|
||||
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "zapier")!;
|
||||
const GOOGLE_SHEETS = CONNECTABLE_APP_DEFINITIONS.find((app) => app.slug === "google-sheets")!;
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
listGallery: (companyId: string) => listGalleryMock(companyId),
|
||||
@@ -122,15 +126,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
document.body.appendChild(container);
|
||||
listGalleryMock.mockResolvedValue({
|
||||
apps: [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Automate things",
|
||||
authKind: "api_key",
|
||||
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
|
||||
logoUrl: null,
|
||||
credentialFields: [{ configPath: "credentials.authorization", label: "API key", required: true }],
|
||||
},
|
||||
ZAPIER,
|
||||
],
|
||||
});
|
||||
finishAppMock.mockResolvedValue({});
|
||||
@@ -280,15 +276,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
mockSearch.value = "byo=1&source=zapier";
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "zapier",
|
||||
name: "Zapier",
|
||||
tagline: "Automate things",
|
||||
authKind: "api_key",
|
||||
urlPatterns: ["https://zapier.com/*", "https://*.zapier.com/*"],
|
||||
logoUrl: "https://example.com/zapier.png",
|
||||
credentialFields: [],
|
||||
},
|
||||
{ ...ZAPIER, branding: { ...ZAPIER.branding, logoUrl: "https://example.com/zapier.png" } },
|
||||
],
|
||||
});
|
||||
connectAppMock.mockResolvedValueOnce({
|
||||
@@ -475,16 +463,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
it("shows the Google Sheets robot email and keeps empty sheet links from continuing", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
|
||||
],
|
||||
});
|
||||
await render();
|
||||
@@ -505,16 +484,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
it("shows inline validation for invalid Google Sheets links", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
|
||||
],
|
||||
});
|
||||
await render();
|
||||
@@ -615,16 +585,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
it("a custom name on the Google Sheets step is sent to the connect mutation", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
|
||||
],
|
||||
});
|
||||
await render();
|
||||
@@ -659,16 +620,7 @@ describe("AppsConnect — Connect with a link (M4 frame)", () => {
|
||||
it("passes parsed Google Sheets IDs as connection config values", async () => {
|
||||
listGalleryMock.mockResolvedValueOnce({
|
||||
apps: [
|
||||
{
|
||||
key: "google-sheets",
|
||||
name: "Google Sheets",
|
||||
tagline: "Read and update selected spreadsheets.",
|
||||
authKind: "none",
|
||||
urlPatterns: ["https://docs.google.com/spreadsheets/*"],
|
||||
logoUrl: "https://example.com/sheets.png",
|
||||
credentialFields: [],
|
||||
availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" },
|
||||
},
|
||||
{ ...GOOGLE_SHEETS, availability: { available: true, robotEmail: "robot@paperclip.iam.gserviceaccount.com" } },
|
||||
],
|
||||
});
|
||||
await render();
|
||||
|
||||
@@ -15,11 +15,11 @@ import {
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import type {
|
||||
Agent,
|
||||
AppGalleryEntry,
|
||||
AppDefinition,
|
||||
ConnectToolAppResult,
|
||||
ToolAppConnectionActionSummary,
|
||||
} from "@paperclipai/shared";
|
||||
import { getToolAppGalleryEntryForUrl } from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAppDefinitionForUrl, getAvailableConnectionMethod } from "@paperclipai/shared";
|
||||
import { useNavigate, useParams, useSearchParams } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -83,8 +83,8 @@ function askFirstLevelsFrom(result: ConnectToolAppResult): string[] {
|
||||
return Array.isArray(raw) ? raw.filter((x): x is string => typeof x === "string") : ["write", "destructive"];
|
||||
}
|
||||
|
||||
function isGoogleSheetsEntry(entry: AppGalleryEntry | null): boolean {
|
||||
return entry?.key === "google-sheets";
|
||||
function isGoogleSheetsEntry(entry: AppDefinition | null): boolean {
|
||||
return entry?.slug === "google-sheets";
|
||||
}
|
||||
|
||||
export function AppsConnect() {
|
||||
@@ -109,7 +109,7 @@ export function AppsConnect() {
|
||||
});
|
||||
|
||||
const [step, setStep] = useState<Step>(appKey || prefill.link || zapierSource ? "key" : "gallery");
|
||||
const [entry, setEntry] = useState<AppGalleryEntry | null>(null);
|
||||
const [entry, setEntry] = useState<AppDefinition | null>(null);
|
||||
const [galleryName, setGalleryName] = useState("");
|
||||
const [linkUrl, setLinkUrl] = useState(prefill.link);
|
||||
const [linkName, setLinkName] = useState(prefill.name || (zapierSource ? "Zapier" : ""));
|
||||
@@ -160,15 +160,15 @@ export function AppsConnect() {
|
||||
useEffect(() => {
|
||||
if (!appKey || galleryQuery.isLoading || !galleryQuery.data) return;
|
||||
|
||||
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.key === appKey);
|
||||
if (!requestedEntry || requestedEntry.authKind === "oauth" || requestedEntry.availability?.available === false) {
|
||||
const requestedEntry = galleryQuery.data.apps.find((candidate) => candidate.slug === appKey);
|
||||
if (!requestedEntry || getAvailableConnectionMethod(requestedEntry)?.auth === "oauth" || requestedEntry.availability?.available === false) {
|
||||
setEntry(null);
|
||||
setStep("gallery");
|
||||
navigate("/apps/connect", { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (entry?.key !== requestedEntry.key) {
|
||||
if (entry?.slug !== requestedEntry.slug) {
|
||||
setEntry(requestedEntry);
|
||||
setGalleryName(requestedEntry.name);
|
||||
setLinkUrl("");
|
||||
@@ -183,11 +183,11 @@ export function AppsConnect() {
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
}, [appKey, entry?.key, galleryQuery.data, galleryQuery.isLoading, navigate]);
|
||||
}, [appKey, entry?.slug, galleryQuery.data, galleryQuery.isLoading, navigate]);
|
||||
|
||||
const setAppStep = (nextStep: Step) => {
|
||||
setStep(nextStep);
|
||||
if (entry) navigate(appConnectHref(entry.key, nextStep));
|
||||
if (entry) navigate(appConnectHref(entry.slug, nextStep));
|
||||
};
|
||||
|
||||
const connectMutation = useMutation({
|
||||
@@ -196,7 +196,7 @@ export function AppsConnect() {
|
||||
const sheetIds = isGoogleSheetsEntry(entry) ? parseGoogleSheetIds(googleSheetsLinks).ids : [];
|
||||
const trimmedGalleryName = galleryName.trim();
|
||||
return toolsApi.connectApp(selectedCompanyId!, {
|
||||
galleryKey: entry.key,
|
||||
galleryKey: entry.slug,
|
||||
name: trimmedGalleryName || undefined,
|
||||
credentialValues: credentials,
|
||||
configValues: isGoogleSheetsEntry(entry) ? { allowedSpreadsheetIds: sheetIds } : undefined,
|
||||
@@ -284,7 +284,7 @@ export function AppsConnect() {
|
||||
entry?.name ??
|
||||
(linkName.trim() || defaultLinkName(linkUrl) || "this app");
|
||||
const zapierEntry = zapierSource
|
||||
? galleryQuery.data?.apps.find((app) => app.key === "zapier") ?? null
|
||||
? galleryQuery.data?.apps.find((app) => app.slug === "zapier") ?? null
|
||||
: null;
|
||||
const stepLabels = zapierSource
|
||||
? ZAPIER_STEP_LABELS
|
||||
@@ -311,7 +311,7 @@ export function AppsConnect() {
|
||||
labels={stepLabels}
|
||||
appIdentity={
|
||||
zapierSource
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.logoUrl ?? null }
|
||||
? { name: "Zapier", logoUrl: zapierEntry?.branding.logoUrl ?? null }
|
||||
: undefined
|
||||
}
|
||||
onCancel={() => navigate(zapierSource ? "/apps/browse" : "/apps")}
|
||||
@@ -338,10 +338,10 @@ export function AppsConnect() {
|
||||
setInstallMode("none");
|
||||
setInstallAgentIds(new Set());
|
||||
setStep("key");
|
||||
navigate(appConnectHref(picked.key, "key"));
|
||||
navigate(appConnectHref(picked.slug, "key"));
|
||||
}}
|
||||
onUseLink={(url) => {
|
||||
const matchedEntry = getToolAppGalleryEntryForUrl(url, galleryQuery.data?.apps ?? []);
|
||||
const matchedEntry = getAppDefinitionForUrl(url, galleryQuery.data?.apps ?? []);
|
||||
setEntry(null);
|
||||
setGalleryName("");
|
||||
setLinkUrl(url);
|
||||
@@ -470,7 +470,7 @@ export function AppsConnect() {
|
||||
{step === "success" && (
|
||||
<SuccessStep
|
||||
appName={appName}
|
||||
logoUrl={entry?.logoUrl}
|
||||
logoUrl={entry?.branding.logoUrl}
|
||||
enabledCount={Object.values(enabled).filter(Boolean).length}
|
||||
access={access}
|
||||
installMode={installMode}
|
||||
@@ -607,11 +607,11 @@ function GalleryStep({
|
||||
onPasteConfig,
|
||||
}: {
|
||||
loading: boolean;
|
||||
apps: AppGalleryEntry[];
|
||||
apps: AppDefinition[];
|
||||
/** Entered via the "Connect your own MCP server" card (PAP-12371, Finding C): focus the link path. */
|
||||
byo?: boolean;
|
||||
source?: string | null;
|
||||
onPick: (entry: AppGalleryEntry) => void;
|
||||
onPick: (entry: AppDefinition) => void;
|
||||
onUseLink: (link: string) => void;
|
||||
onRunYourOwn: () => void;
|
||||
onPasteConfig: () => void;
|
||||
@@ -635,7 +635,7 @@ function GalleryStep({
|
||||
return apps.filter((a) => a.name.toLowerCase().includes(q));
|
||||
}, [apps, search]);
|
||||
const normalizedLink = normalizeAppLink(linkInput);
|
||||
const matchedEntry = normalizedLink ? getToolAppGalleryEntryForUrl(normalizedLink, apps) : null;
|
||||
const matchedEntry = normalizedLink ? getAppDefinitionForUrl(normalizedLink, apps) : null;
|
||||
const zapierSource = source === "zapier";
|
||||
|
||||
const continueWithLink = () => {
|
||||
@@ -672,12 +672,12 @@ function GalleryStep({
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4">
|
||||
{filtered.map((app) => {
|
||||
const copy = appCopyFor(app.key, app.tagline);
|
||||
const oauth = app.authKind === "oauth";
|
||||
const copy = appCopyFor(app.slug, app.description);
|
||||
const oauth = getAvailableConnectionMethod(app)?.auth === "oauth";
|
||||
const unavailable = app.availability?.available === false;
|
||||
return (
|
||||
<button
|
||||
key={app.key}
|
||||
key={app.slug}
|
||||
type="button"
|
||||
disabled={oauth || unavailable}
|
||||
title={
|
||||
@@ -691,7 +691,7 @@ function GalleryStep({
|
||||
oauth || unavailable ? "cursor-not-allowed opacity-60" : "hover:border-foreground/30 hover:bg-accent/40",
|
||||
)}
|
||||
>
|
||||
<AppLogo name={app.name} logoUrl={app.logoUrl} size={36} />
|
||||
<AppLogo name={app.name} logoUrl={app.branding.logoUrl} size={36} />
|
||||
<div className="mt-3 text-sm font-bold text-foreground">{app.name}</div>
|
||||
<div className="mt-1 line-clamp-2 text-xs text-muted-foreground">{copy.tagline}</div>
|
||||
<div className="mt-3 text-xs font-semibold text-foreground">
|
||||
@@ -740,7 +740,7 @@ function GalleryStep({
|
||||
{matchedEntry && (
|
||||
<div className="mt-3 flex items-center justify-between gap-3 rounded-lg border border-border bg-muted/40 px-3 py-2">
|
||||
<div className="flex min-w-0 items-center gap-2 text-sm">
|
||||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.logoUrl} size={24} />
|
||||
<AppLogo name={matchedEntry.name} logoUrl={matchedEntry.branding.logoUrl} size={24} />
|
||||
<span className="truncate">This looks like {matchedEntry.name}.</span>
|
||||
</div>
|
||||
<Button
|
||||
@@ -750,7 +750,7 @@ function GalleryStep({
|
||||
disabled={matchedEntry.availability?.available === false}
|
||||
onClick={() => {
|
||||
setLinkError(null);
|
||||
if (matchedEntry.key === "zapier") {
|
||||
if (matchedEntry.slug === "zapier") {
|
||||
continueWithLink();
|
||||
return;
|
||||
}
|
||||
@@ -759,7 +759,7 @@ function GalleryStep({
|
||||
>
|
||||
{matchedEntry.availability?.available === false
|
||||
? "Not available"
|
||||
: matchedEntry.key === "zapier"
|
||||
: matchedEntry.slug === "zapier"
|
||||
? "Continue"
|
||||
: `Use ${matchedEntry.name}`}
|
||||
</Button>
|
||||
@@ -1037,7 +1037,7 @@ function KeyStep({
|
||||
onBack,
|
||||
onConnect,
|
||||
}: {
|
||||
entry: AppGalleryEntry;
|
||||
entry: AppDefinition;
|
||||
name: string;
|
||||
onNameChange: (next: string) => void;
|
||||
values: Record<string, string>;
|
||||
@@ -1049,8 +1049,13 @@ function KeyStep({
|
||||
onBack: () => void;
|
||||
onConnect: () => void;
|
||||
}) {
|
||||
const copy = appCopyFor(entry.key, entry.tagline);
|
||||
const fields = entry.credentialFields ?? [];
|
||||
const copy = appCopyFor(entry.slug, entry.description);
|
||||
const method = getAvailableConnectionMethod(entry);
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
configPath: credentialConfigPath(field),
|
||||
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
|
||||
}));
|
||||
const allFilled = fields.every(
|
||||
(f) => f.required === false || (values[f.configPath]?.trim().length ?? 0) > 0,
|
||||
);
|
||||
@@ -1063,7 +1068,7 @@ function KeyStep({
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
|
||||
<div>
|
||||
<h2 className="text-lg font-bold tracking-tight sm:text-xl">Connect Google Sheets</h2>
|
||||
<p className="text-sm text-muted-foreground">{copy.short}</p>
|
||||
@@ -1136,7 +1141,7 @@ function KeyStep({
|
||||
return (
|
||||
<div className="mx-auto max-w-xl rounded-2xl border border-border bg-card p-8">
|
||||
<div className="flex items-center gap-3">
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={48} />
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={48} />
|
||||
<div>
|
||||
<h2 className="text-xl font-bold tracking-tight">Connect {entry.name}</h2>
|
||||
<p className="text-sm text-muted-foreground">{copy.short}</p>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { Link2, Search } from "lucide-react";
|
||||
import type { AppGalleryEntry } from "@paperclipai/shared";
|
||||
import type { AppDefinition } from "@paperclipai/shared";
|
||||
import { useNavigate } from "@/lib/router";
|
||||
import { useCompany } from "@/context/CompanyContext";
|
||||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||||
@@ -49,8 +49,8 @@ export function Browse() {
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const popular = useMemo(
|
||||
() =>
|
||||
POPULAR_KEYS.map((key) => gallery.find((entry) => entry.key === key)).filter(
|
||||
(entry): entry is AppGalleryEntry => Boolean(entry),
|
||||
POPULAR_KEYS.map((key) => gallery.find((entry) => entry.slug === key)).filter(
|
||||
(entry): entry is AppDefinition => Boolean(entry),
|
||||
),
|
||||
[gallery],
|
||||
);
|
||||
@@ -61,7 +61,7 @@ export function Browse() {
|
||||
return gallery.filter(
|
||||
(entry) =>
|
||||
entry.name.toLowerCase().includes(trimmed) ||
|
||||
entry.tagline.toLowerCase().includes(trimmed) ||
|
||||
entry.description.toLowerCase().includes(trimmed) ||
|
||||
(entry.description?.toLowerCase().includes(trimmed) ?? false),
|
||||
);
|
||||
}, [gallery, trimmed]);
|
||||
@@ -109,9 +109,9 @@ export function Browse() {
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
|
||||
{popular.map((entry) => (
|
||||
<AppTile
|
||||
key={entry.key}
|
||||
key={entry.slug}
|
||||
entry={entry}
|
||||
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
compact
|
||||
/>
|
||||
))}
|
||||
@@ -132,9 +132,9 @@ export function Browse() {
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{filtered.map((entry) => (
|
||||
<AppTile
|
||||
key={entry.key}
|
||||
key={entry.slug}
|
||||
entry={entry}
|
||||
onConnect={entry.key === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
onConnect={entry.slug === "zapier" ? () => navigate(ZAPIER_CONNECT_HREF) : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
@@ -160,7 +160,7 @@ function AppTile({
|
||||
onConnect,
|
||||
compact = false,
|
||||
}: {
|
||||
entry: AppGalleryEntry;
|
||||
entry: AppDefinition;
|
||||
onConnect?: () => void;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
@@ -175,7 +175,7 @@ function AppTile({
|
||||
? "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"}
|
||||
>
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={36} />
|
||||
<span className="text-xs font-medium text-foreground">{entry.name}</span>
|
||||
<span className={disabled ? "text-xs text-muted-foreground" : "text-xs font-semibold text-primary"}>
|
||||
{disabled ? "Coming soon" : "Connect →"}
|
||||
@@ -192,10 +192,10 @@ function AppTile({
|
||||
? "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"}
|
||||
>
|
||||
<AppLogo name={entry.name} logoUrl={entry.logoUrl} size={36} />
|
||||
<AppLogo name={entry.name} logoUrl={entry.branding.logoUrl} size={36} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">{entry.name}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.tagline}</div>
|
||||
<div className="mt-0.5 line-clamp-2 text-xs text-muted-foreground">{entry.description}</div>
|
||||
</div>
|
||||
<span className={disabled ? "shrink-0 text-xs font-semibold text-muted-foreground" : "shrink-0 text-xs font-semibold text-primary"}>
|
||||
{disabled ? "Coming soon" : "Connect →"}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useMemo, useState, type ReactNode } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { AppWindow, ShieldAlert, ShieldQuestion } from "lucide-react";
|
||||
import type {
|
||||
AppGalleryEntry,
|
||||
AppDefinition,
|
||||
ToolApplication,
|
||||
ToolConnection,
|
||||
ToolProfileWithDetails,
|
||||
@@ -115,13 +115,13 @@ export function Connections() {
|
||||
|
||||
const gallery = galleryQuery.data?.apps ?? [];
|
||||
const logoByName = useMemo(() => {
|
||||
const map = new Map<string, AppGalleryEntry>();
|
||||
const map = new Map<string, AppDefinition>();
|
||||
for (const entry of gallery) map.set(entry.name.toLowerCase(), entry);
|
||||
return map;
|
||||
}, [gallery]);
|
||||
const logoByKey = useMemo(() => {
|
||||
const map = new Map<string, AppGalleryEntry>();
|
||||
for (const entry of gallery) map.set(entry.key, entry);
|
||||
const map = new Map<string, AppDefinition>();
|
||||
for (const entry of gallery) map.set(entry.slug, entry);
|
||||
return map;
|
||||
}, [gallery]);
|
||||
|
||||
@@ -173,7 +173,7 @@ export function Connections() {
|
||||
status: statusFor(application, appConnections),
|
||||
actionCount,
|
||||
lastUsedAt,
|
||||
logoUrl: galleryEntry?.logoUrl ?? logoByName.get(application.name.toLowerCase())?.logoUrl,
|
||||
logoUrl: galleryEntry?.branding.logoUrl ?? logoByName.get(application.name.toLowerCase())?.branding.logoUrl,
|
||||
};
|
||||
});
|
||||
}, [actionCountByConnection, applications, connectionsByApplication, logoByKey, logoByName]);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { ArrowUpRight, Loader2, Lock } from "lucide-react";
|
||||
import type { AppGalleryEntry, ToolConnection } from "@paperclipai/shared";
|
||||
import { humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import type { AppDefinition, ToolConnection } from "@paperclipai/shared";
|
||||
import { credentialConfigPath, getAvailableConnectionMethod, humanizeConnectionDisplayName } from "@paperclipai/shared";
|
||||
import { toolsApi } from "@/api/tools";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -37,7 +37,7 @@ function KeySection({
|
||||
onReplaced,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onReplaced: () => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -82,7 +82,7 @@ export function ReconnectCard({
|
||||
onReconnected,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onReconnected: () => void;
|
||||
}) {
|
||||
return (
|
||||
@@ -105,12 +105,17 @@ function ReconnectForm({
|
||||
onReconnected,
|
||||
}: {
|
||||
connection: ToolConnection;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
galleryEntry: AppDefinition | null;
|
||||
onCancel?: () => void;
|
||||
onReconnected: () => void;
|
||||
}) {
|
||||
const { pushToast } = useToast();
|
||||
const fields = galleryEntry?.credentialFields ?? [];
|
||||
const method = galleryEntry ? getAvailableConnectionMethod(galleryEntry) : null;
|
||||
const fields = (method?.credentialFields ?? []).map((field) => ({
|
||||
...field,
|
||||
configPath: credentialConfigPath(field),
|
||||
helpUrl: method?.consoleLinks?.keys ?? method?.consoleLinks?.docs ?? "",
|
||||
}));
|
||||
const [values, setValues] = useState<Record<string, string>>({});
|
||||
const [single, setSingle] = useState("");
|
||||
const usesGallery = fields.length > 0 && !!galleryEntry;
|
||||
|
||||
@@ -26,7 +26,7 @@ export function SetupPanel({
|
||||
onStartOAuth: () => void;
|
||||
oauthStartDisabled: boolean;
|
||||
}) {
|
||||
const description = galleryEntry?.description ?? galleryEntry?.tagline ?? null;
|
||||
const description = galleryEntry?.description ?? null;
|
||||
const oauth = connection.config?.oauth;
|
||||
const hasOAuthSignIn = Boolean(oauth && typeof oauth === "object" && !Array.isArray(oauth));
|
||||
const isSmokeLabFixture = connection.config?.smokeLabFixture === "oauth-http";
|
||||
@@ -35,7 +35,7 @@ export function SetupPanel({
|
||||
{description && (
|
||||
<p className="max-w-2xl text-sm leading-6 text-muted-foreground">{description}</p>
|
||||
)}
|
||||
{galleryEntry?.key === "google-sheets" && (
|
||||
{galleryEntry?.slug === "google-sheets" && (
|
||||
<GoogleSheetsAllowlistSection
|
||||
connection={connection}
|
||||
disabled={configUpdateDisabled}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type {
|
||||
Agent,
|
||||
AppGalleryEntry,
|
||||
AppDefinition,
|
||||
ToolCallEvent,
|
||||
ToolConnectionActivityResponse,
|
||||
ToolConnectionLifecycleEvent,
|
||||
@@ -14,7 +14,7 @@ export interface AppDetailSectionProps {
|
||||
connectionId: string;
|
||||
connection: ToolConnection;
|
||||
appName: string;
|
||||
galleryEntry: AppGalleryEntry | null;
|
||||
galleryEntry: AppDefinition | null;
|
||||
catalog: ToolCatalogEntry[];
|
||||
active: ToolCatalogEntry[];
|
||||
readOnly: ToolCatalogEntry[];
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import {
|
||||
TOOL_APP_GALLERY,
|
||||
type AppGalleryEntry,
|
||||
CONNECTABLE_APP_DEFINITIONS,
|
||||
type AppDefinition,
|
||||
type McpJsonImportPreview,
|
||||
} from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
@@ -23,7 +23,7 @@ import { PasteConfigTab } from "@/pages/tools/PasteConfigTab";
|
||||
|
||||
const COMPANY = "company-storybook";
|
||||
|
||||
const GALLERY: AppGalleryEntry[] = TOOL_APP_GALLERY.slice(0, 6) as AppGalleryEntry[];
|
||||
const GALLERY: AppDefinition[] = CONNECTABLE_APP_DEFINITIONS.slice(0, 6) as AppDefinition[];
|
||||
|
||||
function seededClient() {
|
||||
const c = new QueryClient({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { TOOL_APP_GALLERY, type AppGalleryEntry } from "@paperclipai/shared";
|
||||
import { CONNECTABLE_APP_DEFINITIONS, type AppDefinition } from "@paperclipai/shared";
|
||||
import { queryKeys } from "@/lib/queryKeys";
|
||||
import { AppsConnect } from "@/pages/apps/AppsConnect";
|
||||
|
||||
@@ -17,8 +17,8 @@ const COMPANY = "company-storybook";
|
||||
|
||||
// Zapier is an api_key gallery app, so the key step renders both the new Name
|
||||
// field and a credential input — a representative shape for this screenshot.
|
||||
const ZAPIER = TOOL_APP_GALLERY.find((e) => e.key === "zapier") as AppGalleryEntry;
|
||||
const GALLERY: AppGalleryEntry[] = [ZAPIER];
|
||||
const ZAPIER = CONNECTABLE_APP_DEFINITIONS.find((e) => e.slug === "zapier") as AppDefinition;
|
||||
const GALLERY: AppDefinition[] = [ZAPIER];
|
||||
|
||||
function seededClient() {
|
||||
const c = new QueryClient({
|
||||
|
||||
Reference in new issue
Block a user