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"; import { Label } from "@/components/ui/label"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Link } from "@/lib/router"; import { gitHubAppSettingsUrl } from "./GitHubAppIdentity"; export function GitHubConnectionRecoveryNotice({ endpoint }: { endpoint: ChatEndpoint }) { if (!["attention", "revoked"].includes(endpoint.status)) return null; return (
); } export const GITHUB_APP_CREDENTIAL_FIELDS = ["privateKey", "webhookSecret", "clientSecret"] as const; export type GitHubAppCredentialField = typeof GITHUB_APP_CREDENTIAL_FIELDS[number]; export type GitHubAppCredentials = { appId: string; privateKey: string; webhookSecret: string; clientSecret?: string }; function CredentialLabel({ id, label, help }: { id: string; label: string; help: string }) { return
{help}
; } export function GitHubAppRecovery({ endpoint, reconnect, credentialFields, requireAllCredentialFields = false, rotateWebhookSecret = false, rotationAlreadyCommitted = false, onConnect, onExit }: { endpoint: ChatEndpoint; reconnect: boolean; credentialFields?: readonly GitHubAppCredentialField[]; requireAllCredentialFields?: boolean; rotateWebhookSecret?: boolean; rotationAlreadyCommitted?: boolean; onConnect: (credentials?: GitHubAppCredentials) => Promise; onExit: () => void; }) { const [replace, setReplace] = useState(!!credentialFields || !reconnect); const [credentials, setCredentials] = useState({ appId: endpoint.botExternalId ?? "", privateKey: "", webhookSecret: "" }); const [privateKeyDraft, setPrivateKeyDraft] = useState(""); const [pending, setPending] = useState(false); const [error, setError] = useState(""); const [fileError, setFileError] = useState(""); const [rotationSecret, setRotationSecret] = useState(""); const [rotationGeneration, setRotationGeneration] = useState(null); const [rotationUncertain, setRotationUncertain] = useState(false); const needsPrivateKey = !credentialFields || credentialFields.includes("privateKey"); const needsWebhookSecret = credentialFields ? credentialFields.includes("webhookSecret") : !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()); const keyInvalid = !!credentials.privateKey.trim() && !/-----BEGIN (?:RSA )?PRIVATE KEY-----/.test(credentials.privateKey); const keyLoaded = !!credentials.privateKey.trim() && !keyInvalid && !privateKeyDraft; const incomplete = credentialFields ? !credentialFields.length || appIdInvalid || keyInvalid || (requireAllCredentialFields ? credentialFields.some(field => !credentials[field]?.trim()) : credentialFields.every(field => !credentials[field]?.trim())) : replace && (!credentials.appId.trim() || !credentials.privateKey.trim() || appIdInvalid || keyInvalid || (needsWebhookSecret && !credentials.webhookSecret.trim())); const actionLabel = pending ? "Checking GitHub…" : replace ? (reconnect ? "Verify and reconnect" : "Connect existing App") : "Reconnect saved App"; async function connect() { setPending(true); setError(""); try { await onConnect(replace ? credentials : undefined); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not reconnect GitHub. Try again."); } finally { setPending(false); } } async function checkSavedRotation(expected: string | null | undefined) { try { const saved = await chatEndpointsApi.get(endpoint.id); if (expected !== undefined && (saved.setup?.credentialRotationId ?? null) === expected) { setRotationUncertain(false); return true; } setRotationSecret(""); setRotationGeneration(null); setError("The webhook secret changed, but this page did not receive it. Generate another secret and update GitHub."); } catch { // A failed status read cannot establish that the earlier secret is current. } setRotationUncertain(true); return false; } async function generateSecret() { const previousGeneration = rotationGeneration ?? (rotationSecret ? undefined : endpoint.setup?.credentialRotationId ?? null); setPending(true); setError(""); try { const result = await chatEndpointsApi.generateSetupSecret(endpoint.id); setRotationSecret(result.webhookSecret); setRotationGeneration(result.credentialRotationId ?? null); setRotationUncertain(false); } catch (cause) { setError(cause instanceof Error ? cause.message : "Could not generate the webhook secret. Try again."); setRotationUncertain(true); await checkSavedRotation(previousGeneration); } finally { setPending(false); } } return
{reconnect &&

Restore {appName} using its saved credentials. Paperclip will check the App and its installation, then repair event delivery.

Your agent, repository choices, review settings, and task history stay attached to this connection. You don’t need to create another GitHub App.

} {rotateWebhookSecret &&

Rotate the webhook secret

{rotationAlreadyCommitted ? "The rotated secret is saved. Reconnect using that value; Paperclip will check GitHub’s signed delivery." : <>Generate a new secret, then replace the webhook secret in GitHub App settings. GitHub must verify the new secret before you reconnect.}

Generating immediately invalidates the previous secret. This value is shown only here; keep this page open until you have copied it.

{rotationUncertain &&

The active secret is not confirmed. Check its saved status before using an earlier value.

} {rotationUncertain && rotationSecret && } {rotationSecret && !rotationUncertain &&
{!pending && Copy webhook secret}
}
} {error &&

{rotateWebhookSecret ? "Could not finish secret rotation" : replace ? "Could not reconnect this App" : "Could not reconnect the saved App"}

{error}

{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."}

} {reconnect && !rotateWebhookSecret && !credentialFields && } {replace &&

{reconnect ? "Replace credentials for the same App" : "Connect your existing GitHub App"}

Open GitHub App settings and choose {appName}.

If it’s owned by an organization, open that organization’s Settings → Developer settings → GitHub Apps → Edit. You’ll need permission to manage the App.

{credentialFields &&

Enter the requested credentials below. Other saved credentials stay in place.

}
{needsPrivateKey &&

Copy App ID from the App’s settings page. {reconnect && "The saved ID is filled in when available."}

setCredentials({ ...credentials, appId: event.target.value })} /> {appIdInvalid &&

Use the numeric App ID, not the Client ID.

}
} {needsPrivateKey &&
  1. In the same App’s settings, open Credentials → Key pairs → New key. On the older settings page, use Private keys → Generate a private key.
  2. Upload the downloaded .pem file below, or paste its entire contents.

You can reuse a .pem file you already saved. GitHub cannot show an old private key again. Creating a new key for the same App keeps its installation.

{ const file = event.target.files?.[0]; if (!file) return; setFileError(""); try { const privateKey = await file.text(); setCredentials(current => ({ ...current, privateKey })); setPrivateKeyDraft(""); } catch { setFileError("Could not read this file. Try again or paste the key below."); } }} /> { event.preventDefault(); setCredentials({ ...credentials, privateKey: event.clipboardData.getData("text") }); setPrivateKeyDraft(""); setFileError(""); }} onChange={event => { setPrivateKeyDraft(event.target.value); setCredentials({ ...credentials, privateKey: event.target.value }); }} /> {keyLoaded &&

Private key loaded. Paste or upload another key to replace it.

} {keyInvalid &&

Use a PEM private key, including its BEGIN and END lines. A token or client secret won’t work here.

} {fileError &&

{fileError}

} GitHub’s private key instructions
} {needsWebhookSecret &&

Use the secret saved when you configured this App’s webhook. If you no longer have it, change the webhook secret in GitHub App settings and enter the same value here.

setCredentials({ ...credentials, webhookSecret: event.target.value })} />
} {credentialFields?.includes("clientSecret") &&
setCredentials({ ...credentials, clientSecret: event.target.value })} />
}
}
; }