Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 18 additions & 13 deletions src/shared/components/OAuthModal.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useState, useEffect, useRef, useCallback } from "react";
import PropTypes from "prop-types";
import { Modal, Button, Input } from "@/shared/components";
import { useCopyToClipboard } from "@/shared/hooks/useCopyToClipboard";
import { parseResponseBody, getResponseErrorMessage } from "@/shared/utils/api";

/**
* OAuth Modal Component
Expand Down Expand Up @@ -55,8 +56,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
}),
});

const data = await res.json();
if (!res.ok) throw new Error(data.error);
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth exchange failed"));

setStep("success");
onSuccess?.();
Expand All @@ -74,8 +75,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ code, state: authData.state }),
});
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "xAI manual code exchange failed"));

setStep("success");
onSuccess?.();
Expand Down Expand Up @@ -115,7 +116,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
body: JSON.stringify({ deviceCode, codeVerifier, extraData }),
});

const data = await res.json();
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth polling failed"));

if (data.success) {
pollingAbortRef.current = true; // Stop polling immediately
Expand Down Expand Up @@ -166,8 +168,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
deviceCodeUrl.searchParams.set("auth_method", "idc");
}
const res = await fetch(deviceCodeUrl.toString());
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth device-code request failed"));

setDeviceData(data);

Expand Down Expand Up @@ -207,8 +209,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
Object.entries(oauthMeta).forEach(([k, v]) => { if (v) authorizeUrl.searchParams.set(k, v); });
}
const res = await fetch(authorizeUrl.toString());
const data = await res.json();
if (!res.ok) throw new Error(data.error);
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth authorization failed"));

// Codex: start proxy with server-side session (auto-exchange) + fallback to channels
let codexProxyActive = false;
Expand All @@ -221,7 +223,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
proxyUrl.searchParams.set("code_verifier", data.codeVerifier);
proxyUrl.searchParams.set("redirect_uri", redirectUri);
const proxyRes = await fetch(proxyUrl.toString());
const proxyData = await proxyRes.json();
const proxyData = await parseResponseBody(proxyRes);
if (!proxyRes.ok) throw new Error(getResponseErrorMessage(proxyRes, proxyData, "Codex OAuth proxy failed"));
codexProxyActive = proxyData.success;
codexServerSide = !!proxyData.serverSide;
} catch {
Expand All @@ -240,7 +243,8 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
proxyUrl.searchParams.set("code_verifier", data.codeVerifier);
proxyUrl.searchParams.set("redirect_uri", redirectUri);
const proxyRes = await fetch(proxyUrl.toString());
const proxyData = await proxyRes.json();
const proxyData = await parseResponseBody(proxyRes);
if (!proxyRes.ok) throw new Error(getResponseErrorMessage(proxyRes, proxyData, "Grok Build OAuth proxy failed"));
xaiProxyActive = proxyData.success;
xaiServerSide = !!proxyData.serverSide;
if (!xaiProxyActive && proxyData.reason === "port_busy") {
Expand Down Expand Up @@ -321,8 +325,9 @@ export default function OAuthModal({ isOpen, provider, providerInfo, onSuccess,
if (cancelled || callbackProcessedRef.current) return;
attempts += 1;
try {
const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`);
const data = await res.json();
const res = await fetch(`/api/oauth/${pollProvider}/poll-status?state=${encodeURIComponent(authData.state)}`);
const data = await parseResponseBody(res);
if (!res.ok) throw new Error(getResponseErrorMessage(res, data, "OAuth status polling failed"));
if (cancelled || callbackProcessedRef.current) return;
if (data.status === "done") {
callbackProcessedRef.current = true;
Expand Down
31 changes: 28 additions & 3 deletions src/shared/utils/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,36 @@ export async function del(url, options = {}) {
* @param {Response} response - Fetch response
* @returns {Promise<object>}
*/
async function handleResponse(response) {
const data = await response.json();
export async function parseResponseBody(response) {
const text = await response.text();
if (!text) return {};

try {
return JSON.parse(text);
} catch {
return { error: text.trim() || "Invalid non-JSON response", rawText: text };
}
}

export function getResponseErrorMessage(response, data = {}, fallback = "Request failed") {
const rawError = data.error || data.errorDescription || data.message;
const message = typeof rawError === "object"
? rawError.message || JSON.stringify(rawError)
: rawError;
const cleanMessage = String(message || fallback).trim();
if (!response) return cleanMessage;

const statusLabel = response.status
? `${response.status}${response.statusText ? ` ${response.statusText}` : ""}`
: "";
return statusLabel ? `${fallback} (${statusLabel}): ${cleanMessage}` : cleanMessage;
}

export async function handleResponse(response) {
const data = await parseResponseBody(response);

if (!response.ok) {
const error = new Error(data.error || "An error occurred");
const error = new Error(getResponseErrorMessage(response, data, "An error occurred"));
error.status = response.status;
error.data = data;
throw error;
Expand Down
47 changes: 47 additions & 0 deletions tests/unit/api-response.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";

import {
getResponseErrorMessage,
handleResponse,
parseResponseBody,
} from "../../src/shared/utils/api.js";

describe("shared API response helpers", () => {
it("parses JSON responses", async () => {
const response = new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { "Content-Type": "application/json" },
});

await expect(parseResponseBody(response)).resolves.toEqual({ ok: true });
});

it("turns non-JSON error responses into usable error data", async () => {
const response = new Response("Internal Server Error", {
status: 500,
statusText: "Internal Server Error",
headers: { "Content-Type": "text/plain" },
});

const data = await parseResponseBody(response);

expect(data.error).toBe("Internal Server Error");
expect(getResponseErrorMessage(response, data, "OAuth authorization failed")).toBe(
"OAuth authorization failed (500 Internal Server Error): Internal Server Error"
);
});

it("throws structured errors for JSON API failures", async () => {
const response = new Response(JSON.stringify({ error: "Nope" }), {
status: 400,
statusText: "Bad Request",
headers: { "Content-Type": "application/json" },
});

await expect(handleResponse(response)).rejects.toMatchObject({
message: "An error occurred (400 Bad Request): Nope",
status: 400,
data: { error: "Nope" },
});
});
});