| 1 |
import React, { useState } from "react"; |
| 2 |
import { __ } from "@wordpress/i18n"; |
| 3 |
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; |
| 4 |
import { toast } from "react-toastify"; |
| 5 |
import Swal from "sweetalert2"; |
| 6 |
|
| 7 |
// `connect` and `disconnect` stay in `utils/api` — they are API the driver and |
| 8 |
// the read-only pairing path use (ADR-038/ADR-056) — but the page no longer |
| 9 |
// calls them: the token is minted for it on the first status read. |
| 10 |
import { getConnection, rotate } from "../utils/api"; |
| 11 |
import CopyField from "./CopyField"; |
| 12 |
import EditorsTab from "./EditorsTab"; |
| 13 |
import { IconChatGpt, IconClaude, IconExternal } from "./Icons"; |
| 14 |
|
| 15 |
export const CONNECTION_KEY = "bd-mcp-connection"; |
| 16 |
|
| 17 |
const LEARN_MORE_MCP = "https://modelcontextprotocol.io/docs/concepts/transports"; |
| 18 |
const LEARN_MORE_CHATGPT = "https://developers.openai.com/apps-sdk/deploy/connect-chatgpt"; |
| 19 |
|
| 20 |
const TABS = [ |
| 21 |
{ id: "auth", label: __("OAuth", "betterdocs") }, |
| 22 |
{ id: "others", label: __("Editors & CLI", "betterdocs") }, |
| 23 |
]; |
| 24 |
|
| 25 |
const Steps = ({ steps }) => ( |
| 26 |
<ol className="bd-mcp-steps"> |
| 27 |
{steps.map((step, i) => ( |
| 28 |
<li key={step} className="bd-mcp-steps__item"> |
| 29 |
<span className="bd-mcp-steps__num">{i + 1}</span> |
| 30 |
<span>{step}</span> |
| 31 |
</li> |
| 32 |
))} |
| 33 |
</ol> |
| 34 |
); |
| 35 |
|
| 36 |
/** |
| 37 |
* The icon square. The brand accent arrives as a custom property so the |
| 38 |
* stylesheet — not this component — decides what happens to it in dark mode, |
| 39 |
* where ChatGPT's black and Cursor's near-black would otherwise vanish. |
| 40 |
* |
| 41 |
* `solid` marks a tile whose glyph is a vendor's own logo file rather than one |
| 42 |
* of ours. It draws a plain white chip with a hairline border, so the logo keeps |
| 43 |
* its own colour and every tile in the row gets one treatment (ADR-066). That |
| 44 |
* chip stays light in dark mode too — a terracotta logo on a near-black square |
| 45 |
* is unreadable. |
| 46 |
*/ |
| 47 |
const CardHead = ({ Icon, accent, accentDark, accentFgDark, tint, solid, name, badge, badgeTone, sub }) => ( |
| 48 |
<div className="bd-mcp-card__head"> |
| 49 |
<span |
| 50 |
className={`bd-mcp-card__icon${solid ? " is-solid" : ""}`} |
| 51 |
style={{ |
| 52 |
"--bd-accent": accent, |
| 53 |
"--bd-accent-dark": accentDark || accent, |
| 54 |
...(accentFgDark ? { "--bd-accent-fg-dark": accentFgDark } : {}), |
| 55 |
"--bd-accent-tint": tint || `${accent}14`, |
| 56 |
}} |
| 57 |
> |
| 58 |
<Icon /> |
| 59 |
</span> |
| 60 |
<div className="bd-mcp-card__headtext"> |
| 61 |
<span className="bd-mcp-card__title"> |
| 62 |
{name} |
| 63 |
{badge && <span className={`bda-badge ${badgeTone || "slate"}`}>{badge}</span>} |
| 64 |
</span> |
| 65 |
{sub && <span className="bd-mcp-card__sub">{sub}</span>} |
| 66 |
</div> |
| 67 |
</div> |
| 68 |
); |
| 69 |
|
| 70 |
const AuthConnectorCard = ({ connector, endpoint }) => ( |
| 71 |
<div className="bda-card bd-mcp-card"> |
| 72 |
<CardHead |
| 73 |
Icon={connector.Icon} |
| 74 |
accent={connector.accent} |
| 75 |
accentDark={connector.accentDark} |
| 76 |
accentFgDark={connector.accentFgDark} |
| 77 |
solid={connector.solid} |
| 78 |
name={connector.name} |
| 79 |
badge={__("OAuth", "betterdocs")} |
| 80 |
badgeTone="green" |
| 81 |
sub={__("Signs in with a one-time approval. No token to paste.", "betterdocs")} |
| 82 |
/> |
| 83 |
|
| 84 |
<CopyField label={__("Connector URL", "betterdocs")} value={endpoint} /> |
| 85 |
<Steps steps={connector.steps} /> |
| 86 |
|
| 87 |
{connector.learnMore && ( |
| 88 |
<a |
| 89 |
className="bd-mcp-link" |
| 90 |
href={connector.learnMore} |
| 91 |
target="_blank" |
| 92 |
rel="noopener noreferrer" |
| 93 |
> |
| 94 |
{__("How to add a connector", "betterdocs")} |
| 95 |
<IconExternal size={11} /> |
| 96 |
</a> |
| 97 |
)} |
| 98 |
</div> |
| 99 |
); |
| 100 |
|
| 101 |
export default function ConnectionConfig() { |
| 102 |
const queryClient = useQueryClient(); |
| 103 |
const [tab, setTab] = useState("auth"); |
| 104 |
|
| 105 |
const { data, isLoading, isError } = useQuery({ |
| 106 |
queryKey: [CONNECTION_KEY], |
| 107 |
queryFn: getConnection, |
| 108 |
}); |
| 109 |
|
| 110 |
const refresh = (next) => { |
| 111 |
if (next) queryClient.setQueryData([CONNECTION_KEY], (prev) => ({ ...prev, ...next })); |
| 112 |
queryClient.invalidateQueries({ queryKey: [CONNECTION_KEY] }); |
| 113 |
}; |
| 114 |
|
| 115 |
const rotateMutation = useMutation({ |
| 116 |
mutationFn: () => rotate(null), |
| 117 |
onSuccess: (next) => { |
| 118 |
refresh(next); |
| 119 |
toast.success(__("Connection token rotated. Reconnect your clients with the new one.", "betterdocs")); |
| 120 |
}, |
| 121 |
onError: () => toast.error(__("Couldn't rotate the connection token.", "betterdocs")), |
| 122 |
}); |
| 123 |
|
| 124 |
const confirmRotate = () => |
| 125 |
Swal.fire({ |
| 126 |
title: __("Rotate the connection token?", "betterdocs"), |
| 127 |
text: __( |
| 128 |
"A new token is generated immediately. Every client using the old one — Claude Code, Cursor, VS Code, Claude Desktop — stops working until you give it the new command. Apps connected through OAuth are unaffected.", |
| 129 |
"betterdocs", |
| 130 |
), |
| 131 |
icon: "warning", |
| 132 |
showCancelButton: true, |
| 133 |
confirmButtonText: __("Rotate token", "betterdocs"), |
| 134 |
cancelButtonText: __("Cancel", "betterdocs"), |
| 135 |
confirmButtonColor: "#00b884", |
| 136 |
}).then((r) => r.isConfirmed && rotateMutation.mutate()); |
| 137 |
|
| 138 |
// The card head: the section's title and its "how it works" line on the |
| 139 |
// left, the two tabs as the analytics segmented control on the right |
| 140 |
// (ADR-062). The tabs only make sense once there is something to tab |
| 141 |
// between, so the loading and error states below render the head alone. |
| 142 |
const head = (withTabs) => ( |
| 143 |
<div className="bda-card-head bd-mcp-cardhead"> |
| 144 |
<div> |
| 145 |
<h3 className="bda-card-title">{__("Add BetterDocs to your client", "betterdocs")}</h3> |
| 146 |
<p className="bd-mcp-muted"> |
| 147 |
{__("Pick your assistant below — it opens or copies fully configured.", "betterdocs")}{" "} |
| 148 |
<a className="bd-mcp-link" href={LEARN_MORE_MCP} target="_blank" rel="noopener noreferrer"> |
| 149 |
{__("How it works", "betterdocs")} |
| 150 |
<IconExternal size={11} /> |
| 151 |
</a> |
| 152 |
</p> |
| 153 |
</div> |
| 154 |
{withTabs && ( |
| 155 |
<div className="bda-segtabs bd-mcp-tabs" role="tablist"> |
| 156 |
{TABS.map((t) => ( |
| 157 |
<button |
| 158 |
key={t.id} |
| 159 |
type="button" |
| 160 |
role="tab" |
| 161 |
aria-selected={tab === t.id} |
| 162 |
className={`bda-segtab${tab === t.id ? " active" : ""}`} |
| 163 |
onClick={() => setTab(t.id)} |
| 164 |
> |
| 165 |
{t.label} |
| 166 |
</button> |
| 167 |
))} |
| 168 |
</div> |
| 169 |
)} |
| 170 |
</div> |
| 171 |
); |
| 172 |
|
| 173 |
// The token is minted by `GET /mcp/connection` itself (ADR-056), so the only |
| 174 |
// states this section has are "asking" and "here it is". |
| 175 |
if (isLoading) { |
| 176 |
return ( |
| 177 |
<div className="bda-card bd-mcp-connect"> |
| 178 |
{head(false)} |
| 179 |
<div className="bda-card-body"> |
| 180 |
<p className="bd-mcp-muted">{__("Preparing your connection…", "betterdocs")}</p> |
| 181 |
</div> |
| 182 |
</div> |
| 183 |
); |
| 184 |
} |
| 185 |
|
| 186 |
if (isError || !data) { |
| 187 |
return ( |
| 188 |
<div className="bda-card bd-mcp-connect"> |
| 189 |
{head(false)} |
| 190 |
<div className="bda-card-body"> |
| 191 |
<div className="bd-mcp-alert is-error"> |
| 192 |
{__("Couldn't load the MCP connection status.", "betterdocs")} |
| 193 |
</div> |
| 194 |
</div> |
| 195 |
</div> |
| 196 |
); |
| 197 |
} |
| 198 |
|
| 199 |
const endpoint = data.mcp_endpoint || ""; |
| 200 |
const busy = rotateMutation.isLoading; |
| 201 |
|
| 202 |
// A connected site whose stored token cannot be decrypted — the salt-rotation |
| 203 |
// case. The pairing still authenticates the clients that already have it, so |
| 204 |
// the honest answer is "rotate", not "you are not connected". |
| 205 |
if (!data.token_available) { |
| 206 |
return ( |
| 207 |
<div className="bda-card bd-mcp-connect"> |
| 208 |
{head(false)} |
| 209 |
<div className="bda-card-body"> |
| 210 |
<div className="bd-mcp-alert is-warning"> |
| 211 |
<strong>{__("The stored copy of your connection token can't be read.", "betterdocs")}</strong>{" "} |
| 212 |
{__( |
| 213 |
"This happens when the WordPress security salts change. Clients you already connected keep working — but to see or share the token again you have to rotate it, which disconnects them.", |
| 214 |
"betterdocs", |
| 215 |
)} |
| 216 |
<div className="bd-mcp-actions"> |
| 217 |
<button type="button" className="bda-btn primary sm" onClick={confirmRotate} disabled={busy}> |
| 218 |
{__("Rotate token", "betterdocs")} |
| 219 |
</button> |
| 220 |
</div> |
| 221 |
</div> |
| 222 |
</div> |
| 223 |
</div> |
| 224 |
); |
| 225 |
} |
| 226 |
|
| 227 |
// The two connector assistants. Both need the endpoint URL and nothing |
| 228 |
// else — they sign in through the browser — so this tab has never had a |
| 229 |
// token on it, and the picker redesign left it untouched (ADR-057). |
| 230 |
const authConnectors = [ |
| 231 |
{ |
| 232 |
id: "claude", |
| 233 |
name: __("Claude (web, mobile & desktop)", "betterdocs"), |
| 234 |
Icon: IconClaude, |
| 235 |
accent: "#d97757", |
| 236 |
solid: true, |
| 237 |
steps: [ |
| 238 |
__("In Claude, open Settings → Connectors → Add Connector → Add custom connector.", "betterdocs"), |
| 239 |
__("Paste the connector URL above and give it a name (e.g. BetterDocs).", "betterdocs"), |
| 240 |
__("Click Add, then approve access when Claude asks you to sign in.", "betterdocs"), |
| 241 |
], |
| 242 |
}, |
| 243 |
{ |
| 244 |
id: "chatgpt", |
| 245 |
name: "ChatGPT", |
| 246 |
Icon: IconChatGpt, |
| 247 |
accent: "#000000", |
| 248 |
// A solid black square vanishes on a dark card, so in dark mode the |
| 249 |
// tile inverts: near-white square, near-black logomark — which is |
| 250 |
// how OpenAI's own icon renders on a dark surface. |
| 251 |
accentDark: "#e5e9f2", |
| 252 |
accentFgDark: "#101828", |
| 253 |
solid: true, |
| 254 |
learnMore: LEARN_MORE_CHATGPT, |
| 255 |
steps: [ |
| 256 |
__("In ChatGPT, open Settings → Connectors → Add New.", "betterdocs"), |
| 257 |
__("Enter a name (e.g. BetterDocs).", "betterdocs"), |
| 258 |
__("Paste the connector URL above.", "betterdocs"), |
| 259 |
__("Save, then approve access when ChatGPT asks you to sign in.", "betterdocs"), |
| 260 |
], |
| 261 |
}, |
| 262 |
]; |
| 263 |
|
| 264 |
return ( |
| 265 |
<div className="bda-card bd-mcp-connect"> |
| 266 |
{head(true)} |
| 267 |
|
| 268 |
<div className="bda-card-body"> |
| 269 |
<div className="bd-mcp-tabpanel"> |
| 270 |
{tab === "auth" && ( |
| 271 |
<div className="bd-mcp-grid"> |
| 272 |
{authConnectors.map((c) => ( |
| 273 |
<AuthConnectorCard key={c.id} connector={c} endpoint={endpoint} /> |
| 274 |
))} |
| 275 |
</div> |
| 276 |
)} |
| 277 |
|
| 278 |
{tab === "others" && <EditorsTab data={data} />} |
| 279 |
</div> |
| 280 |
</div> |
| 281 |
|
| 282 |
{/* The card's footer row, below both panels (ADR-062). It used to |
| 283 |
sit inside the Editors & CLI tab, because only those clients use |
| 284 |
the connection token (ADR-057) — but as a card footer it reads as |
| 285 |
a property of the whole card rather than of whichever tab is |
| 286 |
open, and its own last clause ("browser sign-ins are unaffected") |
| 287 |
is addressed to the reader of the OAuth tab. */} |
| 288 |
<div className="bd-mcp-reset"> |
| 289 |
<p className="bd-mcp-reset__text"> |
| 290 |
{__( |
| 291 |
"Rotate the connection token if it may have leaked. Clients that use the token will need the new one; browser sign-ins are unaffected.", |
| 292 |
"betterdocs", |
| 293 |
)} |
| 294 |
</p> |
| 295 |
<div className="bd-mcp-actions"> |
| 296 |
<button type="button" className="bda-btn ghost sm" onClick={confirmRotate} disabled={busy}> |
| 297 |
{rotateMutation.isLoading ? __("Rotating…", "betterdocs") : __("Rotate token", "betterdocs")} |
| 298 |
</button> |
| 299 |
</div> |
| 300 |
</div> |
| 301 |
</div> |
| 302 |
); |
| 303 |
} |
| 304 |
|