| 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 |
|