PluginProbe
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot / 4.9.5
BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot v4.9.5
4.9.5 4.9.4 4.9.3 4.9.2 4.9.1 4.9.0 4.8.2 4.8.1 4.8.0 4.7.0 4.6.2 4.6.1 4.6.0 4.5.6 4.5.5 4.5.4 4.5.3 4.5.2 4.5.1 4.5.0 4.4.1 4.4.0 3.3.4 3.4.0 3.4.1 All 203 releases
betterdocs / react-src / admin / mcp / components / ConnectedApps.js

ConnectedApps.js in BetterDocs – AI Documentation, Knowledge Base, MCP Server, Docs, Wikis, FAQ & Chatbot 4.9.5, at react-src/admin/mcp/components/ConnectedApps.js

178 lines 8.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 import React from "react";
2 import { __, sprintf } 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 import { getApps, revokeApp } from "../utils/api";
8 import { formatDate } from "../utils/format";
9 import { IconPlug, IconRefresh, IconTrash } from "./Icons";
10
11 // Exported so the KPI row can read the same cache rather than fetch the app
12 // list a second time (ADR-062).
13 export const APPS_KEY = "bd-mcp-apps";
14
15 /**
16 * The callback host is the one field of an OAuth registration an attacker
17 * cannot fake — anyone may register a client called "Claude" (RFC 7591 dynamic
18 * registration is open by design), but the callback is where the grant actually
19 * sends access. It is the same field the consent screen flags, shown here for
20 * the grants that already exist.
21 */
22 const callbackHost = (app) => {
23 const uri = Array.isArray(app.redirect_uris) && app.redirect_uris.length ? app.redirect_uris[0] : "";
24 if (!uri) return "";
25 try {
26 const url = new URL(uri);
27 return url.host || uri;
28 } catch (e) {
29 return uri;
30 }
31 };
32
33 export default function ConnectedApps() {
34 const queryClient = useQueryClient();
35
36 const { data, isLoading, isError, refetch, isFetching } = useQuery({
37 queryKey: [APPS_KEY],
38 queryFn: getApps,
39 });
40
41 const revoke = useMutation({
42 mutationFn: (clientId) => revokeApp(clientId),
43 onSuccess: (next) => {
44 queryClient.setQueryData([APPS_KEY], next);
45 queryClient.invalidateQueries({ queryKey: [APPS_KEY] });
46 toast.success(__("Access revoked.", "betterdocs"));
47 },
48 onError: () => toast.error(__("Couldn't revoke that connection.", "betterdocs")),
49 });
50
51 const confirmRevoke = (app) =>
52 Swal.fire({
53 title: __("Revoke this app?", "betterdocs"),
54 text: sprintf(
55 /* translators: %s: the connected app's name. */
56 __("“%s” loses access immediately and has to be approved again to reconnect.", "betterdocs"),
57 app.name || "",
58 ),
59 icon: "warning",
60 showCancelButton: true,
61 confirmButtonText: __("Revoke", "betterdocs"),
62 cancelButtonText: __("Cancel", "betterdocs"),
63 confirmButtonColor: "#d92d20",
64 }).then((r) => r.isConfirmed && revoke.mutate(app.client_id));
65
66 const apps = Array.isArray(data?.oauth_apps) ? data.oauth_apps : [];
67 const hasRows = !isLoading && !isError && apps.length > 0;
68
69 return (
70 <div className="bda-card bd-mcp-apps">
71 <div className="bda-card-head bd-mcp-cardhead">
72 <div>
73 <h3 className="bda-card-title">{__("Connected apps", "betterdocs")}</h3>
74 <p className="bd-mcp-muted">
75 {__(
76 "Apps that signed in through OAuth. Each one acts as the person who approved it, and revoking cuts it off at once. Clients using the connection token are not listed here — rotate the token to cut those off.",
77 "betterdocs",
78 )}
79 </p>
80 </div>
81 <button type="button" className="bda-btn ghost sm" onClick={() => refetch()} disabled={isFetching}>
82 <IconRefresh />
83 {__("Refresh", "betterdocs")}
84 </button>
85 </div>
86
87 {/* The table brings its own head band and row padding (the shared
88 `bda-table` rules), so it sits in a flush body and spans the
89 card; the loading, error and empty states keep the padded one. */}
90 {!hasRows && (
91 <div className="bda-card-body">
92 {isLoading && <p className="bd-mcp-muted">{__("Loading connected apps…", "betterdocs")}</p>}
93
94 {!isLoading && isError && (
95 <div className="bd-mcp-alert is-error">
96 {__("Couldn't load connected apps. Reload the page and try again.", "betterdocs")}
97 </div>
98 )}
99
100 {!isLoading && !isError && apps.length === 0 && (
101 <div className="bd-mcp-empty">
102 <span className="bd-mcp-empty__icon">
103 <IconPlug size={24} />
104 </span>
105 <h4 className="bd-mcp-empty__title">{__("No apps connected yet", "betterdocs")}</h4>
106 <p className="bd-mcp-empty__text">
107 {__("When someone approves an AI client on the consent screen, it appears here.", "betterdocs")}
108 </p>
109 </div>
110 )}
111 </div>
112 )}
113
114 {hasRows && (
115 <div className="bda-card-body flush">
116 <div className="bd-mcp-tablewrap">
117 <table className="bda-table bd-mcp-table">
118 <thead>
119 <tr>
120 <th>{__("App", "betterdocs")}</th>
121 <th>{__("Sends access to", "betterdocs")}</th>
122 <th>{__("Approved by", "betterdocs")}</th>
123 <th>{__("Approved", "betterdocs")}</th>
124 <th>{__("Last used", "betterdocs")}</th>
125 <th>{__("Access", "betterdocs")}</th>
126 <th className="bda-t-right">{__("Actions", "betterdocs")}</th>
127 </tr>
128 </thead>
129 <tbody>
130 {apps.map((app) => (
131 <tr key={app.client_id} className="row-hover">
132 <td className="bda-strong">{app.name}</td>
133 <td>
134 <code className="bd-mcp-host">{callbackHost(app) || __("—", "betterdocs")}</code>
135 </td>
136 <td>
137 {app.user_login ||
138 sprintf(
139 /* translators: %d: WordPress user id, shown when the user no longer exists. */
140 __("User #%d", "betterdocs"),
141 app.user_id,
142 )}
143 </td>
144 <td>{formatDate(app.created)}</td>
145 <td>{formatDate(app.last_used)}</td>
146 <td>
147 <span className={`bda-badge ${app.read_only ? "amber" : "green"}`}>
148 {app.read_only ? __("Read-only", "betterdocs") : __("Read & write", "betterdocs")}
149 </span>
150 </td>
151 <td className="bda-t-right">
152 <button
153 type="button"
154 className="bda-btn ghost sm bd-mcp-danger"
155 onClick={() => confirmRevoke(app)}
156 disabled={revoke.isLoading}
157 aria-label={sprintf(
158 /* translators: %s: the connected app's name. */
159 __("Revoke %s", "betterdocs"),
160 app.name || "",
161 )}
162 >
163 <IconTrash />
164 {__("Revoke", "betterdocs")}
165 </button>
166 </td>
167 </tr>
168 ))}
169 </tbody>
170 </table>
171 </div>
172 </div>
173 )}
174 </div>
175 );
176 }
177
178