Mirror the Composio project's auth configs as the connector catalog with logo.dev icons
This commit is contained in:
1 parent
dde449d226
commit
00e05054ca
18 files changed
+269
-175
No files matched your search
@@ -329,16 +329,16 @@ export function registerIpcHandlers(deps: IpcDeps): void {
|
||||
const composioConnections = new ComposioConnectionService(deps.connection.db);
|
||||
const connectorGrants = new ConnectorGrantsService(deps.connection.db);
|
||||
|
||||
ipcMain.handle(IPC_CHANNELS.listComposioToolkits, async () => {
|
||||
ipcMain.handle(IPC_CHANNELS.listConnectorCatalog, async () => {
|
||||
try {
|
||||
return ok(await composioConnections.listToolkits());
|
||||
return ok(await composioConnections.listCatalog());
|
||||
} catch (error) {
|
||||
return fail("composio_toolkits_failed", error instanceof Error ? error.message : String(error));
|
||||
return fail("composio_catalog_failed", error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
});
|
||||
ipcMain.handle(IPC_CHANNELS.connectComposioToolkit, async (_event, input) => {
|
||||
try {
|
||||
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug, input.toolkitName);
|
||||
const result = await composioConnections.connectToolkit(input.orgId, input.toolkitSlug);
|
||||
notifyDataChanged();
|
||||
return ok(result);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Auth configs are configured in the Composio dashboard and mirrored through
|
||||
* the API at runtime — the app no longer creates them, so the create-once
|
||||
* auth_config:* cache rows in composio_settings are never read again and are
|
||||
* purged. entity_id rows stay: connected-account calls still use them.
|
||||
*/
|
||||
export const authConfigPurgeSchema = `
|
||||
delete from composio_settings where key like 'auth_config:%';
|
||||
`;
|
||||
@@ -45,6 +45,7 @@ import { composioIntegrationSchema } from "./migrations/043-composio-integration
|
||||
import { mentionWakeStateSchema } from "./migrations/044-mention-wake-state";
|
||||
import { connectorGrantsSchema } from "./migrations/045-connector-grants";
|
||||
import { composioKeyPurgeSchema } from "./migrations/046-composio-key-purge";
|
||||
import { authConfigPurgeSchema } from "./migrations/047-auth-config-purge";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -99,6 +100,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 44, name: "mention_wake_state", sql: mentionWakeStateSchema },
|
||||
{ version: 45, name: "connector_grants", sql: connectorGrantsSchema },
|
||||
{ version: 46, name: "composio_key_purge", sql: composioKeyPurgeSchema },
|
||||
{ version: 47, name: "auth_config_purge", sql: authConfigPurgeSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -13,15 +13,11 @@ export interface ComposioToolSchema {
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComposioToolkitSummary {
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
categories: string[];
|
||||
authSchemes: string[];
|
||||
noAuth: boolean;
|
||||
toolsCount: number;
|
||||
export interface ComposioAuthConfigSummary {
|
||||
id: string;
|
||||
toolkitSlug: string;
|
||||
authScheme: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface ComposioConnectedAccount {
|
||||
@@ -40,34 +36,28 @@ export interface ComposioToolExecutionResult {
|
||||
export class ComposioClient {
|
||||
constructor(private readonly apiKey: string) {}
|
||||
|
||||
/** The toolkit catalog, most popular first. */
|
||||
async listToolkits(limit: number): Promise<ComposioToolkitSummary[]> {
|
||||
const toolkits: ComposioToolkitSummary[] = [];
|
||||
/** Every auth config in the Composio project — the connector catalog. */
|
||||
async listAuthConfigs(): Promise<ComposioAuthConfigSummary[]> {
|
||||
const configs: ComposioAuthConfigSummary[] = [];
|
||||
let cursor: string | null = null;
|
||||
while (toolkits.length < limit) {
|
||||
const params = new URLSearchParams({ limit: String(Math.min(100, limit - toolkits.length)) });
|
||||
do {
|
||||
const params = new URLSearchParams({ limit: "100" });
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
const response = await this.request("GET", `${BASE_URL}/toolkits?${params.toString()}`);
|
||||
const response = await this.request("GET", `${BASE_URL}/auth_configs?${params.toString()}`);
|
||||
const items = Array.isArray(response.items) ? response.items as Array<Record<string, unknown>> : [];
|
||||
for (const item of items) {
|
||||
const meta = (item.meta ?? {}) as Record<string, unknown>;
|
||||
toolkits.push({
|
||||
slug: String(item.slug ?? ""),
|
||||
name: String(item.name ?? ""),
|
||||
description: String(meta.description ?? "").slice(0, 200),
|
||||
logo: typeof meta.logo === "string" ? meta.logo : null,
|
||||
categories: Array.isArray(meta.categories)
|
||||
? (meta.categories as Array<Record<string, unknown>>).map((c) => String(c.name ?? c.id ?? "")).filter(Boolean)
|
||||
: [],
|
||||
authSchemes: Array.isArray(item.auth_schemes) ? (item.auth_schemes as unknown[]).map(String) : [],
|
||||
noAuth: item.no_auth === true,
|
||||
toolsCount: Number(meta.tools_count ?? 0),
|
||||
const toolkit = (item.toolkit ?? {}) as Record<string, unknown>;
|
||||
configs.push({
|
||||
id: String(item.id ?? ""),
|
||||
toolkitSlug: String(toolkit.slug ?? "").toLowerCase(),
|
||||
authScheme: String(item.auth_scheme ?? ""),
|
||||
status: String(item.status ?? ""),
|
||||
});
|
||||
}
|
||||
cursor = typeof response.next_cursor === "string" && response.next_cursor ? response.next_cursor : null;
|
||||
if (!cursor || items.length === 0) break;
|
||||
}
|
||||
return toolkits;
|
||||
if (items.length === 0) break;
|
||||
} while (cursor);
|
||||
return configs;
|
||||
}
|
||||
|
||||
async getTools(toolkits: string[]): Promise<ComposioToolSchema[]> {
|
||||
@@ -83,17 +73,6 @@ export class ComposioClient {
|
||||
return all;
|
||||
}
|
||||
|
||||
/** Creates a Composio-managed auth config for a toolkit. */
|
||||
async createAuthConfig(toolkitSlug: string): Promise<string> {
|
||||
const response = await this.request("POST", `${BASE_URL}/auth_configs`, {
|
||||
toolkit: { slug: toolkitSlug },
|
||||
});
|
||||
const authConfig = (response.auth_config ?? {}) as Record<string, unknown>;
|
||||
const id = String(authConfig.id ?? "");
|
||||
if (!id) throw new Error("Composio auth config creation returned no id.");
|
||||
return id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts a connection for a user; the redirect URL completes it in the
|
||||
* browser. Composio-managed auth configs only accept the link flow —
|
||||
|
||||
@@ -1,42 +1,44 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
import type { ComposioToolkitSummaryDto, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient, type ComposioToolkitSummary } from "./composio-client";
|
||||
import type { ConnectorCatalogEntry, ConnectComposioToolkitResult } from "../../../shared/commands";
|
||||
import { ComposioClient } from "./composio-client";
|
||||
import { composioApiKey } from "./composio-credentials";
|
||||
import { getComposioUserId } from "./composio-tool-bridge";
|
||||
import { connectorDisplayName, connectorLogoUrl } from "./connector-brands";
|
||||
import { normalizeSlug } from "./connector-grants";
|
||||
|
||||
const TOOLKIT_CATALOG_LIMIT = 200;
|
||||
const CATALOG_TTL_MS = 10 * 60 * 1000;
|
||||
|
||||
let catalogCache: { fetchedAt: number; toolkits: ComposioToolkitSummary[] } | null = null;
|
||||
let catalogCache: { fetchedAt: number; entries: ConnectorCatalogEntry[] } | null = null;
|
||||
|
||||
/**
|
||||
* Company-level Composio connections. One connected account per toolkit per
|
||||
* organization, mirrored into connector_accounts so agent runs and the
|
||||
* Connectors page read local state instead of calling out on every frame.
|
||||
* Company-level Composio connections. The catalog mirrors the auth configs
|
||||
* of the Composio project — the dashboard is the source of truth for which
|
||||
* connectors exist; the app never creates auth configs, it only connects
|
||||
* accounts against them. One connected account per toolkit per organization,
|
||||
* mirrored into connector_accounts so agent runs and the Connectors page
|
||||
* read local state instead of calling out on every frame.
|
||||
*/
|
||||
export class ComposioConnectionService {
|
||||
constructor(private readonly db: DatabaseSync) {}
|
||||
|
||||
listToolkits(): Promise<ComposioToolkitSummaryDto[]> {
|
||||
const client = this.requireClient();
|
||||
return this.catalog(client);
|
||||
listCatalog(): Promise<ConnectorCatalogEntry[]> {
|
||||
return this.catalog(this.requireClient());
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts (or restarts) the company's connection for a toolkit: creates a
|
||||
* Composio-managed auth config once per toolkit, initiates the connected
|
||||
* account for the company user via the link flow, and records the pending
|
||||
* account locally as INITIALIZING — the poll that follows adopts Composio's
|
||||
* real status. The returned redirect URL completes authorization in the
|
||||
* browser.
|
||||
* Starts (or restarts) the company's connection for a toolkit: initiates a
|
||||
* connected account against the toolkit's configured auth config via the
|
||||
* link flow and records the pending account locally as INITIALIZING — the
|
||||
* poll that follows adopts Composio's real status. The returned redirect
|
||||
* URL completes authorization (or API-key entry) in the browser.
|
||||
*/
|
||||
async connectToolkit(orgId: string, toolkitSlug: string, toolkitName: string): Promise<ConnectComposioToolkitResult> {
|
||||
async connectToolkit(orgId: string, toolkitSlug: string): Promise<ConnectComposioToolkitResult> {
|
||||
const slug = normalizeSlug(toolkitSlug);
|
||||
const client = this.requireClient();
|
||||
const authConfigId = await this.ensureAuthConfig(client, orgId, slug);
|
||||
const entry = (await this.catalog(client)).find((item) => item.slug === slug);
|
||||
if (!entry) throw new Error(`No auth config for "${slug}" exists in the Composio project.`);
|
||||
const account = await client.createConnectedAccount({
|
||||
authConfigId,
|
||||
authConfigId: entry.authConfigId,
|
||||
userId: getComposioUserId(this.db, orgId),
|
||||
});
|
||||
if (!account.id) throw new Error("Composio did not return a connected account id.");
|
||||
@@ -50,7 +52,7 @@ export class ComposioConnectionService {
|
||||
auth_config_id = excluded.auth_config_id,
|
||||
status = excluded.status,
|
||||
updated_at = excluded.updated_at
|
||||
`).run(account.id, orgId, slug, toolkitName.trim() || slug, authConfigId, "INITIALIZING", now, now);
|
||||
`).run(account.id, orgId, slug, entry.name, entry.authConfigId, "INITIALIZING", now, now);
|
||||
return { accountId: account.id, redirectUrl: account.redirectUrl };
|
||||
}
|
||||
|
||||
@@ -73,7 +75,7 @@ export class ComposioConnectionService {
|
||||
account.id,
|
||||
orgId,
|
||||
account.toolkitSlug,
|
||||
account.toolkitSlug,
|
||||
connectorDisplayName(account.toolkitSlug),
|
||||
account.authConfigId,
|
||||
account.status,
|
||||
account.createdAt || now,
|
||||
@@ -88,27 +90,24 @@ export class ComposioConnectionService {
|
||||
return new ComposioClient(apiKey);
|
||||
}
|
||||
|
||||
private async ensureAuthConfig(client: ComposioClient, orgId: string, slug: string): Promise<string> {
|
||||
const key = `auth_config:${slug}`;
|
||||
const existing = this.db.prepare(
|
||||
"select value from composio_settings where org_id = ? and key = ? limit 1",
|
||||
).get(orgId, key) as { value: string } | undefined;
|
||||
if (existing?.value) return existing.value;
|
||||
const authConfigId = await client.createAuthConfig(slug);
|
||||
this.db.prepare(`
|
||||
insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)
|
||||
on conflict(org_id, key) do update set value = excluded.value, updated_at = excluded.updated_at
|
||||
`).run(orgId, key, authConfigId, new Date().toISOString());
|
||||
return authConfigId;
|
||||
}
|
||||
|
||||
private async catalog(client: ComposioClient): Promise<ComposioToolkitSummaryDto[]> {
|
||||
private async catalog(client: ComposioClient): Promise<ConnectorCatalogEntry[]> {
|
||||
if (catalogCache && Date.now() - catalogCache.fetchedAt < CATALOG_TTL_MS) {
|
||||
return catalogCache.toolkits;
|
||||
return catalogCache.entries;
|
||||
}
|
||||
const toolkits = await client.listToolkits(TOOLKIT_CATALOG_LIMIT);
|
||||
catalogCache = { fetchedAt: Date.now(), toolkits };
|
||||
return toolkits;
|
||||
const configs = await client.listAuthConfigs();
|
||||
const bySlug = new Map<string, ConnectorCatalogEntry>();
|
||||
for (const config of configs) {
|
||||
if (config.status !== "ENABLED" || !config.toolkitSlug || bySlug.has(config.toolkitSlug)) continue;
|
||||
bySlug.set(config.toolkitSlug, {
|
||||
authConfigId: config.id,
|
||||
slug: config.toolkitSlug,
|
||||
name: connectorDisplayName(config.toolkitSlug),
|
||||
authScheme: config.authScheme,
|
||||
logoUrl: connectorLogoUrl(config.toolkitSlug),
|
||||
});
|
||||
}
|
||||
const entries = [...bySlug.values()].sort((a, b) => a.name.localeCompare(b.name));
|
||||
catalogCache = { fetchedAt: Date.now(), entries };
|
||||
return entries;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* Display names and logo domains for the toolkits configured in the Composio
|
||||
* project. Logos come from img.logo.dev keyed by company domain (publishable
|
||||
* token — safe to embed). A slug outside this table renders with a prettified
|
||||
* name and the letter tile until its brand row is added here.
|
||||
*/
|
||||
const LOGO_DEV_TOKEN = "***REMOVED***";
|
||||
|
||||
const BRANDS: Record<string, { name: string; domain: string }> = {
|
||||
airtable: { name: "Airtable", domain: "airtable.com" },
|
||||
attio: { name: "Attio", domain: "attio.com" },
|
||||
cal: { name: "Cal.com", domain: "cal.com" },
|
||||
calendly: { name: "Calendly", domain: "calendly.com" },
|
||||
canva: { name: "Canva", domain: "canva.com" },
|
||||
cloudflare: { name: "Cloudflare", domain: "cloudflare.com" },
|
||||
discord: { name: "Discord", domain: "discord.com" },
|
||||
elevenlabs: { name: "ElevenLabs", domain: "elevenlabs.io" },
|
||||
figma: { name: "Figma", domain: "figma.com" },
|
||||
firecrawl: { name: "Firecrawl", domain: "firecrawl.dev" },
|
||||
github: { name: "GitHub", domain: "github.com" },
|
||||
gmail: { name: "Gmail", domain: "gmail.com" },
|
||||
google_analytics: { name: "Google Analytics", domain: "analytics.google.com" },
|
||||
googleads: { name: "Google Ads", domain: "ads.google.com" },
|
||||
googlecalendar: { name: "Google Calendar", domain: "calendar.google.com" },
|
||||
googledocs: { name: "Google Docs", domain: "docs.google.com" },
|
||||
googledrive: { name: "Google Drive", domain: "drive.google.com" },
|
||||
googlesheets: { name: "Google Sheets", domain: "sheets.google.com" },
|
||||
googletasks: { name: "Google Tasks", domain: "tasks.google.com" },
|
||||
heygen: { name: "HeyGen", domain: "heygen.com" },
|
||||
junglescout: { name: "Jungle Scout", domain: "junglescout.com" },
|
||||
linear: { name: "Linear", domain: "linear.app" },
|
||||
linkedin: { name: "LinkedIn", domain: "linkedin.com" },
|
||||
mailchimp: { name: "Mailchimp", domain: "mailchimp.com" },
|
||||
monday: { name: "Monday", domain: "monday.com" },
|
||||
notion: { name: "Notion", domain: "notion.so" },
|
||||
one_drive: { name: "OneDrive", domain: "onedrive.com" },
|
||||
outlook: { name: "Outlook", domain: "outlook.com" },
|
||||
perplexityai: { name: "Perplexity", domain: "perplexity.ai" },
|
||||
reddit: { name: "Reddit", domain: "reddit.com" },
|
||||
salesforce: { name: "Salesforce", domain: "salesforce.com" },
|
||||
sentry: { name: "Sentry", domain: "sentry.io" },
|
||||
share_point: { name: "SharePoint", domain: "sharepoint.com" },
|
||||
shopify: { name: "Shopify", domain: "shopify.com" },
|
||||
slack: { name: "Slack", domain: "slack.com" },
|
||||
stripe: { name: "Stripe", domain: "stripe.com" },
|
||||
supabase: { name: "Supabase", domain: "supabase.com" },
|
||||
tavily: { name: "Tavily", domain: "tavily.com" },
|
||||
twitter: { name: "X", domain: "x.com" },
|
||||
whatsapp: { name: "WhatsApp", domain: "whatsapp.com" },
|
||||
zoho: { name: "Zoho", domain: "zoho.com" },
|
||||
};
|
||||
|
||||
export function connectorDisplayName(slug: string): string {
|
||||
const brand = BRANDS[slug];
|
||||
if (brand) return brand.name;
|
||||
return slug
|
||||
.split(/[_-]+/)
|
||||
.filter(Boolean)
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export function connectorLogoUrl(slug: string): string | null {
|
||||
const brand = BRANDS[slug];
|
||||
if (!brand) return null;
|
||||
return `https://img.logo.dev/${brand.domain}?token=${LOGO_DEV_TOKEN}`;
|
||||
}
|
||||
@@ -83,7 +83,7 @@ const api: DesktopApi = {
|
||||
setLegalHoldState: (input) => invoke(IPC_CHANNELS.setLegalHoldState, input),
|
||||
createRetentionFreeze: (input) => invoke(IPC_CHANNELS.createRetentionFreeze, input),
|
||||
setRetentionFreezeState: (input) => invoke(IPC_CHANNELS.setRetentionFreezeState, input),
|
||||
listComposioToolkits: (input) => invoke(IPC_CHANNELS.listComposioToolkits, input),
|
||||
listConnectorCatalog: () => invoke(IPC_CHANNELS.listConnectorCatalog),
|
||||
connectComposioToolkit: (input) => invoke(IPC_CHANNELS.connectComposioToolkit, input),
|
||||
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
|
||||
grantConnector: (input) => invoke(IPC_CHANNELS.grantConnector, input),
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
http-equiv="Content-Security-Policy"
|
||||
content="default-src 'self'; script-src 'self' http://localhost:*; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self' data:; connect-src 'self' http://localhost:* ws://localhost:*; object-src 'none'; base-uri 'none'; frame-src 'none';"
|
||||
content="default-src 'self'; script-src 'self' http://localhost:*; style-src 'self' 'unsafe-inline'; img-src 'self' data: https://img.logo.dev; font-src 'self' data:; connect-src 'self' http://localhost:* ws://localhost:*; object-src 'none'; base-uri 'none'; frame-src 'none';"
|
||||
/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>AgentCompany</title>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button } from "../shared/ui";
|
||||
import type { ComposioToolkitSummaryDto } from "../../../shared/commands";
|
||||
import type { ConnectorCatalogEntry } from "../../../shared/commands";
|
||||
import type { PageProps } from "./types";
|
||||
import { activeOrgId, requireActiveEmployee } from "./types";
|
||||
import { ConnectedList } from "./connectors/ConnectedList";
|
||||
@@ -8,7 +8,7 @@ import { ToolkitCatalog } from "./connectors/ToolkitCatalog";
|
||||
|
||||
type CatalogState =
|
||||
| { phase: "loading" }
|
||||
| { phase: "ready"; toolkits: ComposioToolkitSummaryDto[] }
|
||||
| { phase: "ready"; entries: ConnectorCatalogEntry[] }
|
||||
| { phase: "error"; message: string };
|
||||
|
||||
type CatalogSlot = { orgId: string; state: CatalogState } | null;
|
||||
@@ -47,14 +47,22 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
[accounts],
|
||||
);
|
||||
|
||||
const logoBySlug = useMemo(() => {
|
||||
const map = new Map<string, string | null>();
|
||||
if (catalog.phase === "ready") {
|
||||
for (const entry of catalog.entries) map.set(entry.slug, entry.logoUrl);
|
||||
}
|
||||
return map;
|
||||
}, [catalog]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
|
||||
void window.agentCompany.listConnectorCatalog().then((result) => {
|
||||
if (cancelled) return;
|
||||
setCatalogSlot({
|
||||
orgId,
|
||||
state: result.ok
|
||||
? { phase: "ready", toolkits: result.data }
|
||||
? { phase: "ready", entries: result.data }
|
||||
: { phase: "error", message: result.error.message },
|
||||
});
|
||||
});
|
||||
@@ -89,17 +97,17 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
}, CONNECT_POLL_INTERVAL_MS);
|
||||
};
|
||||
|
||||
const connect = (toolkit: ComposioToolkitSummaryDto) => {
|
||||
setBusySlug(toolkit.slug);
|
||||
const connect = (entry: ConnectorCatalogEntry) => {
|
||||
setBusySlug(entry.slug);
|
||||
setConnectError(null);
|
||||
void window.agentCompany
|
||||
.connectComposioToolkit({ orgId, toolkitSlug: toolkit.slug, toolkitName: toolkit.name })
|
||||
.connectComposioToolkit({ orgId, toolkitSlug: entry.slug })
|
||||
.then(async (result) => {
|
||||
if (!result.ok) {
|
||||
setConnectError(result.error.message);
|
||||
return;
|
||||
}
|
||||
pollUntilActive(toolkit.slug, 0);
|
||||
pollUntilActive(entry.slug, 0);
|
||||
if (result.data.redirectUrl) {
|
||||
const opened = await window.agentCompany.openExternalUrl(result.data.redirectUrl);
|
||||
if (!opened.ok) setConnectError(opened.error.message);
|
||||
@@ -133,11 +141,15 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConnectedList accounts={accounts} grants={grants} agents={agents} onGrant={grant} onRevoke={revoke} />
|
||||
<ConnectedList
|
||||
accounts={accounts}
|
||||
grants={grants}
|
||||
agents={agents}
|
||||
logoBySlug={logoBySlug}
|
||||
onGrant={grant}
|
||||
onRevoke={revoke}
|
||||
/>
|
||||
|
||||
{catalog.phase === "loading" && (
|
||||
<div style={{ color: "var(--text-3)", fontSize: 13, padding: "24px 0" }}>Loading catalog…</div>
|
||||
)}
|
||||
{catalog.phase === "error" && (
|
||||
<div className="row" style={{ gap: 10, padding: "24px 0" }}>
|
||||
<span
|
||||
@@ -155,11 +167,11 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
</span>
|
||||
<Button size="small" onClick={() => {
|
||||
setCatalogSlot(null);
|
||||
void window.agentCompany.listComposioToolkits({ orgId }).then((result) => {
|
||||
void window.agentCompany.listConnectorCatalog().then((result) => {
|
||||
setCatalogSlot({
|
||||
orgId,
|
||||
state: result.ok
|
||||
? { phase: "ready", toolkits: result.data }
|
||||
? { phase: "ready", entries: result.data }
|
||||
: { phase: "error", message: result.error.message },
|
||||
});
|
||||
});
|
||||
@@ -168,7 +180,7 @@ export function ConnectorsPage({ snapshot, actions }: PageProps) {
|
||||
)}
|
||||
{catalog.phase === "ready" && (
|
||||
<ToolkitCatalog
|
||||
toolkits={catalog.toolkits}
|
||||
entries={catalog.entries}
|
||||
connectedSlugs={connectedSlugs}
|
||||
busySlug={busySlug}
|
||||
onConnect={connect}
|
||||
|
||||
@@ -20,12 +20,14 @@ export function ConnectedList({
|
||||
accounts,
|
||||
grants,
|
||||
agents,
|
||||
logoBySlug,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
accounts: ConnectorAccountRecord[];
|
||||
grants: ConnectorGrantRecord[];
|
||||
agents: AgentRecord[];
|
||||
logoBySlug: Map<string, string | null>;
|
||||
onGrant: (agentId: string, toolkitSlug: string) => void;
|
||||
onRevoke: (agentId: string, toolkitSlug: string) => void;
|
||||
}) {
|
||||
@@ -40,6 +42,7 @@ export function ConnectedList({
|
||||
account={account}
|
||||
grantedAgentIds={new Set(grants.filter((grant) => grant.toolkitSlug === account.toolkitSlug).map((grant) => grant.agentId))}
|
||||
agents={agents}
|
||||
logoUrl={logoBySlug.get(account.toolkitSlug) ?? null}
|
||||
onGrant={onGrant}
|
||||
onRevoke={onRevoke}
|
||||
/>
|
||||
@@ -53,12 +56,14 @@ function ConnectedRow({
|
||||
account,
|
||||
grantedAgentIds,
|
||||
agents,
|
||||
logoUrl,
|
||||
onGrant,
|
||||
onRevoke,
|
||||
}: {
|
||||
account: ConnectorAccountRecord;
|
||||
grantedAgentIds: Set<string>;
|
||||
agents: AgentRecord[];
|
||||
logoUrl: string | null;
|
||||
onGrant: (agentId: string, toolkitSlug: string) => void;
|
||||
onRevoke: (agentId: string, toolkitSlug: string) => void;
|
||||
}) {
|
||||
@@ -66,7 +71,7 @@ function ConnectedRow({
|
||||
return (
|
||||
<div style={{ border: "1px solid var(--border)", borderRadius: 12, padding: 14, background: "var(--panel)" }}>
|
||||
<div className="row" style={{ gap: 10 }}>
|
||||
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} size={30} />
|
||||
<ToolkitTile name={account.toolkitName} slug={account.toolkitSlug} logoUrl={logoUrl} size={30} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5 }}>{account.toolkitName}</span>
|
||||
<Pill tone={STATUS_TONE[account.status]} dot>{account.status.charAt(0) + account.status.slice(1).toLowerCase()}</Pill>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { Button, Icon, Pill } from "../../shared/ui";
|
||||
import type { ComposioToolkitSummaryDto } from "../../../../shared/commands";
|
||||
import type { ConnectorCatalogEntry } from "../../../../shared/commands";
|
||||
|
||||
/** Deterministic tile color for a toolkit's letter glyph. */
|
||||
const TILE_COLORS = ["#5e6ad2", "#3b82a0", "#7c5cb0", "#3f8f5f", "#b0684f", "#a05c86", "#6f7d3d", "#4f6fb0"];
|
||||
@@ -11,7 +11,18 @@ function tileColor(slug: string): string {
|
||||
return TILE_COLORS[Math.abs(hash) % TILE_COLORS.length];
|
||||
}
|
||||
|
||||
export function ToolkitTile({ name, slug, size = 34 }: { name: string; slug: string; size?: number }) {
|
||||
export function ToolkitTile({ name, slug, logoUrl, size = 34 }: { name: string; slug: string; logoUrl: string | null; size?: number }) {
|
||||
if (logoUrl) {
|
||||
return (
|
||||
<img
|
||||
src={logoUrl}
|
||||
alt=""
|
||||
width={size}
|
||||
height={size}
|
||||
style={{ width: size, height: size, borderRadius: 9, objectFit: "cover", flexShrink: 0 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span
|
||||
style={{
|
||||
@@ -34,26 +45,22 @@ export function ToolkitTile({ name, slug, size = 34 }: { name: string; slug: str
|
||||
}
|
||||
|
||||
export function ToolkitCatalog({
|
||||
toolkits,
|
||||
entries,
|
||||
connectedSlugs,
|
||||
busySlug,
|
||||
onConnect,
|
||||
}: {
|
||||
toolkits: ComposioToolkitSummaryDto[];
|
||||
entries: ConnectorCatalogEntry[];
|
||||
connectedSlugs: Set<string>;
|
||||
busySlug: string | null;
|
||||
onConnect: (toolkit: ComposioToolkitSummaryDto) => void;
|
||||
onConnect: (entry: ConnectorCatalogEntry) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const filtered = useMemo(() => {
|
||||
const trimmed = query.trim().toLowerCase();
|
||||
if (!trimmed) return toolkits;
|
||||
return toolkits.filter((toolkit) =>
|
||||
toolkit.name.toLowerCase().includes(trimmed) ||
|
||||
toolkit.slug.includes(trimmed) ||
|
||||
toolkit.categories.some((category) => category.toLowerCase().includes(trimmed)),
|
||||
);
|
||||
}, [toolkits, query]);
|
||||
if (!trimmed) return entries;
|
||||
return entries.filter((entry) => entry.name.toLowerCase().includes(trimmed) || entry.slug.includes(trimmed));
|
||||
}, [entries, query]);
|
||||
|
||||
return (
|
||||
<section>
|
||||
@@ -70,12 +77,12 @@ export function ToolkitCatalog({
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(250px, 1fr))", gap: 10 }}>
|
||||
{filtered.map((toolkit) => {
|
||||
const connected = connectedSlugs.has(toolkit.slug);
|
||||
<div style={{ display: "grid", gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))", gap: 10 }}>
|
||||
{filtered.map((entry) => {
|
||||
const connected = connectedSlugs.has(entry.slug);
|
||||
return (
|
||||
<div
|
||||
key={toolkit.slug}
|
||||
key={entry.slug}
|
||||
style={{
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 12,
|
||||
@@ -83,46 +90,20 @@ export function ToolkitCatalog({
|
||||
background: "var(--panel)",
|
||||
display: "flex",
|
||||
gap: 10,
|
||||
alignItems: "flex-start",
|
||||
alignItems: "center",
|
||||
}}
|
||||
>
|
||||
<ToolkitTile name={toolkit.name} slug={toolkit.slug} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div className="row" style={{ gap: 6 }}>
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{toolkit.name}
|
||||
</span>
|
||||
{toolkit.categories[0] && <Pill>{toolkit.categories[0]}</Pill>}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: 12,
|
||||
color: "var(--text-3)",
|
||||
lineHeight: 1.45,
|
||||
marginTop: 2,
|
||||
display: "-webkit-box",
|
||||
WebkitLineClamp: 2,
|
||||
WebkitBoxOrient: "vertical",
|
||||
overflow: "hidden",
|
||||
minHeight: 34,
|
||||
}}
|
||||
>
|
||||
{toolkit.description}
|
||||
</div>
|
||||
<div className="row" style={{ marginTop: 8 }}>
|
||||
{connected ? (
|
||||
<Pill tone="green" dot>Connected</Pill>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
disabled={busySlug !== null}
|
||||
onClick={() => onConnect(toolkit)}
|
||||
>
|
||||
{busySlug === toolkit.slug ? "Opening…" : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ToolkitTile name={entry.name} slug={entry.slug} logoUrl={entry.logoUrl} />
|
||||
<span style={{ fontWeight: 600, fontSize: 13.5, flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
|
||||
{entry.name}
|
||||
</span>
|
||||
{connected ? (
|
||||
<Pill tone="green" dot>Connected</Pill>
|
||||
) : (
|
||||
<Button size="small" disabled={busySlug !== null} onClick={() => onConnect(entry)}>
|
||||
{busySlug === entry.slug ? "Opening…" : "Connect"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,22 +1,14 @@
|
||||
export interface ComposioToolkitSummaryDto {
|
||||
export interface ConnectorCatalogEntry {
|
||||
authConfigId: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
description: string;
|
||||
logo: string | null;
|
||||
categories: string[];
|
||||
authSchemes: string[];
|
||||
noAuth: boolean;
|
||||
toolsCount: number;
|
||||
}
|
||||
|
||||
export interface ListComposioToolkitsInput {
|
||||
orgId: string;
|
||||
authScheme: string;
|
||||
logoUrl: string | null;
|
||||
}
|
||||
|
||||
export interface ConnectComposioToolkitInput {
|
||||
orgId: string;
|
||||
toolkitSlug: string;
|
||||
toolkitName: string;
|
||||
}
|
||||
|
||||
export interface ConnectComposioToolkitResult {
|
||||
|
||||
@@ -64,11 +64,10 @@ import type {
|
||||
SyncSkillsFromDiskInput,
|
||||
RevokeProviderCredentialInput,
|
||||
SaveProviderCredentialInput,
|
||||
ComposioToolkitSummaryDto,
|
||||
ConnectComposioToolkitInput,
|
||||
ConnectComposioToolkitResult,
|
||||
ConnectorCatalogEntry,
|
||||
ConnectorGrantInput,
|
||||
ListComposioToolkitsInput,
|
||||
RefreshComposioAccountsInput,
|
||||
SearchInput,
|
||||
SendMessageInput,
|
||||
@@ -180,7 +179,7 @@ export interface DesktopApi {
|
||||
setLegalHoldState(input: SetLegalHoldStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
createRetentionFreeze(input: CreateRetentionFreezeInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
setRetentionFreezeState(input: SetRetentionFreezeStateInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
listComposioToolkits(input: ListComposioToolkitsInput): Promise<DesktopResult<ComposioToolkitSummaryDto[]>>;
|
||||
listConnectorCatalog(): Promise<DesktopResult<ConnectorCatalogEntry[]>>;
|
||||
connectComposioToolkit(input: ConnectComposioToolkitInput): Promise<DesktopResult<ConnectComposioToolkitResult>>;
|
||||
refreshComposioAccounts(input: RefreshComposioAccountsInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
grantConnector(input: ConnectorGrantInput): Promise<DesktopResult<AppSnapshot>>;
|
||||
|
||||
+1
-1
@@ -75,7 +75,7 @@ export const IPC_CHANNELS = {
|
||||
deleteMeeting: "agentcompany:delete-meeting",
|
||||
createSprint: "agentcompany:create-sprint",
|
||||
deleteSprint: "agentcompany:delete-sprint",
|
||||
listComposioToolkits: "agentcompany:list-composio-toolkits",
|
||||
listConnectorCatalog: "agentcompany:list-connector-catalog",
|
||||
connectComposioToolkit: "agentcompany:connect-composio-toolkit",
|
||||
refreshComposioAccounts: "agentcompany:refresh-composio-accounts",
|
||||
grantConnector: "agentcompany:grant-connector",
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||
import { authConfigPurgeSchema } from "../../src/main/database/migrations/047-auth-config-purge";
|
||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||
|
||||
describe("auth config purge migration", () => {
|
||||
test("removes cached auth_config rows but keeps the entity id", () => {
|
||||
const connection = new DatabaseConnection(join(tmpdir(), `ac-authconfig-purge-${randomUUID()}.db`));
|
||||
const organization = new OrganizationService(connection.db);
|
||||
let orgId = "";
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({ organizationName: "OrbitOps", employeeName: "Yang", mission: "m", goal: "g" });
|
||||
});
|
||||
const now = new Date().toISOString();
|
||||
const insert = connection.db.prepare(
|
||||
"insert into composio_settings(org_id, key, value, updated_at) values (?, ?, ?, ?)",
|
||||
);
|
||||
insert.run(orgId, "entity_id", orgId, now);
|
||||
insert.run(orgId, "auth_config:github", "ac_123", now);
|
||||
insert.run(orgId, "auth_config:stripe", "ac_456", now);
|
||||
|
||||
connection.db.exec(authConfigPurgeSchema);
|
||||
|
||||
const keys = (connection.db.prepare("select key from composio_settings where org_id = ? order by key asc").all(orgId) as Array<{ key: string }>)
|
||||
.map((row) => row.key);
|
||||
expect(keys).toEqual(["entity_id"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { connectorDisplayName, connectorLogoUrl } from "../../src/main/services/composio/connector-brands";
|
||||
|
||||
describe("connector brands", () => {
|
||||
test("known toolkits resolve a display name and a logo.dev URL", () => {
|
||||
expect(connectorDisplayName("google_analytics")).toBe("Google Analytics");
|
||||
expect(connectorLogoUrl("google_analytics")).toBe(
|
||||
"https://img.logo.dev/analytics.google.com?token=***REMOVED***",
|
||||
);
|
||||
expect(connectorDisplayName("share_point")).toBe("SharePoint");
|
||||
expect(connectorLogoUrl("stripe")).toContain("img.logo.dev/stripe.com");
|
||||
});
|
||||
|
||||
test("unknown toolkits prettify the slug and carry no logo", () => {
|
||||
expect(connectorDisplayName("acme_billing")).toBe("Acme Billing");
|
||||
expect(connectorLogoUrl("acme_billing")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46]);
|
||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47]);
|
||||
connection.close();
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user