| 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 | */ |
| 8 | import { EllipsisVertical, KeyRound, LogIn, LogOut, RefreshCw, Trash2 } from "lucide-react"; |
| 9 | import { type ReactNode, useEffect, useId, useState } from "react"; |
| 10 | import { useFetcher } from "react-router"; |
| 11 | |
| 12 | import type { WorkspaceConnector } from "@g1t/contracts"; |
| 13 | import { connectorById } from "@g1t/contracts/connectors"; |
| 14 | |
| 15 | import { type ActionResult, Confirm } from "./agents/dialogs"; |
| 16 | import { ConnectorMark } from "./connectors"; |
| 17 | import { AvailableTo, ListingMark } from "./customize"; |
| 18 | import { SubmitButton } from "./ui"; |
| 19 | import { Button } from "./ui/button"; |
| 20 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; |
| 21 | import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu"; |
| 22 | import { Field, FieldDescription, FieldError, FieldLabel } from "./ui/field"; |
| 23 | import { Input } from "./ui/input"; |
| 24 | import { 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. */ |
| 27 | export 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. */ |
| 34 | export 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. */ |
| 39 | export 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 | */ |
| 61 | export 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 | */ |
| 129 | export 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 | } |