Skip to content
217 linesCodeBlameRaw
1/**
2 * One connector's page: what it is and where it came from, how it is
3 * doing, who signed it in and when, the tools it lists (each a read or a
4 * write), which agents it reaches with the control that changes that, and
5 * what an owner does with it.
6 */
7import { ArrowLeft, ExternalLink } from "lucide-react";
8import { Link, data, useSearchParams } from "react-router";
9
10import type { SkillLibrary, WorkspaceConnector } from "@g1t/contracts";
11import { connectorById } from "@g1t/contracts/connectors";
12import { customizePath } from "@g1t/contracts/customize";
13
14import type { Route } from "./+types/connector";
15import { useCustomize } from "./layout";
16import { agentsAction, readOrNull } from "../../../components/agents/actions.server";
17import type { ActionResult } from "../../../components/agents/dialogs";
18import { NoAnswer, SectionHead, StateBadge } from "../../../components/customize";
19import { ConnectorMenu, ConnectorReach, SetKeyDialog, WorkspaceConnectorMark } from "../../../components/customize-connector";
20import { TimeAgo } from "../../../components/ui";
21import { Alert } from "../../../components/ui/alert";
22import { Badge } from "../../../components/ui/badge";
23import { Button } from "../../../components/ui/button";
24import { Card } from "../../../components/ui/card";
25import { AUTH_WORDS, type ReachTargets, flashOf, fromWords, pluginPath } from "../../../lib/customize";
26import { connectorAction } from "../../../lib/customize.server";
27import { page } from "../../../lib/meta";
28import { skillLibrary, workspaceAgents } from "../../../lib/services.server";
29import { requireUser, roleIn } from "../../../lib/session.server";
30
31export function meta({ params, ...args }: Route.MetaArgs) {
32 return page(args, { title: `${args.loaderData?.connector?.title ?? "Connector"} · Customize · ${params.owner} · g1t` });
33}
34
35export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ connector: WorkspaceConnector | null; targets: ReachTargets | null }> {
36 const viewer = requireUser(context, request);
37 const slug = params.owner.toLowerCase();
38 const role = roleIn(viewer, slug);
39 if (!role) throw data(null, { status: 404 });
40 const [found, library] = await Promise.all([
41 workspaceAgents.connector(slug, viewer, params.id).catch(() => null),
42 role === "owner" ? readOrNull(skillLibrary.library(slug, viewer)) : Promise.resolve(null as SkillLibrary | null),
43 ]);
44 if (found && !found.ok) throw data(null, { status: found.error.code === "not_found" ? 404 : 502 });
45 return { connector: found?.value ?? null, targets: library ? { can_manage: true, teams: library.teams, agents: library.agents } : null };
46}
47
48export async function action({ params, context, request }: Route.ActionArgs): Promise<Response | ActionResult> {
49 const { viewer, slug, form } = await agentsAction(request, context, params.owner);
50 const intent = String(form.get("intent") ?? "");
51 return (await connectorAction(intent, form, slug, viewer, customizePath("connectors", slug))) ?? { ok: false, intent, error: "Unknown request." };
52}
53
54export default function ConnectorPage({ loaderData }: Route.ComponentProps) {
55 const { slug, owner } = useCustomize();
56 const { connector, targets } = loaderData;
57 const [params] = useSearchParams();
58 const flash = flashOf(params);
59 const back = (
60 <Link to={customizePath("connectors", slug)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
61 <ArrowLeft size={14} />
62 Connectors
63 </Link>
64 );
65 if (!connector) {
66 return (
67 <div className="space-y-6">
68 {back}
69 <NoAnswer what="This connector" />
70 </div>
71 );
72 }
73 const entry = connector.from.kind === "directory" ? connectorById(connector.from.id) : null;
74 const caveat = entry?.mcp?.caveat ?? null;
75 return (
76 <div className="space-y-8">
77 {back}
78 {flash && (
79 <Alert tone={flash.tone} asChild>
80 <p role="alert">{flash.text}</p>
81 </Alert>
82 )}
83 <header className="flex flex-wrap items-start gap-4">
84 <WorkspaceConnectorMark connector={connector} size={56} />
85 <div className="min-w-0 grow basis-60">
86 <div className="flex flex-wrap items-center gap-2">
87 <h2 className="text-xl font-semibold tracking-tight">{connector.title}</h2>
88 <StateBadge state={connector.state} problem={connector.problem} />
89 </div>
90 <p className="mt-1 flex flex-wrap items-center gap-x-2 text-sm text-muted">
91 <a href={connector.url} target="_blank" rel="noreferrer" className="inline-flex min-w-0 items-center gap-1 truncate font-mono text-xs hover:text-fg">
92 {connector.url}
93 <ExternalLink size={11} aria-hidden />
94 </a>
95 <span aria-hidden="true" className="text-faint">
96 ·
97 </span>
98 <span>{AUTH_WORDS[connector.auth]}</span>
99 <span aria-hidden="true" className="text-faint">
100 ·
101 </span>
102 {connector.from.kind === "plugin" ? (
103 <Link to={pluginPath(slug, connector.from.ref)} className="hover:text-fg hover:underline">
104 {fromWords(connector.from)}
105 </Link>
106 ) : (
107 <span>{fromWords(connector.from)}</span>
108 )}
109 </p>
110 {connector.about && <p className="mt-2 max-w-3xl text-sm text-fg-soft">{connector.about}</p>}
111 </div>
112 {owner && (
113 <div className="flex shrink-0 items-center gap-2">
114 {connector.state === "needs_key" && (
115 <SetKeyDialog
116 connector={connector}
117 caveat={caveat}
118 trigger={
119 <Button type="button" variant="accent" size="sm">
120 Set the key
121 </Button>
122 }
123 />
124 )}
125 <ConnectorMenu connector={connector} caveat={caveat} />
126 </div>
127 )}
128 </header>
129 {connector.problem && (
130 <Alert tone="warn" asChild>
131 <p>{connector.problem}</p>
132 </Alert>
133 )}
134 {caveat && connector.state !== "connected" && (
135 <Alert tone="info" asChild>
136 <p>{caveat}</p>
137 </Alert>
138 )}
139
140 <div className="grid gap-8 lg:grid-cols-[minmax(0,1fr)_20rem]">
141 <section aria-labelledby="tools" className="min-w-0">
142 <SectionHead id="tools" title="Tools" aside={connector.checked_at ? <span>Listed <TimeAgo at={connector.checked_at} /></span> : undefined}>
143 Each becomes an ability of the agents that have it. A tool that doesn&apos;t say it only reads counts as a write, which asks first unless an owner says otherwise.
144 </SectionHead>
145 {connector.tools.length === 0 ? (
146 <Card asChild tone="plain" className="border-dashed px-4 py-6 text-center text-sm text-muted">
147 <p>{connector.state === "connected" ? "It listed no tools." : "Its tools are listed once it is connected."}</p>
148 </Card>
149 ) : (
150 <Card asChild divided className="overflow-hidden">
151 <ul>
152 {connector.tools.map((tool) => (
153 <li key={tool.name} className="flex items-start gap-3 px-4 py-3">
154 <div className="min-w-0 grow">
155 <p className="flex flex-wrap items-center gap-2">
156 <span className="font-mono text-sm font-medium">{tool.name}</span>
157 <Badge tone={tool.kind === "read" ? "neutral" : "warn"}>{tool.kind === "read" ? "Reads" : "Writes"}</Badge>
158 </p>
159 {tool.description && <p className="mt-0.5 text-sm text-muted">{tool.description}</p>}
160 </div>
161 </li>
162 ))}
163 </ul>
164 </Card>
165 )}
166 </section>
167
168 <aside className="space-y-4 text-sm">
169 <Card asChild className="space-y-3 p-4">
170 <section aria-labelledby="reach">
171 <h3 id="reach" className="text-xs text-faint">
172 Available to
173 </h3>
174 <ConnectorReach connector={connector} targets={targets} size="sm" />
175 <p className="text-xs text-muted">Every agent, a team&apos;s agents, or one agent, the way skills are attached. Each agent&apos;s Abilities tab sets the level of every tool.</p>
176 </section>
177 </Card>
178 <Card asChild className="space-y-3 p-4">
179 <dl>
180 {connector.auth === "oauth" && (
181 <div>
182 <dt className="text-xs text-faint">Signed in</dt>
183 <dd className="mt-0.5">
184 {connector.signed_in_at ? (
185 <>
186 {connector.signed_in_as ? `As ${connector.signed_in_as}, ` : ""}
187 <TimeAgo at={connector.signed_in_at} />
188 </>
189 ) : (
190 "Not yet"
191 )}
192 </dd>
193 </div>
194 )}
195 {connector.auth === "header" && (
196 <div>
197 <dt className="text-xs text-faint">Key</dt>
198 <dd className="mt-0.5">{connector.state === "needs_key" ? "None yet" : `Sent as ${connector.header_name ?? "Authorization"}; its value is never shown.`}</dd>
199 </div>
200 )}
201 <div>
202 <dt className="text-xs text-faint">Name to agents</dt>
203 <dd className="mt-0.5 font-mono text-xs">{connector.name}__&lt;tool&gt;</dd>
204 </div>
205 <div>
206 <dt className="text-xs text-faint">Added</dt>
207 <dd className="mt-0.5">
208 By @{connector.added_by} · <TimeAgo at={connector.added_at} />
209 </dd>
210 </div>
211 </dl>
212 </Card>
213 </aside>
214 </div>
215 </div>
216 );
217}