mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-29 12:59:13 +01:00
Open saved GitHub credential handoffs at the required human step
Preserve import and rotation objectives in the existing setup screen, keep one-time secret copy and retry states bounded, and document authorized self-targeted policy administration. Record fixture evidence separately from live verification. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
f109b3836e
commit
e22d2a0eba
9 files changed
+92
-14
No files matched your search
@@ -398,4 +398,6 @@ Standard agents can configure connections through the same domain APIs used by t
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -2619,4 +2619,6 @@ Standard agents can configure connections through the same domain APIs used by t
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -871,4 +871,6 @@ Standard agents can configure connections through the same domain APIs used by t
|
||||
|
||||
The agent performs authorized setup, reuses saved accounts and drafts, and hands off only required human authentication, consent, credential entry or identity confirmation. Saved handoffs resume through existing task continuation after current authority is rechecked. Management, tool execution and approval remain separate. Private accounts, host commands and stored credentials retain their boundaries; agent-issued gateway clients preserve issuer and responsible-user restrictions at use time.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
The implementation, operation inventory, permission matrix and fixture qualification are recorded in [Agent API parity for connection configuration](plans/2026-10-13-connection-api-parity.md). Provider fixtures establish API behavior; they do not constitute live provider setup or an empirical agent-success claim.
|
||||
@@ -37,3 +37,5 @@ The five default tool grants are usable through the existing connection APIs and
|
||||
Connection management always intersects the current human's authority, including in responsible-user shadow mode. Personal self-service remains available where the human setup surface permits it; sharing and assignments require authority over their targets. Current run/work-mode/trust restrictions, scoped grants and explicit grant removal remain effective. No existing-agent permissions are backfilled. Agent-issued gateway clients preserve this delegation at every use, even after the setup run ends.
|
||||
|
||||
See [the authorization and qualification record](plans/2026-10-13-connection-api-parity.md) and the operation-reviewed [agent access maps](../maps/agent-access-map/connection-setup.md). Human consent, credential entry and identity confirmation remain bounded handoffs to saved setup.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
@@ -25,7 +25,9 @@ The acting principal, responsible user, credential owner and target agent remain
|
||||
|
||||
Agent keys resolve their saved responsible user. Run JWTs require current run identity and assignment. Both fail closed on absent user context, inactive membership, foreign companies, unavailable personal credentials, invalid scopes or low trust. Connection authorization enforces the human ceiling even when general responsible-user authorization runs in shadow mode. `skill_test` and `task_bridge` cannot administer connections. Ask and Plan contexts cannot mutate configuration. Connection/profile/gateway/provider/purpose/operation grant scopes apply to resolved resources, including plural policy selectors and affected targets.
|
||||
|
||||
Services recheck management context after asynchronous provider work before sensitive persistence. Existing revisions, transaction locks, credential leases, vault storage, duplicate prevention and cleanup stay authoritative. Reads filter private accounts, referenced profile/pool resources and task activity. Configuration authority is separate from use: an allowed policy change does not bypass Off/Ask first/Allowed execution policy.
|
||||
Services recheck management context after asynchronous provider work before sensitive persistence. Existing revisions, transaction locks, credential leases, vault storage, duplicate prevention and cleanup stay authoritative. Reads filter private accounts, referenced profile/pool resources and task activity. Configuration authority is separate from use: authorized policy changes, including self-targeted changes, take effect for subsequent calls. Pending governed action requests remain separate and cannot be self-approved.
|
||||
|
||||
Authorized policy administration includes user-requested changes that loosen the acting agent’s own tool controls. These writes remain bounded by management grants, resource scope and the responsible user’s current authority; they do not need an additional approval solely because the agent is a target. Subsequent tool calls obey the resulting policy. Agents cannot approve their own governed action requests or edit their own principal permission grants.
|
||||
|
||||
Binding and audience authorization checks field presence, including `null`, `false` and empty arrays. Clearing an assignment or reducing an audience remains a profile-management operation after an explicit grant revocation. Gateway binding changes check both the saved and replacement profile/project/task contexts; reauthorization reloads the saved side after asynchronous work. Referenced resources require each applicable connection and profile permission, including the replacement side of a project binding.
|
||||
|
||||
@@ -152,3 +154,8 @@ The implementation was rebased onto `a1f77821b9cd5c9c846fdf7a0fa5cc620ad2b6bb`.
|
||||
- CI on implementation `271740cc19` failed a browser shard when run cancellation hit a database lock timeout. Companion `4ad17e94d2` failed the Runner static suite when its real Claude descriptor fixture reported an unverified surviving process group; that exact test passed in an isolated local run. Both failures remain recorded; fresh current-head CI is required after the security fix.
|
||||
|
||||
- The OpenAPI setup-secret response now distinguishes the agent credential-entry handoff (200) from human-only secret generation (201). All **27 OpenAPI contract tests passed**, the focused actual-auth GitHub flow passed, and server typecheck passed after this contract update.
|
||||
|
||||
- The maintainer confirmed authorized policy administration, including self-targeted changes. The new actual-auth scenario changes an agent’s own Ask-first rule through an authorized policy write, proves the next call follows the new policy, preserves self-approval denial, and rejects further policy writes after grant revocation. All **42 assembled-server scenarios passed** in fresh PostgreSQL databases.
|
||||
- Existing-App import now hands off to the human credential screen; agents cannot persist a caller-chosen signing key. Rotation requests record a separate saved objective and do not complete from old connection health. The rotation generation and cleared webhook verification commit atomically with new vault references; completion requires a newer generation and restored verified health. The regression was red before the fix. No best-effort audit or clock comparison establishes completion.
|
||||
- Saved import and rotation links open the matching credential screen. The rotation pane exposes a one-time copy action, waits for explicit human generation, and preserves retry behavior through generation/reconnect failures. Both new UI regressions were red before the fix; all **35 GitHub wizard tests passed**, and token gates passed. The provider verification state in the assembled-server test is a deterministic fixture; live GitHub rotation remains unqualified.
|
||||
- Implementation `35585448ef` hit a 30-second module-load hook timeout in `company-skills-routes`; that suite passed in a focused local run. The combined route/contract selection passed **90 tests**, followed by all **27 OpenAPI contract tests** after the final server contract update. The companion’s previous complete CI was green at `d84538f8a3`. Fresh CI on the credential-handoff changes remains required. Entire was removed from the expected gates by the maintainer and is no longer a completion dependency.
|
||||
@@ -211,11 +211,11 @@ Gaps: This individual operation has source review but no recorded HTTP verificat
|
||||
### `post-chat-endpoints-github-app`
|
||||
|
||||
API: `present` — `POST /api/chat-endpoints/{endpointId}/github/app`; JSON REST.
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api`.
|
||||
Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user.
|
||||
Evidence: `verified` — [route](../../server/src/routes/chat-channels.ts); reviewed in the working tree based on the source commit above. [Authenticated fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed with database persistence and actual bearer/session middleware.
|
||||
Verification: `passed` — deterministic provider fixtures / local assembled server; no live provider qualification.
|
||||
Gaps: Provider fixtures do not qualify live provider setup or availability.
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api` return a saved human credential-entry handoff; agents may send an empty body.
|
||||
Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). A human session imports the App credentials and signing key. Delegated service import is rejected, and agent requests persist no caller-chosen key.
|
||||
Evidence: `verified` — [route](../../server/src/routes/chat-channels.ts), [provider service](../../server/src/services/chat-channels/github/app-setup.ts), and [actual-auth fixture](../../server/src/__tests__/connection-management.integration.test.ts). Agent key/JWT handoffs leave vault refs unchanged; human import persists credentials, then agent repository discovery and verification continue.
|
||||
Verification: `passed` — deterministic provider fixtures, assembled server and [saved-screen tests](../../ui/src/pages/apps/chat/GitHubChatSetup.test.tsx).
|
||||
Gaps: Human credential entry is intentional; no live provider qualification.
|
||||
|
||||
### `post-chat-endpoints-github-repositories-refresh`
|
||||
|
||||
@@ -277,7 +277,7 @@ API: `present` — `POST /api/chat-endpoints/{endpointId}/setup-secret`; JSON RE
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api` return the saved endpoint's `requiredUserAction` handoff. Only a human session can rotate and receive the signing secret.
|
||||
Policy: `tools:manage_connections`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Agents cannot generate or export the GitHub webhook signing secret; the service also rejects delegated generation.
|
||||
Evidence: `verified` — [route](../../server/src/routes/chat-channels.ts), [provider service](../../server/src/services/chat-channels/github/setup-secret.ts), and [authenticated regression](../../server/src/__tests__/connection-management.integration.test.ts). Actual key/JWT requests return a handoff without changing credential refs; a human session can rotate and persist new refs.
|
||||
Verification: `passed` — deterministic provider fixtures / local assembled server. The regression failed before the fix; all 41 assembled-server scenarios passed afterward.
|
||||
Verification: `passed` — deterministic provider fixtures / local assembled server. The regression failed before the fix; all 42 assembled-server scenarios passed afterward. Rotation remains pending through old health and generation/reconnect failures. A completion marker commits with new vault refs and cleared webhook verification; restored verified health is also required. Saved links open the one-time human rotation screen.
|
||||
Gaps: No live GitHub rotation qualification; plaintext signing-secret setup intentionally remains a human step.
|
||||
|
||||
### `get-chat-endpoints-resources`
|
||||
|
||||
@@ -72,6 +72,7 @@ vi.mock("@/api/chatEndpoints", () => ({
|
||||
get: vi.fn(),
|
||||
listResources: vi.fn(),
|
||||
setup: vi.fn(),
|
||||
generateSetupSecret: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock("@/api/projects", () => ({ projectsApi: { repositoryOptions: vi.fn() } }));
|
||||
@@ -257,6 +258,43 @@ describe("GitHub App wizard", () => {
|
||||
expect(container.textContent).not.toContain("GitHub connected");
|
||||
expect(githubChatApi.registration).not.toHaveBeenCalled();
|
||||
});
|
||||
it("opens a saved credential-entry handoff without starting another App registration", async () => {
|
||||
await render("resume=draft-1&stage=credentials");
|
||||
expect(container.querySelector("#github-app-id")).not.toBeNull();
|
||||
expect(container.querySelector("#github-webhook-secret")).not.toBeNull();
|
||||
expect(githubChatApi.advance).not.toHaveBeenCalled();
|
||||
expect(githubChatApi.registration).not.toHaveBeenCalled();
|
||||
});
|
||||
it("keeps a saved rotation handoff open through generation and reconnect failures", async () => {
|
||||
fixture.endpoint.status = "active";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
const secret = "a".repeat(64);
|
||||
vi.mocked(githubChatApi.advance).mockResolvedValue({ endpointId: "draft-1", state: "connected" } as any);
|
||||
vi.mocked(chatEndpointsApi.generateSetupSecret).mockRejectedValueOnce(new Error("Temporarily unavailable")).mockResolvedValue({ webhookSecret: secret });
|
||||
vi.mocked(chatEndpointsApi.setup).mockRejectedValueOnce(new Error("Waiting for the new signed webhook")).mockResolvedValue(fixture.endpoint);
|
||||
await render("resume=draft-1&stage=credentials&reconnect=1&rotateSecret=1");
|
||||
const reconnect = () => [...container.querySelectorAll("button")].find(button => button.getAttribute("aria-label") === "Reconnect saved App")!;
|
||||
const generate = () => [...container.querySelectorAll("button")].find(button => button.textContent === "Generate webhook secret")!;
|
||||
expect(reconnect().disabled).toBe(true);
|
||||
expect(chatEndpointsApi.generateSetupSecret).not.toHaveBeenCalled();
|
||||
expect(githubChatApi.advance).not.toHaveBeenCalled();
|
||||
await act(async () => generate().click());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Temporarily unavailable");
|
||||
expect(reconnect().disabled).toBe(true);
|
||||
await act(async () => generate().click());
|
||||
expect(chatEndpointsApi.generateSetupSecret).toHaveBeenLastCalledWith("draft-1");
|
||||
expect((container.querySelector("#github-rotation-secret") as HTMLInputElement).value).toBe(secret);
|
||||
expect((container.querySelector("#github-rotation-secret") as HTMLInputElement).type).toBe("password");
|
||||
await act(async () => (container.querySelector('[aria-label="Copy webhook secret"]') as HTMLButtonElement).click());
|
||||
expect(copyTextToClipboard).toHaveBeenLastCalledWith(secret);
|
||||
await act(async () => reconnect().click());
|
||||
expect(container.querySelector('[role="alert"]')?.textContent).toContain("Waiting for the new signed webhook");
|
||||
expect(reconnect().disabled).toBe(false);
|
||||
await act(async () => reconnect().click());
|
||||
expect(chatEndpointsApi.setup).toHaveBeenLastCalledWith("draft-1", { action: "reconnect" });
|
||||
expect(githubChatApi.connectApp).not.toHaveBeenCalled();
|
||||
expect(chatEndpointsApi.create).not.toHaveBeenCalled();
|
||||
});
|
||||
it("reconnects the same App using vaulted credentials without requiring another paste", async () => {
|
||||
fixture.endpoint.status = "attention";
|
||||
fixture.endpoint.botExternalId = "1234";
|
||||
|
||||
@@ -80,7 +80,8 @@ export function GitHubChatSetup() {
|
||||
const queryClient = useQueryClient();
|
||||
const resume = params.get("resume");
|
||||
const identityOnly = params.get("stage") === "identity";
|
||||
const reconnect = params.get("reconnect") === "1";
|
||||
const rotateWebhookSecret = params.get("rotateSecret") === "1";
|
||||
const reconnect = params.get("reconnect") === "1" || rotateWebhookSecret;
|
||||
const [agentId, setAgentId] = useState(params.get("agentId") ?? "");
|
||||
const [ownerType, setOwnerType] = useState<"personal" | "organization">(
|
||||
"personal",
|
||||
@@ -91,7 +92,7 @@ export function GitHubChatSetup() {
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [submitManifest, setSubmitManifest] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [existing, setExisting] = useState(params.get("reconnect") === "1");
|
||||
const [existing, setExisting] = useState(reconnect || params.get("stage") === "credentials");
|
||||
const [appNotCreated, setAppNotCreated] = useState(false);
|
||||
const [legacyAccount, setLegacyAccount] = useState("");
|
||||
const [identityLinked, setIdentityLinked] = useState(false);
|
||||
@@ -378,6 +379,7 @@ export function GitHubChatSetup() {
|
||||
key={bot.id}
|
||||
endpoint={bot}
|
||||
reconnect={reconnect}
|
||||
rotateWebhookSecret={rotateWebhookSecret}
|
||||
onExit={exit}
|
||||
onConnect={async (credentials) => {
|
||||
if (reconnect || ["active", "paused", "revoked"].includes(bot.status)) {
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { AlertTriangle, CircleHelp, ExternalLink, Loader2 } from "lucide-react";
|
||||
import type { ChatEndpoint } from "@/api/chatEndpoints";
|
||||
import { chatEndpointsApi } from "@/api/chatEndpoints";
|
||||
import { CopyText } from "@/components/CopyText";
|
||||
import { SetupWizardFooter } from "@/components/SetupWizard";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
@@ -40,9 +42,10 @@ function CredentialLabel({ id, label, help }: { id: string; label: string; help:
|
||||
</div>;
|
||||
}
|
||||
|
||||
export function GitHubAppRecovery({ endpoint, reconnect, onConnect, onExit }: {
|
||||
export function GitHubAppRecovery({ endpoint, reconnect, rotateWebhookSecret = false, onConnect, onExit }: {
|
||||
endpoint: ChatEndpoint;
|
||||
reconnect: boolean;
|
||||
rotateWebhookSecret?: boolean;
|
||||
onConnect: (credentials?: GitHubAppCredentials) => Promise<void>;
|
||||
onExit: () => void;
|
||||
}) {
|
||||
@@ -52,6 +55,7 @@ export function GitHubAppRecovery({ endpoint, reconnect, onConnect, onExit }: {
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [fileError, setFileError] = useState("");
|
||||
const [rotationSecret, setRotationSecret] = useState("");
|
||||
const needsWebhookSecret = !reconnect && !["active", "paused", "revoked"].includes(endpoint.status);
|
||||
const appName = endpoint.botLabel ?? endpoint.setup?.github?.appName ?? "your existing App";
|
||||
const appIdInvalid = !!credentials.appId.trim() && !/^\d+$/.test(credentials.appId.trim());
|
||||
@@ -66,17 +70,36 @@ export function GitHubAppRecovery({ endpoint, reconnect, onConnect, onExit }: {
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not reconnect GitHub. Try again."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
async function generateSecret() {
|
||||
setPending(true);
|
||||
setError("");
|
||||
setRotationSecret("");
|
||||
try { setRotationSecret((await chatEndpointsApi.generateSetupSecret(endpoint.id)).webhookSecret); }
|
||||
catch (cause) { setError(cause instanceof Error ? cause.message : "Could not generate the webhook secret. Try again."); }
|
||||
finally { setPending(false); }
|
||||
}
|
||||
return <div className="space-y-6">
|
||||
{reconnect && <div className="space-y-3">
|
||||
<p className="text-sm">Restore <strong>{appName}</strong> using its saved credentials. Paperclip will check the App and its installation, then repair event delivery.</p>
|
||||
<p className="text-sm text-muted-foreground">Your agent, repository choices, review settings, and task history stay attached to this connection. You don’t need to create another GitHub App.</p>
|
||||
</div>}
|
||||
{rotateWebhookSecret && <section aria-label="Rotate webhook secret" className="space-y-3">
|
||||
<h2 className="text-base font-semibold">Rotate the webhook secret</h2>
|
||||
<p className="text-sm">Generate a new secret, then replace the webhook secret in <a href={gitHubAppSettingsUrl(endpoint)} target="_blank" rel="noreferrer" className="underline">GitHub App settings</a>. GitHub must verify the new secret before you reconnect.</p>
|
||||
<p className="text-sm text-muted-foreground">Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.</p>
|
||||
<Button type="button" variant="outline" disabled={pending} onClick={() => void generateSecret()}>Generate webhook secret</Button>
|
||||
{rotationSecret && <div className="space-y-2">
|
||||
<Label htmlFor="github-rotation-secret">New webhook secret</Label>
|
||||
<Input id="github-rotation-secret" type="password" autoComplete="off" readOnly value={rotationSecret} />
|
||||
<CopyText text={rotationSecret} ariaLabel="Copy webhook secret" className="text-sm underline">Copy webhook secret</CopyText>
|
||||
</div>}
|
||||
</section>}
|
||||
{error && <div role="alert" className="space-y-2 rounded-lg border border-destructive/40 bg-destructive/5 p-4 text-sm">
|
||||
<p className="font-medium">{replace ? "Could not reconnect this App" : "Could not reconnect the saved App"}</p>
|
||||
<p className="font-medium">{rotateWebhookSecret ? "Could not finish secret rotation" : replace ? "Could not reconnect this App" : "Could not reconnect the saved App"}</p>
|
||||
<p className="break-words">{error}</p>
|
||||
<p>Try again if the service is temporarily unavailable. If GitHub rejected the saved key, replace the credentials for this same App below.</p>
|
||||
<p>{rotateWebhookSecret ? "Use the newest secret in GitHub App settings, then retry reconnecting after GitHub verifies delivery." : "Try again if the service is temporarily unavailable. If GitHub rejected the saved key, replace the credentials for this same App below."}</p>
|
||||
</div>}
|
||||
{reconnect && <Button variant="link" className="h-auto p-0" disabled={pending} onClick={() => { setReplace(!replace); setError(""); }}>
|
||||
{reconnect && !rotateWebhookSecret && <Button variant="link" className="h-auto p-0" disabled={pending} onClick={() => { setReplace(!replace); setError(""); }}>
|
||||
{replace ? "Use saved credentials instead" : "Replace App credentials…"}
|
||||
</Button>}
|
||||
{replace && <div className="space-y-6">
|
||||
@@ -123,7 +146,7 @@ export function GitHubAppRecovery({ endpoint, reconnect, onConnect, onExit }: {
|
||||
</div>}
|
||||
</div>}
|
||||
<SetupWizardFooter onSaveExit={onExit} disabled={pending}>
|
||||
<Button aria-label={actionLabel} disabled={pending || incomplete} onClick={() => void connect()}>
|
||||
<Button aria-label={actionLabel} disabled={pending || incomplete || (rotateWebhookSecret && !rotationSecret)} onClick={() => void connect()}>
|
||||
{pending && <Loader2 className="size-4 animate-spin" aria-hidden="true" />}
|
||||
<span className="sm:hidden" aria-hidden="true">{pending ? "Checking…" : reconnect ? "Reconnect" : "Connect"}</span>
|
||||
<span className="hidden sm:inline" aria-hidden="true">{actionLabel}</span>
|
||||
|
||||
Reference in new issue
Block a user