| 1 | import { useBeforeLeave, useLocation, useParams } from "@solidjs/router"; |
| 2 | import { parseResponse } from "hono/client"; |
| 3 | import { createEffect, createResource, createSignal, on, onCleanup, onMount, Show } from "solid-js"; |
| 4 | import { api, reason } from "../api.ts"; |
| 5 | import { showConfirmDialog } from "../components/Dialog.tsx"; |
| 6 | import { MCPAccess } from "./MCPAccess.tsx"; |
| 7 | import type { Access } from "../types/mcp.ts"; |
| 8 | import "./MCP.css"; |
| 9 | |
| 10 | export function MCPConsent() { |
| 11 | const params = useParams<{ id: string }>(); |
| 12 | const location = useLocation(); |
| 13 | const request = () => params.id || new URLSearchParams(location.search).get("request") || ""; |
| 14 | const [consent, { refetch }] = createResource(request, |
| 15 | (id) => parseResponse(api.mcp.consent[":id"].$get({ param: { id } }))); |
| 16 | const details = () => consent.state === "ready" ? consent.latest : undefined; |
| 17 | const [selection, setSelection] = createSignal<Access>(); |
| 18 | const access = () => selection() ?? (details()?.catalog === "shale" ? "all" : []); |
| 19 | const [busy, setBusy] = createSignal(false); |
| 20 | const [error, setError] = createSignal(""); |
| 21 | let leaving = false; |
| 22 | createEffect(on(request, () => { setSelection(undefined); setError(""); })); |
| 23 | const redirect = (target: string) => { leaving = true; window.location.assign(target); }; |
| 24 | const answer = async (deny: boolean) => { |
| 25 | setBusy(true); setError(""); |
| 26 | try { |
| 27 | const result = await parseResponse(api.mcp.consent[":id"].$post({ param: { id: request() }, json: deny ? { deny: true } : { resources: access() } })); |
| 28 | redirect(result.redirect); |
| 29 | } catch (error) { setError(reason(error)); setBusy(false); throw error; } |
| 30 | }; |
| 31 | useBeforeLeave((event) => { |
| 32 | if (leaving) return; |
| 33 | event.preventDefault(); |
| 34 | if (busy()) return; |
| 35 | showConfirmDialog({ title: "Decline this connection?", description: "The client will receive no access.", confirmLabel: "Decline", onConfirm: () => answer(true) }); |
| 36 | }); |
| 37 | onMount(() => { |
| 38 | const guard = (event: BeforeUnloadEvent) => { if (!leaving) event.preventDefault(); }; |
| 39 | window.addEventListener("beforeunload", guard); |
| 40 | onCleanup(() => window.removeEventListener("beforeunload", guard)); |
| 41 | }); |
| 42 | const linkShale = async () => { |
| 43 | setBusy(true); setError(""); |
| 44 | try { |
| 45 | const result = await parseResponse(api.mcp.shale.$post({ json: { request: request() } })); |
| 46 | redirect(result.redirect); |
| 47 | } catch (error) { setError(reason(error)); setBusy(false); } |
| 48 | }; |
| 49 | return <main class="mcp-authorization"> |
| 50 | <section class="mcp-approval" aria-labelledby="connection-title"> |
| 51 | <div class="mcp-approval-brand">Snowglobe <span class="muted">· MCP connection</span></div> |
| 52 | <Show when={details()} fallback={<> |
| 53 | <h1 id="connection-title">{consent.state === "errored" ? "Connection unavailable" : "Loading connection…"}</h1> |
| 54 | <Show when={consent.state === "errored"} fallback={<div class="skeleton" style={{ height: "120px" }} aria-label="Loading connection" />}> |
| 55 | <p class="error" role="alert">{reason(consent.error)}</p> |
| 56 | <button class="button" onClick={() => refetch()}>Retry</button> |
| 57 | </Show> |
| 58 | </>}> |
| 59 | {(data) => <> |
| 60 | <h1 id="connection-title">Connect {data().client}</h1> |
| 61 | <p class="mcp-approval-intro">{data().catalog === "shale" ? "Grant access to Shale issues." : data().catalog === "agents" ? "Grant access to your local agents." : "Grant access to logs and traces."}</p> |
| 62 | <dl class="mcp-request-identity"><div><dt>Signed in as</dt><dd>{data().account}</dd></div><div><dt>Return to</dt><dd>{data().redirectHost}</dd></div></dl> |
| 63 | <div class="mcp-permissions"><h2>Requested access</h2> |
| 64 | <p>{data().catalog === "shale" ? "Read issues and comments" : data().catalog === "agents" ? "Read saved chats" : "Read logs and traces"}</p> |
| 65 | <Show when={data().scopes.includes("shale:write")}><p>Create issues, comment, and edit issue titles and status</p></Show> |
| 66 | <Show when={data().scopes.includes("sessions:write")}><p>Send messages, start chats, and interrupt turns</p></Show> |
| 67 | <Show when={data().scopes.includes("offline_access")}><p>Stay connected without signing in again</p></Show> |
| 68 | </div> |
| 69 | <Show when={data().linked} fallback={<div class="mcp-link-step"><h2>Link your Shale account</h2><p>Sign in to Shale, then return here to approve access.</p><button class="button primary" disabled={busy()} onClick={linkShale}>Link account</button></div>}> |
| 70 | <MCPAccess catalog={data().catalog} resources={data().resources} value={access()} onChange={setSelection} disabled={busy()} /> |
| 71 | <Show when={data().resourceError}><p class="error" role="alert">{data().resourceError} <button class="button small" disabled={busy()} onClick={() => refetch()}>Retry</button></p></Show> |
| 72 | <p class="muted">You can change this connection’s access later in MCP settings.</p> |
| 73 | </Show> |
| 74 | </>} |
| 75 | </Show> |
| 76 | <Show when={error()}><p class="error" role="alert">{error()}</p></Show> |
| 77 | <div class="mcp-approval-actions"> |
| 78 | <button class="button" disabled={busy()} onClick={() => answer(true).catch(() => {})}>Decline</button> |
| 79 | <Show when={details()?.linked}><button class="button primary" disabled={busy() || (access() !== "all" && !access().length)} onClick={() => answer(false).catch(() => {})}>Allow access</button></Show> |
| 80 | </div> |
| 81 | <Show when={consent.state === "errored"}><button class="mcp-close" onClick={() => redirect("/mcp")}>Close request</button></Show> |
| 82 | </section> |
| 83 | </main>; |
| 84 | } |