Skip to content

Commit 4773ef6

Browse files
mxmzbclaudedavidmckayv
authored
An app's account can belong to the team (#757)
* Pin shared brokered accounts with failing tests before building them Tests for the account holder, the audience check, the account-mode switch, the shared-use inbox and the app screens. They fail until the code lands. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Document Shared apps, who may use them, and what changes on upgrade Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Format the shared-account tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record brokered accounts with an explicit holder, and give each app a Personal or Shared mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Decide whose account a brokered call reaches in one place Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a handoff carry where its run started, and accept every root in handed-over work Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Audit a Shared app's calls as reached by the deployment Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read shared accounts, approvals and requests in the app, and let an account row carry its own heading Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Migrate Composio connections into brokered_connections and give every account-holding app a mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Write down who may steer a run that uses a shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Carry where a run started through every handoff, and read it strictly from a signed run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tell the broker whose account it acts for, and let it name the account it holds Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Declare the audit events for shared accounts and mode switches Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Resolve the account a brokered call acts for from the row, never the request Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add the app pieces for approving, requesting and switching shared accounts Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ask Composio about an account by its holder's vendor id, and read only its published display name Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check a brokered connection by holder and call the vendor under the account's stored id Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Store shared-account approvals and requests, and gate each shared call on them Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let an administrator share an app's account and approve who may use it through each Bot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show people when a Bot acts as the team account in Settings Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Tell owners when widening a Bot needs an administrator, and show administrators the requests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Call the broker by account holder throughout the adapter tests Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Read and write one brokered account's row by its holder Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Give administrators one place to answer requests to use a shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Approve a wider Bot inline for administrators, and say what is missing for owners Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Add the Personal or Shared control to an app's admin page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Check against the live API that a deployment identity is accepted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Confirm, connect with a key and re-check an account by its holder, naming who acted Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Say what publishing, assigning, a trigger or making a Bot public means for its shared apps Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Show each Bot's shared-account approval on the app's admin page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Count the account-mode calls in the mutation refresh roster Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Record the administrator who most recently connected a shared account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * End one brokered account by its holder, and name whose it was in the audit Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Offboard a person from their own brokered accounts only, never the deployment's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let only an administrator confirm, re-check or end a Shared app's account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Start a newly enabled brokered app as Personal, and never reset a Shared one on re-add Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Connect a Shared app's team account only as an administrator, and return them to the admin page Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Revoke every account on a removed brokered app, the deployment's included Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Expose each Shared app's team account, its name and who connected it, to readers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move the remaining fake brokers to ConnectedAppBroker Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Refuse a shared call from a run its approval does not cover, and fail closed without a gate Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List Shared apps as the deployment's in GET /connections Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Let a memory source read a Shared app through the deployment's account Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * List and end every account on an app, and write its account mode Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Build the shared-use store and audience gate at boot Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move the connection, scheme and route tests to brokered_connections and account holders Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin that granting a Shared app's action records who may use it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Decide whose account and whether to gate from the app's answering row, never a duplicate A second mcp_servers row at the same composio:// address could mark a Shared app Personal and let calls through it skip the audience gate, or let a non-admin connect a personal account to it. Every account decision, the gate, the audit's reachedAs and the mode write now resolve the app's answering row, and the gate refuses if the mode changes before the call goes out. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Key shared-account approvals and requests by the app's answering row A Bot granted through a duplicate or non-canonical row of an app now gets the approval the audience gate actually reads, appears once in what it holds, and is found when the app's mode changes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Carry the app's account mode on a server's address, read from its answering row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Switch an app between Personal and Shared only once every old account is ended Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Ask the approval gate under the app's id for a write granted through a duplicate row Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Move the plugin store tests to brokered_connections and a Personal default Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Pin that a call whose app turned Personal mid-flight never reaches the vendor Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Decide which Connected accounts sections draw in one place, keeping Shared beside the empty state Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Switch an app's account mode, record a Shared grant's approval, and forget its rule on removal Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Wire the mode switch, the shared-use inbox and exposure re-checks into the running app Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Hand the shared wiring to createApp in the coworker options, not as a 37th positional argument Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Fail loudly when a decided request cannot be read back, and settle the lint and format run Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Make the switch to Shared safe to retry, and refuse a shared call that does not say what started it switchMode set the mode, then each Bot's approval, then the ask-before-write rule, with no transaction, and a retry on an app already in that mode returned changed without doing anything. A failure after the mode left a Shared app writing through the team account without asking, permanently. The approvals and the rule are now made before the mode says shared, only where missing, and the already-in-that-mode path makes them exist again (or, for Personal, removes what a half-finished switch back left). The rule is never duplicated and an approval an administrator changed is not written over. A run that carried no initiator was read as a person, so a background path added later that forgot to pass one would get past the outside-input check on a shared account. The shared gate now refuses it, without filing a request no approval could answer, and records it as shared_steering. The direct tool-call route says it is a person. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: David McKay <david@copilotkit.ai>
1 parent 99a329d commit 4773ef6

87 files changed

Lines changed: 19565 additions & 1021 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎CHANGELOG.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,22 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
88

99
## Unreleased
1010

11+
**Before upgrading.** Two things change for an existing deployment:
12+
- Migration `0052_shared_brokered_accounts` copies every Composio connection into a new
13+
`brokered_connections` table and leaves `composio_connections` in place, unwritten. Rolling back to
14+
0.1.2 works, but accounts connected after the upgrade are invisible to it.
15+
- A handoff now records what started the run it came from. Older remote Bots' signed runs carry no
16+
such record for up to ten minutes after the upgrade, and calls they make to a Shared app in that
17+
window are refused.
18+
19+
### An app's account can belong to the team
20+
21+
An administrator can make a Composio app Shared: one account, connected once, that every Bot granted
22+
the app acts as. Who may use it through each Bot is approved per Bot — owner only, named people and
23+
groups, or everyone, and separately whether email, Slack or webhook input may — and checked on every
24+
call. Requests to widen it wait in the Approvals inbox. Writes through a Shared app ask the person
25+
first.
26+
1127
### A new deployment starts with two coworkers, not twelve
1228

1329
The example package loaded every coworker in `examples/fintech/agents/`, so a fresh deployment opened

‎app/src/components/approvals/inbox.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
22
import { useState } from "react";
3+
import { SharedUseRequests } from "@/components/approvals/shared-use-requests";
34
import { Button } from "@/components/ui/button";
45
import { Switch } from "@/components/ui/switch";
56
import { Textarea } from "@/components/ui/textarea";
@@ -331,6 +332,7 @@ export function ApprovalInbox() {
331332
))
332333
)}
333334
</div>
335+
<SharedUseRequests />
334336
<div className="space-y-3">
335337
<h2 className="font-medium">Rules</h2>
336338
<p className="text-sm text-muted-foreground">
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { Button } from "@/components/ui/button";
3+
import { currentUserQueryOptions } from "@/lib/auth/queries";
4+
import {
5+
decideSharedUseMutationOptions,
6+
describeApproval,
7+
type SharedUseRequestReason,
8+
sharedUseRequestsQueryOptions,
9+
} from "@/lib/plugins/shared-use";
10+
11+
const WHY: Record<SharedUseRequestReason, string> = {
12+
refused_call:
13+
"A call was refused because this Bot reaches more people than approved.",
14+
publish: "Its owner widened who can reach it.",
15+
trigger: "Its owner added a trigger that lets outside input steer it.",
16+
grant: "It was granted the app's actions.",
17+
};
18+
19+
export function SharedUseRequests() {
20+
const queryClient = useQueryClient();
21+
const me = useQuery(currentUserQueryOptions()).data;
22+
const isAdmin = me?.role === "admin";
23+
const requests = useQuery(sharedUseRequestsQueryOptions(isAdmin));
24+
const decide = useMutation(decideSharedUseMutationOptions(queryClient));
25+
if (!isAdmin || !requests.data?.length) return null;
26+
27+
return (
28+
<div className="space-y-3">
29+
<h2 className="font-medium">Shared account requests</h2>
30+
{decide.error ? (
31+
<p className="text-destructive text-sm" role="alert">
32+
{decide.error.message}
33+
</p>
34+
) : null}
35+
{requests.data.map((request) => (
36+
<article className="space-y-2 rounded-lg border p-4" key={request.id}>
37+
<h3 className="font-medium text-sm">
38+
{request.botName} wants the shared {request.title} account
39+
</h3>
40+
<p className="text-muted-foreground text-sm">
41+
{WHY[request.reason as SharedUseRequestReason] ?? request.reason}
42+
</p>
43+
<p className="text-sm">
44+
Now:{" "}
45+
{request.current
46+
? describeApproval(request.current)
47+
: "Not approved"}
48+
</p>
49+
<p className="text-sm">Asked: {describeApproval(request.proposed)}</p>
50+
<div className="flex gap-2">
51+
<Button
52+
disabled={decide.isPending}
53+
onClick={() =>
54+
decide.mutate({ id: request.id, decision: "approve" })
55+
}
56+
size="sm"
57+
>
58+
Approve
59+
</Button>
60+
<Button
61+
disabled={decide.isPending}
62+
onClick={() =>
63+
decide.mutate({ id: request.id, decision: "decline" })
64+
}
65+
size="sm"
66+
variant="outline"
67+
>
68+
Decline
69+
</Button>
70+
</div>
71+
</article>
72+
))}
73+
</div>
74+
);
75+
}

‎app/src/components/bot-profile/profile.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Link } from "@tanstack/react-router";
1010
import { Fragment, useState } from "react";
1111
import { AbstractAvatar } from "@/components/agents/abstract-avatar";
1212
import { PageRows, PageSection } from "@/components/layout/page-shell";
13+
import { SharedAppNotice } from "@/components/plugins/shared-app-notice";
1314
import { Button } from "@/components/ui/button";
1415
import {
1516
Dialog,
@@ -290,6 +291,9 @@ export function BotProfile({ agent }: { agent: AgentProfile }) {
290291
</div>
291292

292293
<PageSection title="For you">
294+
{agent.canManage ? (
295+
<SharedAppNotice botId={agent.id} reason="publish" />
296+
) : null}
293297
{lifecycle.isPending ? null : lifecycle.error ? (
294298
<p className="mt-4 text-destructive text-sm" role="alert">
295299
Could not load this Bot's state.
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
// app/src/components/plugins/account-mode-dialog.tsx
2+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
3+
import { Button } from "@/components/ui/button";
4+
import {
5+
Dialog,
6+
DialogContent,
7+
DialogDescription,
8+
DialogFooter,
9+
DialogHeader,
10+
DialogTitle,
11+
} from "@/components/ui/dialog";
12+
import {
13+
accountModePreview,
14+
setAccountModeMutationOptions,
15+
} from "@/lib/plugins/mutations";
16+
import { describeApproval } from "@/lib/plugins/shared-use";
17+
18+
/**
19+
* The one moment an administrator decides between people's own accounts and one shared account.
20+
*
21+
* WHAT GOES IS NAMED BEFORE THE BUTTON, from the server's own dry run, so the count on screen is the
22+
* count that will be revoked — and each Bot's approval is listed as it will be written, because that
23+
* is the decision being made on their behalf, not a detail of it.
24+
*/
25+
export function AccountModeDialog({
26+
serverId,
27+
title,
28+
target,
29+
open,
30+
onOpenChange,
31+
names = {},
32+
}: {
33+
serverId: string;
34+
title: string;
35+
target: "personal" | "shared";
36+
open: boolean;
37+
onOpenChange: (open: boolean) => void;
38+
names?: Record<string, string>;
39+
}) {
40+
const queryClient = useQueryClient();
41+
/**
42+
* Kept out of the `["plugins", …]` family on purpose. `invalidatePlugins` — which every plugin
43+
* write, including the switch below, runs `onSettled` — invalidates by that prefix, and this
44+
* dialog stays mounted and enabled through its own confirm. Sharing the prefix would have the
45+
* switch's own settle re-fire this dry run a moment after the real write, so the last request the
46+
* dialog sent would read as a second preview rather than the switch that just happened.
47+
*/
48+
const preview = useQuery({
49+
queryKey: ["account-mode", serverId, target],
50+
queryFn: () => accountModePreview(serverId, target),
51+
enabled: open,
52+
});
53+
const change = useMutation({
54+
...setAccountModeMutationOptions(queryClient),
55+
onSuccess: () => onOpenChange(false),
56+
});
57+
const count = preview.data?.wouldRevoke.count ?? 0;
58+
const goes =
59+
preview.data?.wouldRevoke.holder === "deployment"
60+
? count > 0
61+
? "The shared account will be disconnected."
62+
: "No shared account is connected."
63+
: `${count} personal ${count === 1 ? "account" : "accounts"} will be disconnected.`;
64+
const action =
65+
target === "shared" ? `Make ${title} shared` : `Make ${title} personal`;
66+
67+
return (
68+
<Dialog onOpenChange={onOpenChange} open={open}>
69+
<DialogContent className="max-w-md">
70+
<DialogHeader>
71+
<DialogTitle>
72+
{target === "shared"
73+
? `Share one ${title} account?`
74+
: `Give everyone their own ${title} account?`}
75+
</DialogTitle>
76+
<DialogDescription>
77+
{preview.data ? goes : "Checking what this would change…"}
78+
</DialogDescription>
79+
</DialogHeader>
80+
{target === "shared" ? (
81+
<div className="mt-4 space-y-3 text-sm">
82+
<p>
83+
Everyone who can use a Bot granted this app will act as this one
84+
account.
85+
</p>
86+
{preview.data?.bots.length ? (
87+
<ul className="space-y-1">
88+
{preview.data.bots.map((bot) => (
89+
<li key={bot.botId}>
90+
<span className="font-medium">
91+
{names[bot.botId] ?? bot.botId}
92+
</span>
93+
: {describeApproval(bot.exposure)}
94+
</li>
95+
))}
96+
</ul>
97+
) : null}
98+
</div>
99+
) : null}
100+
{preview.error || change.error ? (
101+
<p className="mt-4 text-destructive text-sm" role="alert">
102+
{(change.error ?? preview.error)?.message}
103+
</p>
104+
) : null}
105+
<DialogFooter className="mt-4">
106+
<Button
107+
onClick={() => onOpenChange(false)}
108+
size="sm"
109+
variant="outline"
110+
>
111+
Cancel
112+
</Button>
113+
<Button
114+
disabled={!preview.data || change.isPending}
115+
onClick={() => change.mutate({ serverId, mode: target })}
116+
size="sm"
117+
variant="destructive"
118+
>
119+
{action}
120+
</Button>
121+
</DialogFooter>
122+
</DialogContent>
123+
</Dialog>
124+
);
125+
}

‎app/src/components/plugins/brokered-account-row.tsx‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -816,6 +816,8 @@ export function BrokeredAccountRow({
816816
connectedDescription,
817817
disconnectedDescription,
818818
disconnectedReassurance,
819+
heading,
820+
notice,
819821
title,
820822
}: {
821823
account: BrokeredAccount;
@@ -832,6 +834,21 @@ export function BrokeredAccountRow({
832834
* right for both. A screen with nothing of the second kind to say passes nothing.
833835
*/
834836
disconnectedReassurance?: string;
837+
/**
838+
* The row's own title, where "Your account" is wrong for it.
839+
*
840+
* A shared app is nobody's own account, so the screen that draws one passes its own heading
841+
* rather than live with the row's default sense of ownership.
842+
*/
843+
heading?: string;
844+
/**
845+
* A sentence beneath the row's description, for what the description itself is not about.
846+
*
847+
* Separate from `connectedDescription`/`disconnectedDescription` because those two already carry
848+
* everything the connection state decides; a notice is the screen's own fact about the row —
849+
* that it is shared, say — which holds regardless of which of those two is showing.
850+
*/
851+
notice?: string;
835852
/**
836853
* The app's own name, for the sentences that name it.
837854
*
@@ -868,7 +885,7 @@ export function BrokeredAccountRow({
868885
<ItemContent>
869886
{/* Not "Connect your account": the row is also the connected state, and a title has to
870887
read for both. */}
871-
<ItemTitle>Your account</ItemTitle>
888+
<ItemTitle>{heading ?? "Your account"}</ItemTitle>
872889
{/* Unclamped where the key is missing: that sentence is the only place the setting is
873890
named, so it is the point rather than a hint. */}
874891
<ItemDescription
@@ -882,6 +899,13 @@ export function BrokeredAccountRow({
882899
title,
883900
})}
884901
</ItemDescription>
902+
{/* A fact about the row itself rather than about the connection, so it holds under
903+
either of the sentences above and is never clamped away. */}
904+
{notice ? (
905+
<ItemDescription className="line-clamp-none">
906+
{notice}
907+
</ItemDescription>
908+
) : null}
885909
</ItemContent>
886910
{/*
887911
* AN APP THAT NEEDS NO ACCOUNT HAS NOTHING HERE AT ALL — no Connect, and not a disabled one
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
2+
import { Button } from "@/components/ui/button";
3+
import { currentUserQueryOptions } from "@/lib/auth/queries";
4+
import {
5+
botSharedAppsQueryOptions,
6+
requestSharedUseMutationOptions,
7+
} from "@/lib/plugins/shared-use";
8+
9+
/**
10+
* What changing who can reach a Bot means for the shared accounts it uses, said BEFORE the change.
11+
*
12+
* An administrator's change is its own approval, so they are told that and nothing is asked of them.
13+
* Anybody else is told plainly that the shared calls will be refused until an administrator agrees,
14+
* and is given the one button that asks — the alternative being that the first person to find out
15+
* is whoever the refused call was for.
16+
*/
17+
export function SharedAppNotice({
18+
botId,
19+
reason,
20+
}: {
21+
botId: string;
22+
reason: "publish" | "trigger";
23+
}) {
24+
const queryClient = useQueryClient();
25+
const me = useQuery(currentUserQueryOptions()).data;
26+
const shared = useQuery(botSharedAppsQueryOptions(botId));
27+
const ask = useMutation(requestSharedUseMutationOptions(queryClient));
28+
const apps = shared.data?.apps ?? [];
29+
if (apps.length === 0) return null;
30+
31+
if (me?.role === "admin") {
32+
return (
33+
<div className="space-y-1 text-sm">
34+
{apps.map((app) => (
35+
<p key={app.serverId}>
36+
Saving also approves the shared {app.title} account for whoever can
37+
reach this Bot.
38+
</p>
39+
))}
40+
</div>
41+
);
42+
}
43+
44+
const waiting = new Set(
45+
(shared.data?.pending ?? []).map((request) => request.serverId),
46+
);
47+
return (
48+
<div className="space-y-2 text-sm">
49+
{apps.map((app) =>
50+
waiting.has(app.serverId) ? (
51+
<p className="text-muted-foreground" key={app.serverId}>
52+
Waiting for an administrator: shared {app.title}
53+
</p>
54+
) : app.covered ? null : (
55+
<div className="flex flex-wrap items-center gap-2" key={app.serverId}>
56+
<p className="text-amber-700 dark:text-amber-400">
57+
{app.title} calls from this Bot are refused until an administrator
58+
approves who can reach it.
59+
</p>
60+
<Button
61+
aria-label={`Request approval for ${app.title}`}
62+
disabled={ask.isPending}
63+
onClick={() =>
64+
ask.mutate({ botId, serverId: app.serverId, reason })
65+
}
66+
size="sm"
67+
type="button"
68+
variant="outline"
69+
>
70+
Request approval
71+
</Button>
72+
</div>
73+
),
74+
)}
75+
{ask.error ? (
76+
<p className="text-destructive" role="alert">
77+
{ask.error.message}
78+
</p>
79+
) : null}
80+
</div>
81+
);
82+
}

0 commit comments

Comments
 (0)