diff --git a/ui/src/ui/connect-error.node.test.ts b/ui/src/ui/connect-error.node.test.ts index 245156f92ec..71f81622884 100644 --- a/ui/src/ui/connect-error.node.test.ts +++ b/ui/src/ui/connect-error.node.test.ts @@ -26,4 +26,17 @@ describe("formatConnectError", () => { }), ).toBe("gateway pairing required: device is not approved yet"); }); + + it("preserves surfaced pending approvals", () => { + expect( + formatConnectError({ + message: "scope upgrade pending approval (requestId: req-123)", + details: { + code: "PAIRING_REQUIRED", + reason: "scope-upgrade", + requestId: "req-123", + }, + }), + ).toBe("scope upgrade pending approval (requestId: req-123)"); + }); }); diff --git a/ui/src/ui/connect-error.ts b/ui/src/ui/connect-error.ts index 6797620ffa9..ffdfd04e7a9 100644 --- a/ui/src/ui/connect-error.ts +++ b/ui/src/ui/connect-error.ts @@ -1,6 +1,9 @@ import { ConnectErrorDetailCodes, + describePairingConnectRequirement, formatConnectPairingRequiredMessage, + readConnectPairingRequiredMessage, + readPairingConnectErrorDetails, } from "../../../src/gateway/protocol/connect-error-details.js"; import { resolveGatewayErrorDetailCode } from "./gateway.ts"; import { normalizeLowercaseStringOrEmpty } from "./string-coerce.ts"; @@ -20,6 +23,41 @@ function normalizeErrorMessage(message: unknown): string { return "unknown error"; } +function formatPairingRequiredError(error: ErrorWithMessageAndDetails): string { + const message = normalizeErrorMessage(error.message); + const normalizedMessage = normalizeLowercaseStringOrEmpty(message); + const pairing = readPairingConnectErrorDetails(error.details); + const pairingMessage = readConnectPairingRequiredMessage(message); + const pairingReason = pairing?.reason ?? pairingMessage?.reason; + if (normalizedMessage.startsWith("pairing required:") && pairingReason) { + return `gateway pairing required: ${describePairingConnectRequirement(pairingReason)}`; + } + if (pairingMessage && normalizedMessage !== "pairing required") { + return message; + } + + const approvedRoles = pairing?.approvedRoles?.join(", ") ?? "none"; + const requestedRole = pairing?.requestedRole ?? "none"; + const approvedScopes = pairing?.approvedScopes?.join(", ") ?? "none"; + const requestedScopes = pairing?.requestedScopes?.join(", ") ?? "none"; + switch (pairing?.reason) { + case "scope-upgrade": + if (pairing.approvedScopes || pairing.requestedScopes) { + return `device scope upgrade requires approval (approved: ${approvedScopes}; requested: ${requestedScopes})`; + } + return formatConnectPairingRequiredMessage(error.details); + case "role-upgrade": + if (pairing.approvedRoles || pairing.requestedRole) { + return `device role upgrade requires approval (approved: ${approvedRoles}; requested: ${requestedRole})`; + } + return formatConnectPairingRequiredMessage(error.details); + case "metadata-upgrade": + return "device reconnect details changed and require approval"; + default: + return "gateway pairing required"; + } +} + function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): string { const message = normalizeErrorMessage(error.message); const detailCode = resolveGatewayErrorDetailCode(error); @@ -32,7 +70,7 @@ function formatErrorFromMessageAndDetails(error: ErrorWithMessageAndDetails): st case ConnectErrorDetailCodes.AUTH_RATE_LIMITED: return "too many failed authentication attempts"; case ConnectErrorDetailCodes.PAIRING_REQUIRED: - return formatConnectPairingRequiredMessage(error.details); + return formatPairingRequiredError(error); case ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED: return "device identity required (use HTTPS/localhost or allow insecure auth explicitly)"; case ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED: