Skip to content
186 linesCodeBlameRaw
1/**
2 * A connector's pieces, shared by Customize → Connectors and a connector's
3 * own page: its mark, the menu of what an owner does with it (sign in or
4 * out, a key, listing its tools again, removing it), the dialog that takes
5 * a key, and the control that says which agents it reaches. Every form
6 * posts to the page it is on; lib/customize.server.ts answers them.
7 */
8import { EllipsisVertical, KeyRound, LogIn, LogOut, RefreshCw, Trash2 } from "lucide-react";
9import { type ReactNode, useEffect, useId, useState } from "react";
10import { useFetcher } from "react-router";
11
12import type { WorkspaceConnector } from "@g1t/contracts";
13import { connectorById } from "@g1t/contracts/connectors";
14
15import { type ActionResult, Confirm } from "./agents/dialogs";
16import { ConnectorMark } from "./connectors";
17import { AvailableTo, ListingMark } from "./customize";
18import { SubmitButton } from "./ui";
19import { Button } from "./ui/button";
20import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
21import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
22import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field";
23import { Input } from "./ui/input";
24import { type Reach, type ReachOption, type ReachTargets } from "../lib/customize";
25
26/** A connector's mark: the directory entry's when it came from one, its own letters otherwise. */
27export function WorkspaceConnectorMark({ connector, size = 40 }: { connector: Pick<WorkspaceConnector, "id" | "title" | "from">; size?: number }) {
28 const entry = connector.from.kind === "directory" ? connectorById(connector.from.id) : null;
29 if (entry) return <ConnectorMark view={{ id: entry.id, name: entry.name, provider: entry.provider ?? null }} size={size} />;
30 return <ListingMark id={connector.id} name={connector.title} size={size} />;
31}
32
33/** Where a connector reaches, as the control reads it. */
34export function connectorReach(connector: Pick<WorkspaceConnector, "grants">): Reach[] {
35 return connector.grants.map((grant) => ({ id: grant.id, scope: grant.scope, target: grant.target, label: grant.label }));
36}
37
38/** Which agents a connector is available to, with the control that changes it for owners. */
39export function ConnectorReach({ connector, targets, size }: { connector: Pick<WorkspaceConnector, "id" | "grants">; targets: ReachTargets | null; size?: "xs" | "sm" }) {
40 const fetcher = useFetcher<ActionResult>({ key: `connector-reach-${connector.id}` });
41 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
42 const change = (option: ReachOption, checked: boolean) => {
43 if (checked) fetcher.submit({ intent: "grant", connector: connector.id, where: option.value }, { method: "post" });
44 else if (option.reach) fetcher.submit({ intent: "revoke", connector: connector.id, grant: option.reach.id }, { method: "post" });
45 };
46 return (
47 <>
48 <AvailableTo reach={connectorReach(connector)} targets={targets} busy={fetcher.state !== "idle"} onChange={change} size={size} />
49 {error && <p className="text-xs text-danger">{error}</p>}
50 </>
51 );
52}
53
54/**
55 * A key for a connector that takes one: the header's name (Authorization,
56 * usually) and its value, which is sealed and never shown again. `caveat`
57 * is what the directory knows about the key the server wants. With
58 * `connect` (a directory entry's id) it connects that entry with the key
59 * in one step, instead of changing a connector's.
60 */
61export function SetKeyDialog({
62 connector,
63 caveat,
64 connect,
65 trigger,
66 open,
67 onOpenChange,
68}: {
69 connector: Pick<WorkspaceConnector, "id" | "title" | "header_name">;
70 caveat?: string | null;
71 connect?: string;
72 trigger?: ReactNode;
73 open?: boolean;
74 onOpenChange?: (open: boolean) => void;
75}) {
76 const fetcher = useFetcher<ActionResult>({ key: `connector-key-${connector.id}` });
77 const [own, setOwn] = useState(false);
78 const isOpen = open ?? own;
79 const setOpen = onOpenChange ?? setOwn;
80 const id = useId();
81 useEffect(() => {
82 if (fetcher.state === "idle" && fetcher.data?.ok) setOpen(false);
83 }, [fetcher.state, fetcher.data, setOpen]);
84 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
85 const fields = connect ? { intent: "connect", directory: connect } : { intent: "key", connector: connector.id };
86 return (
87 <Dialog open={isOpen} onOpenChange={setOpen}>
88 {trigger && <DialogTrigger asChild>{trigger}</DialogTrigger>}
89 <DialogContent>
90 <DialogHeader>
91 <DialogTitle>{connect ? `Connect ${connector.title}` : `A key for ${connector.title}`}</DialogTitle>
92 <DialogDescription>Sent with every call as a header. It is sealed when saved and never shown again; paste a new one to replace it.</DialogDescription>
93 </DialogHeader>
94 {caveat && <p className="text-sm text-fg-soft">{caveat}</p>}
95 <fetcher.Form method="post" className="grid gap-5">
96 {Object.entries(fields).map(([name, value]) => (
97 <input key={name} type="hidden" name={name} value={value} />
98 ))}
99 <Field>
100 <FieldLabel htmlFor={`${id}-header`}>Header</FieldLabel>
101 <Input id={`${id}-header`} name="header_name" defaultValue={connector.header_name ?? "Authorization"} required autoComplete="off" spellCheck={false} className="font-mono" />
102 </Field>
103 <Field>
104 <FieldLabel htmlFor={`${id}-value`}>Value</FieldLabel>
105 <Input id={`${id}-value`} name="value" type="password" placeholder="Bearer …" required autoComplete="off" spellCheck={false} className="font-mono" />
106 <FieldDescription>The whole value, scheme included, such as Bearer and the token.</FieldDescription>
107 </Field>
108 <FieldError>{error}</FieldError>
109 <DialogFooter>
110 <Button type="button" variant="outline" onClick={() => setOpen(false)}>
111 Cancel
112 </Button>
113 <SubmitButton variant="accent" fetcher={fetcher} match={fields} pending={connect ? "Connecting…" : "Saving and listing tools…"}>
114 <KeyRound size={14} />
115 {connect ? "Connect" : "Save key"}
116 </SubmitButton>
117 </DialogFooter>
118 </fetcher.Form>
119 </DialogContent>
120 </Dialog>
121 );
122}
123
124/**
125 * What an owner does with a connector: sign it in (the browser leaves for
126 * the server) or out, set a key, list its tools again, or remove it, which
127 * is asked twice.
128 */
129export function ConnectorMenu({ connector, caveat }: { connector: WorkspaceConnector; caveat?: string | null }) {
130 const fetcher = useFetcher<ActionResult>({ key: `connector-menu-${connector.id}` });
131 const [key, setKey] = useState(false);
132 const post = (intent: string) => fetcher.submit({ intent, connector: connector.id }, { method: "post" });
133 const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
134 return (
135 <>
136 <DropdownMenu>
137 <DropdownMenuTrigger asChild>
138 <Button type="button" variant="ghost" size="icon-sm" aria-label={`What to do with ${connector.title}`} disabled={fetcher.state !== "idle"}>
139 <EllipsisVertical size={15} />
140 </Button>
141 </DropdownMenuTrigger>
142 <DropdownMenuContent align="end">
143 {connector.auth === "oauth" && (
144 <DropdownMenuItem onSelect={() => post(connector.state === "needs_sign_in" ? "sign_in" : "sign_out")}>
145 {connector.state === "needs_sign_in" ? <LogIn /> : <LogOut />}
146 {connector.state === "needs_sign_in" ? "Sign in" : "Sign out"}
147 </DropdownMenuItem>
148 )}
149 {connector.auth === "oauth" && connector.state !== "needs_sign_in" && (
150 <DropdownMenuItem onSelect={() => post("sign_in")}>
151 <LogIn />
152 Sign in again
153 </DropdownMenuItem>
154 )}
155 {connector.auth === "header" && (
156 <DropdownMenuItem onSelect={() => setKey(true)}>
157 <KeyRound />
158 {connector.state === "needs_key" ? "Set the key" : "Replace the key"}
159 </DropdownMenuItem>
160 )}
161 <DropdownMenuItem onSelect={() => post("refresh")}>
162 <RefreshCw />
163 List tools again
164 </DropdownMenuItem>
165 <DropdownMenuSeparator />
166 <Confirm
167 title={`Remove ${connector.title}?`}
168 confirm="Remove connector"
169 fields={{ intent: "remove", connector: connector.id }}
170 fetcherKey={`connector-remove-${connector.id}`}
171 trigger={
172 <DropdownMenuItem onSelect={(event) => event.preventDefault()} className="text-danger data-highlighted:text-danger [&_svg]:text-danger">
173 <Trash2 />
174 Remove
175 </DropdownMenuItem>
176 }
177 >
178 Its tools stop being offered to every agent it reaches, at once, and its sign-in or key is forgotten.
179 </Confirm>
180 </DropdownMenuContent>
181 </DropdownMenu>
182 {connector.auth === "header" && <SetKeyDialog connector={connector} caveat={caveat} open={key} onOpenChange={setKey} />}
183 {error && <p className="text-xs text-danger">{error}</p>}
184 </>
185 );
186}