feat(ui): sign in to a ChatGPT subscription from LLM Credentials

Choosing ChatGPT in the credential form now asks for the owning team or Key ID and runs the device-code sign-in in a dialog that shows the code, polls until the user approves, and lets the admin retry or start over when the code expires. Editing a ChatGPT credential offers "Sign in again" to rotate it, and the generated API types include the two new endpoints
This commit is contained in:
mateo-berri 2026-10-29 10:02:04 -07:00
parent 9a1e20f674
commit ae96eae705
13 changed files with 1043 additions and 21 deletions

View file

@ -0,0 +1,88 @@
import TeamDropdown from "@/components/common_components/team_dropdown";
import { requiredRule } from "@/components/common_components/formRules";
import { MountedFormField } from "@/components/common_components/MountedFormField";
import { labelWithHint } from "@/components/shared/form/LabelWithHint";
import { Input } from "@/components/ui/input";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { CHATGPT_OWNER_FIELDS, type ChatGPTOwnerKind } from "./chatgpt_sign_in_flow";
const OWNER_KIND_LABEL_ID = "chatgpt_owner_kind_label";
interface ChatGPTOwnerFieldsProps {
readonly ownerKind: ChatGPTOwnerKind;
readonly onOwnerKindChange: (ownerKind: ChatGPTOwnerKind) => void;
readonly storedOwnerTeamId: string | null;
readonly storedOwnerKeyHash: string | null;
}
export default function ChatGPTOwnerFields({
ownerKind,
onOwnerKindChange,
storedOwnerTeamId,
storedOwnerKeyHash,
}: ChatGPTOwnerFieldsProps) {
return (
<>
<div className="mb-4 flex flex-col gap-2">
<span id={OWNER_KIND_LABEL_ID} className="text-sm font-medium">
{labelWithHint("Owner:", "Only the team or virtual key you pick can use this ChatGPT subscription.")}
</span>
<RadioGroup
aria-labelledby={OWNER_KIND_LABEL_ID}
value={ownerKind}
onValueChange={(value) => onOwnerKindChange(value as ChatGPTOwnerKind)}
className="flex w-auto items-center gap-4"
>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="team" />
Team
</label>
<label className="flex items-center gap-2 text-sm">
<RadioGroupItem value="key" />
Virtual key
</label>
</RadioGroup>
</div>
{ownerKind === "team" ? (
<MountedFormField
key={CHATGPT_OWNER_FIELDS.team}
label="Team:"
name={CHATGPT_OWNER_FIELDS.team}
required
defaultValue={storedOwnerTeamId}
rules={{ validate: { required: requiredRule("Pick the team that owns this login") } }}
className="mb-4"
>
{(control) => (
<TeamDropdown
id={control.id}
value={typeof control.value === "string" ? control.value : null}
onChange={control.onChange}
/>
)}
</MountedFormField>
) : (
<MountedFormField
key={CHATGPT_OWNER_FIELDS.key}
label="Key ID:"
name={CHATGPT_OWNER_FIELDS.key}
required
defaultValue={storedOwnerKeyHash}
rules={{ validate: { required: requiredRule("Enter the ID of the key that owns this login") } }}
className="mb-4"
>
{(control) => (
<Input
id={control.id}
value={typeof control.value === "string" ? control.value : ""}
onChange={control.onChange}
onBlur={control.onBlur}
placeholder="The Key ID shown on the Virtual Keys page"
/>
)}
</MountedFormField>
)}
</>
);
}

View file

@ -0,0 +1,117 @@
import { act, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import ChatGPTSignInModal from "./ChatGPTSignInModal";
const networking = vi.hoisted(() => ({
chatGPTLoginStartCall: vi.fn(),
chatGPTLoginPollCall: vi.fn(),
}));
vi.mock("@/components/networking", () => networking);
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: () => ({ accessToken: "session-token" }) }));
const START_RESPONSE = {
user_code: "WXYZ-9876",
verification_uri: "https://auth.openai.com/codex/device",
expires_in: 900,
interval: 5,
flow_handle: "fh-1",
};
const REQUEST = { ownerTeamId: "team-1", ownerKeyHash: null, displayName: "Shared ChatGPT" };
const renderModal = () => {
const props = { onClose: vi.fn(), onConnected: vi.fn() };
render(<ChatGPTSignInModal credentialName="team chatgpt" request={REQUEST} {...props} />);
return props;
};
const advance = async (seconds: number) => {
await act(async () => {
await vi.advanceTimersByTimeAsync(seconds * 1000);
});
};
describe("ChatGPTSignInModal", () => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: false });
networking.chatGPTLoginStartCall.mockReset().mockResolvedValue(START_RESPONSE);
networking.chatGPTLoginPollCall.mockReset();
});
afterEach(() => {
vi.useRealTimers();
});
it("shows the code and link, polls at the returned interval, and calls onConnected once connected", async () => {
networking.chatGPTLoginPollCall
.mockResolvedValueOnce({ status: "pending", account_id: null })
.mockResolvedValueOnce({ status: "connected", account_id: "acct-1" });
const props = renderModal();
await advance(0);
expect(networking.chatGPTLoginStartCall).toHaveBeenCalledWith("session-token", "team chatgpt", REQUEST);
expect(screen.getByTestId("chatgpt-user-code")).toHaveTextContent("WXYZ-9876");
expect(screen.getByRole("link", { name: "https://auth.openai.com/codex/device" })).toHaveAttribute(
"href",
"https://auth.openai.com/codex/device",
);
await advance(4.9);
expect(networking.chatGPTLoginPollCall).not.toHaveBeenCalled();
await advance(0.1);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(1);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledWith("session-token", "team chatgpt", "fh-1");
expect(props.onConnected).not.toHaveBeenCalled();
await advance(5);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2);
expect(props.onConnected).toHaveBeenCalledTimes(1);
await advance(30);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2);
});
it("shows the error from a rejected poll, stops polling, and starts a new flow on request", async () => {
networking.chatGPTLoginPollCall.mockRejectedValue(new Error("Invalid or expired flow handle"));
const props = renderModal();
await advance(0);
await advance(5);
expect(screen.getByRole("alert")).toHaveTextContent("Invalid or expired flow handle");
expect(props.onConnected).not.toHaveBeenCalled();
await advance(30);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByRole("button", { name: "Start again" }));
await advance(0);
expect(networking.chatGPTLoginStartCall).toHaveBeenCalledTimes(2);
expect(screen.getByTestId("chatgpt-user-code")).toBeInTheDocument();
});
it("stops polling and says the code expired once its lifetime passes", async () => {
networking.chatGPTLoginStartCall.mockResolvedValue({ ...START_RESPONSE, expires_in: 12 });
networking.chatGPTLoginPollCall.mockResolvedValue({ status: "pending", account_id: null });
const props = renderModal();
await advance(0);
await advance(5);
await advance(5);
await advance(5);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2);
expect(screen.getByRole("alert")).toHaveTextContent("The sign-in code expired");
await advance(30);
expect(networking.chatGPTLoginPollCall).toHaveBeenCalledTimes(2);
expect(props.onConnected).not.toHaveBeenCalled();
});
it("shows the proxy error when the start request fails", async () => {
networking.chatGPTLoginStartCall.mockRejectedValue(new Error("Team id does not exist"));
renderModal();
await advance(0);
expect(screen.getByRole("alert")).toHaveTextContent("Team id does not exist");
expect(networking.chatGPTLoginPollCall).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,126 @@
"use client";
import { useEffect, useReducer } from "react";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import { chatGPTLoginPollCall, chatGPTLoginStartCall, type ChatGPTLoginStartRequest } from "@/components/networking";
import CopyButton from "@/components/shared/CopyButton";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { initialSignInFlowState, signInFailureMessage, signInFlowReducer } from "./chatgpt_sign_in_flow";
interface ChatGPTSignInModalProps {
readonly credentialName: string;
readonly request: ChatGPTLoginStartRequest;
readonly onClose: () => void;
readonly onConnected: () => void;
}
export default function ChatGPTSignInModal({ credentialName, request, onClose, onConnected }: ChatGPTSignInModalProps) {
const { accessToken } = useAuthorized();
const [state, dispatch] = useReducer(signInFlowReducer, initialSignInFlowState);
useEffect(() => {
if (state.kind !== "starting" || !accessToken) {
return;
}
let cancelled = false;
chatGPTLoginStartCall(accessToken, credentialName, request)
.then((response) => !cancelled && dispatch({ type: "started", response, now: Date.now() }))
.catch((error: unknown) => !cancelled && dispatch({ type: "errored", message: extractProxyErrorMessage(error) }));
return () => {
cancelled = true;
};
}, [state, accessToken, credentialName, request]);
useEffect(() => {
if (state.kind !== "awaiting" || !accessToken) {
return;
}
let cancelled = false;
const timer = setTimeout(() => {
if (Date.now() >= state.expiresAt) {
dispatch({ type: "timed_out" });
return;
}
chatGPTLoginPollCall(accessToken, credentialName, state.flowHandle)
.then((response) => !cancelled && dispatch({ type: "polled", response }))
.catch(
(error: unknown) => !cancelled && dispatch({ type: "errored", message: extractProxyErrorMessage(error) }),
);
}, state.intervalSeconds * 1000);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [state, accessToken, credentialName]);
useEffect(() => {
if (state.kind === "connected") {
onConnected();
}
}, [state.kind, onConnected]);
return (
<Dialog open onOpenChange={(open) => !open && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign in with ChatGPT</DialogTitle>
<DialogDescription>Credential: {credentialName}</DialogDescription>
</DialogHeader>
{state.kind === "starting" && <p className="text-sm">Requesting a sign-in code from ChatGPT...</p>}
{state.kind === "awaiting" && (
<div className="flex flex-col gap-3">
<p className="text-sm">
Open{" "}
<a
href={state.verificationUri}
target="_blank"
rel="noopener noreferrer"
className="text-primary underline-offset-4 hover:underline"
>
{state.verificationUri}
</a>{" "}
and enter this code:
</p>
<div className="flex items-center gap-2">
<code data-testid="chatgpt-user-code" className="rounded-md bg-muted px-3 py-2 font-mono text-lg">
{state.userCode}
</code>
<CopyButton value={state.userCode} label="Copy code" copiedLabel="Code copied" />
</div>
<p className="text-sm text-muted-foreground">Waiting for you to finish signing in on ChatGPT...</p>
</div>
)}
{state.kind === "connected" && <p className="text-sm">Signed in with ChatGPT.</p>}
{state.kind === "failed" && (
<p role="alert" className="text-sm text-destructive">
{signInFailureMessage(state)}
</p>
)}
<DialogFooter>
{state.kind === "failed" && (
<Button variant="outline" onClick={() => dispatch({ type: "restarted" })}>
Start again
</Button>
)}
<Button onClick={onClose}>{state.kind === "connected" ? "Done" : "Close"}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -1,10 +1,33 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { Providers } from "../provider_info_helpers";
import { CredentialItem } from "../networking";
import { CredentialItem, type ChatGPTLoginStartRequest } from "../networking";
import CredentialModal from "./CredentialModal";
const signInModal = vi.hoisted(() => vi.fn());
vi.mock("./ChatGPTSignInModal", () => ({
default: (props: { credentialName: string; request: ChatGPTLoginStartRequest; onConnected: () => void }) => {
signInModal(props);
return (
<div data-testid="chatgpt-sign-in">
<button onClick={props.onConnected}>Finish ChatGPT sign-in</button>
</div>
);
},
}));
vi.mock("../common_components/team_dropdown", () => ({
default: ({ id, value, onChange }: { id?: string; value?: string | null; onChange?: (v: string | null) => void }) => (
<select id={id} value={value ?? ""} onChange={(event) => onChange?.(event.target.value || null)}>
<option value="">No team</option>
<option value="team-1">Team One</option>
</select>
),
}));
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
@ -125,4 +148,92 @@ describe("CredentialModal", () => {
expect(screen.getByLabelText("Credential Name:")).toBeDisabled();
});
});
describe("ChatGPT Subscription", () => {
beforeEach(() => {
signInModal.mockClear();
});
const lastSignIn = () =>
signInModal.mock.lastCall?.[0] as { credentialName: string; request: ChatGPTLoginStartRequest } | undefined;
const chatGPTCredential: CredentialItem = {
credential_name: "key-chatgpt",
display_name: "Key ChatGPT",
credential_values: { chatgpt_access_token: "eyJh****", chatgpt_account_id: "acct-1" },
credential_info: { custom_llm_provider: "chatgpt", chatgpt_owner_key_hash: "hash-stored" },
};
it("offers sign-in and an owner picker in place of the add button", () => {
renderModal({ mode: "add", initialProvider: "CHATGPT" });
expect(screen.getByRole("button", { name: "Sign in with ChatGPT" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Add Credential" })).not.toBeInTheDocument();
expect(screen.getByRole("radio", { name: "Team" })).toBeChecked();
expect(screen.getByLabelText("Team:")).toBeInTheDocument();
});
it("signs in for the entered name and team without calling onSubmit, then reports the sign-in", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
const onSignedIn = vi.fn();
renderModal({ initialProvider: "CHATGPT", onSubmit, onSignedIn });
fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "team-chatgpt" } });
fireEvent.change(screen.getByLabelText("Team:"), { target: { value: "team-1" } });
await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" }));
expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument();
expect(lastSignIn()).toMatchObject({
credentialName: "team-chatgpt",
request: { ownerTeamId: "team-1", ownerKeyHash: null, displayName: null },
});
await user.click(screen.getByRole("button", { name: "Finish ChatGPT sign-in" }));
expect(onSignedIn).toHaveBeenCalledTimes(1);
expect(screen.queryByTestId("chatgpt-sign-in")).not.toBeInTheDocument();
expect(onSubmit).not.toHaveBeenCalled();
});
it("requires an owner and sends exactly one of the team or the virtual key", async () => {
const user = userEvent.setup();
renderModal({ mode: "add", initialProvider: "CHATGPT" });
fireEvent.change(screen.getByLabelText("Credential Name:"), { target: { value: "key-chatgpt" } });
await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" }));
expect(await screen.findByText("Pick the team that owns this login")).toBeInTheDocument();
expect(signInModal).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Team:"), { target: { value: "team-1" } });
await user.click(screen.getByRole("radio", { name: "Virtual key" }));
await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" }));
expect(await screen.findByText("Enter the ID of the key that owns this login")).toBeInTheDocument();
expect(signInModal).not.toHaveBeenCalled();
fireEvent.change(screen.getByLabelText("Key ID:"), { target: { value: " hash-1 " } });
await user.click(screen.getByRole("button", { name: "Sign in with ChatGPT" }));
expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument();
expect(lastSignIn()?.request).toEqual({ ownerTeamId: null, ownerKeyHash: "hash-1", displayName: null });
});
it("recognizes a stored chatgpt credential and signs in again with its saved owner and name", async () => {
const user = userEvent.setup();
const onSubmit = vi.fn();
renderModal({ mode: "edit", existingCredential: chatGPTCredential, onSubmit });
expect(screen.getByRole("combobox", { name: /Provider/ })).toHaveValue(Providers.CHATGPT);
expect(screen.getByRole("radio", { name: "Virtual key" })).toBeChecked();
expect(screen.getByLabelText("Key ID:")).toHaveValue("hash-stored");
await user.click(screen.getByRole("button", { name: "Sign in again" }));
expect(await screen.findByTestId("chatgpt-sign-in")).toBeInTheDocument();
expect(lastSignIn()).toMatchObject({
credentialName: "key-chatgpt",
request: { ownerTeamId: null, ownerKeyHash: "hash-stored", displayName: "Key ChatGPT" },
});
expect(onSubmit).not.toHaveBeenCalled();
});
});
});

View file

@ -16,7 +16,7 @@ import {
type MountedFormValues,
} from "../common_components/MountedFormField";
import { CredentialItem } from "../networking";
import { Providers } from "../provider_info_helpers";
import { getProviderLogoAndName, providerEnumKeyOf, Providers } from "../provider_info_helpers";
import { Logo } from "@/components/molecules/logo/Logo";
import {
displayNameChange,
@ -42,6 +42,15 @@ import {
type AuthMethod,
} from "./credential_federation";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import ChatGPTOwnerFields from "./ChatGPTOwnerFields";
import ChatGPTSignInModal from "./ChatGPTSignInModal";
import {
chatGPTSignInFrom,
credentialSubmitLabel,
storedChatGPTOwner,
type ChatGPTOwnerKind,
type ChatGPTSignIn,
} from "./chatgpt_sign_in_flow";
const providerOptions: SearchSelectOption[] = Object.entries(Providers).map(([providerEnum, providerDisplayName]) => ({
label: providerDisplayName,
@ -58,10 +67,14 @@ const authMethodItems: { value: AuthMethod; label: string }[] = [
{ value: "federation", label: "Workload identity federation" },
];
const isChatGPTSubscription = (provider: string | null): boolean =>
provider !== null && getProviderLogoAndName(provider).displayName === Providers.CHATGPT;
interface CredentialModalProps {
open: boolean;
onCancel: () => void;
onSubmit: (submission: CredentialSubmission, valuesToDelete: readonly string[]) => void;
onSignedIn?: () => void;
mode: "add" | "edit";
existingCredential?: CredentialItem | null;
initialProvider?: string | null;
@ -77,6 +90,7 @@ export default function CredentialModal({
open,
onCancel,
onSubmit,
onSignedIn,
mode,
existingCredential = null,
initialProvider = null,
@ -100,6 +114,10 @@ export default function CredentialModal({
);
const [identitySource, setIdentitySource] = useState<IdentitySourceId>(storedIdentitySource);
const selection = selectionFor(federatedProviderOf(selectedProvider), authMethod, identitySource);
const storedOwner = storedChatGPTOwner(existingCredential);
const [chatGPTOwnerKind, setChatGPTOwnerKind] = useState<ChatGPTOwnerKind>(storedOwner.kind);
const [chatGPTSignIn, setChatGPTSignIn] = useState<ChatGPTSignIn | null>(null);
const signsInWithChatGPT = isChatGPTSubscription(selectedProvider);
const initialValues = initialFormValues(existingCredential, initialProvider);
@ -154,6 +172,19 @@ export default function CredentialModal({
onSubmit({ ...meta, credential_values: patch.credential_values }, valuesToDelete);
};
const startChatGPTSignIn = async () => {
const isValid = await form.trigger(registry.mountedNames() as string[]);
if (!isValid) {
return;
}
setChatGPTSignIn(chatGPTSignInFrom(chatGPTOwnerKind, projectMountedValues(registry, form.getValues)));
};
const finishChatGPTSignIn = () => {
setChatGPTSignIn(null);
onSignedIn?.();
};
const closeAndReset = () => {
onCancel();
form.reset();
@ -170,7 +201,7 @@ export default function CredentialModal({
<form
onSubmit={(event) => {
event.preventDefault();
void handleSubmit();
void (signsInWithChatGPT ? startChatGPTSignIn() : handleSubmit());
}}
>
<MountedFormField
@ -216,7 +247,7 @@ export default function CredentialModal({
inputId={control.id}
placeholder="Select a provider"
options={providerOptions}
value={typeof control.value === "string" ? control.value : null}
value={typeof control.value === "string" ? providerEnumKeyOf(control.value) ?? control.value : null}
disabled={providerLocked}
onValueChange={(value) => {
control.onChange(value);
@ -253,12 +284,21 @@ export default function CredentialModal({
</div>
)}
<ProviderSpecificFields
selectedProvider={selectedProvider}
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
initialAuthTypeId={initialAuthTypeId}
fieldValidators={providerFieldValidators(selection)}
/>
{signsInWithChatGPT ? (
<ChatGPTOwnerFields
ownerKind={chatGPTOwnerKind}
onOwnerKindChange={setChatGPTOwnerKind}
storedOwnerTeamId={storedOwner.teamId}
storedOwnerKeyHash={storedOwner.keyHash}
/>
) : (
<ProviderSpecificFields
selectedProvider={selectedProvider}
hiddenFieldKeys={selection.authMethod === "federation" ? API_KEY_FIELDS : NO_HIDDEN_FIELDS}
initialAuthTypeId={initialAuthTypeId}
fieldValidators={providerFieldValidators(selection)}
/>
)}
{selection.authMethod === "federation" && (
<FederationFields
@ -281,12 +321,20 @@ export default function CredentialModal({
<Button variant="outline" className="mr-2.5" onClick={closeAndReset}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Update Credential" : "Add Credential"}</Button>
<Button type="submit">{credentialSubmitLabel(signsInWithChatGPT, mode)}</Button>
</div>
</div>
</form>
</MountedFormProvider>
</FormProvider>
{chatGPTSignIn && (
<ChatGPTSignInModal
credentialName={chatGPTSignIn.credentialName}
request={chatGPTSignIn.request}
onClose={() => setChatGPTSignIn(null)}
onConnected={finishChatGPTSignIn}
/>
)}
</DialogContent>
</Dialog>
);

View file

@ -1,5 +1,5 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { render, screen, waitFor } from "@testing-library/react";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
@ -37,10 +37,12 @@ vi.mock("./CredentialModal", () => ({
mode,
open,
onSubmit,
onSignedIn,
}: {
mode: "add" | "edit";
open: boolean;
onSubmit: (submission: CredentialSubmission, valuesToDelete?: readonly string[]) => void;
onSignedIn?: () => void;
}) {
if (!open) {
return null;
@ -80,6 +82,9 @@ vi.mock("./CredentialModal", () => ({
>
switch {mode} to federation
</button>
<button data-testid={`credential-modal-${mode}-chatgpt-signed-in`} onClick={() => onSignedIn?.()}>
finish {mode} ChatGPT sign-in
</button>
</>
);
},
@ -262,6 +267,57 @@ describe("CredentialsPanel", () => {
expect(plainPayload).not.toHaveProperty("credential_values_to_delete");
});
it.each(["add", "edit"] as const)(
"closes the %s modal and refetches the list after a ChatGPT sign-in",
async (mode) => {
const user = userEvent.setup();
const refetch = vi.fn();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
mockUseCredentials.mockReturnValue({ data: { credentials }, isLoading: false, refetch });
renderPanel();
if (mode === "add") {
await user.click(screen.getByRole("button", { name: /add credential/i }));
} else {
await user.click(screen.getByTestId("credential-actions-openai-key"));
await user.click(await screen.findByTestId("credential-action-edit"));
}
await user.click(screen.getByTestId(`credential-modal-${mode}-chatgpt-signed-in`));
expect(refetch).toHaveBeenCalledTimes(1);
expect(toast.success).toHaveBeenCalledWith("Signed in with ChatGPT");
expect(screen.queryByTestId(`credential-modal-${mode}-submit`)).not.toBeInTheDocument();
},
);
it("names a stored chatgpt credential's provider as ChatGPT Subscription in the delete confirmation", async () => {
const user = userEvent.setup();
mockUseAuthorized.mockReturnValue({ accessToken: "test-token", userRole: "Admin" });
mockUseCredentials.mockReturnValue({
data: {
credentials: [
{
credential_name: "team-chatgpt",
credential_values: {},
credential_info: { custom_llm_provider: "chatgpt", chatgpt_owner_team_id: "team-1" },
},
],
},
isLoading: false,
refetch: vi.fn(),
});
renderPanel();
await user.click(screen.getByTestId("credential-actions-team-chatgpt"));
await user.click(await screen.findByTestId("credential-action-delete"));
const confirmation = await screen.findByRole("dialog");
expect(within(confirmation).getByText("ChatGPT Subscription")).toBeInTheDocument();
expect(within(confirmation).queryByText("chatgpt")).not.toBeInTheDocument();
});
describe("Admin Viewer write-action gating", () => {
// Admin Viewer can VIEW credentials but must not add / edit / delete them.
it("hides the Add Credential button but still lists credentials", () => {

View file

@ -12,6 +12,7 @@ import {
CredentialItem,
credentialUpdateCall,
} from "@/components/networking";
import { getProviderLogoAndName } from "@/components/provider_info_helpers";
import { Button } from "@/components/ui/button";
import { extractProxyErrorMessage } from "@/lib/http/client";
import { stripMaskedSecrets } from "@/utils/maskedSecretUtils";
@ -71,6 +72,13 @@ export default function CredentialsPanel() {
}
};
const handleChatGPTSignedIn = () => {
toast.success("Signed in with ChatGPT");
setIsAddModalOpen(false);
setIsUpdateModalOpen(false);
void refetchCredentials();
};
const handleDeleteCredential = async () => {
if (!accessToken || !credentialToDelete) {
return;
@ -130,6 +138,7 @@ export default function CredentialsPanel() {
<CredentialModal
mode="add"
onSubmit={handleAddCredential}
onSignedIn={handleChatGPTSignedIn}
open={isAddModalOpen}
onCancel={() => setIsAddModalOpen(false)}
/>
@ -140,6 +149,7 @@ export default function CredentialsPanel() {
open={isUpdateModalOpen}
existingCredential={selectedCredential}
onSubmit={handleUpdateCredential}
onSignedIn={handleChatGPTSignedIn}
onCancel={() => setIsUpdateModalOpen(false)}
/>
)}
@ -153,7 +163,10 @@ export default function CredentialsPanel() {
resourceInformationTitle="Credential Information"
resourceInformation={[
{ label: "Credential Name", value: credentialToDelete?.credential_name },
{ label: "Provider", value: credentialToDelete?.credential_info?.custom_llm_provider || "-" },
{
label: "Provider",
value: getProviderLogoAndName(credentialToDelete?.credential_info?.custom_llm_provider ?? "").displayName,
},
]}
confirmLoading={isCredentialDeleting}
requiredConfirmation={credentialToDelete?.credential_name}

View file

@ -0,0 +1,118 @@
import { describe, expect, it } from "vitest";
import {
chatGPTStartRequest,
initialSignInFlowState,
signInFailureMessage,
signInFlowReducer,
type SignInFlowState,
} from "./chatgpt_sign_in_flow";
const started = (verificationUri = "https://auth.openai.com/codex/device"): SignInFlowState =>
signInFlowReducer(initialSignInFlowState, {
type: "started",
response: {
user_code: "WXYZ-9876",
verification_uri: verificationUri,
expires_in: 900,
interval: 5,
flow_handle: "fh-1",
},
now: 1_000,
});
const connected = (): SignInFlowState =>
signInFlowReducer(started(), { type: "polled", response: { status: "connected", account_id: "acct-1" } });
describe("signInFlowReducer", () => {
it("moves from starting to awaiting with the code, expiry, interval and handle from the start response", () => {
const expected: SignInFlowState = {
kind: "awaiting",
attempt: 0,
pollCount: 0,
userCode: "WXYZ-9876",
verificationUri: "https://auth.openai.com/codex/device",
expiresAt: 901_000,
intervalSeconds: 5,
flowHandle: "fh-1",
};
expect(started()).toEqual(expected);
});
it.each([
"https://evil.example/codex/device",
"http://auth.openai.com/codex/device",
"https://auth.openai.com.evil.example/codex/device",
"https://user@auth.openai.com/codex/device",
"https://auth.openai.com:8443/codex/device",
"not a url",
])("falls back to the ChatGPT device page for unsafe verification URL %s", (uri) => {
const state = started(uri);
expect(state.kind === "awaiting" && state.verificationUri).toBe("https://auth.openai.com/codex/device");
});
it("keeps waiting on pending as a new state so the next poll is scheduled", () => {
const awaiting = started();
const pending = signInFlowReducer(awaiting, { type: "polled", response: { status: "pending", account_id: null } });
expect(pending).not.toBe(awaiting);
expect(pending).toEqual({ ...awaiting, pollCount: 1 });
});
it("connects with the account id from the poll", () => {
expect(connected()).toEqual({ kind: "connected", attempt: 0, accountId: "acct-1" });
});
it("expires locally once the code lifetime has passed", () => {
const expired = signInFlowReducer(started(), { type: "timed_out" });
expect(expired).toEqual({ kind: "failed", attempt: 0, reason: "expired" });
expect(expired.kind === "failed" && signInFailureMessage(expired)).toContain("expired");
});
it("fails with the backend's message when a poll is rejected, then restarts with a new attempt", () => {
const failed = signInFlowReducer(started(), { type: "errored", message: "Invalid or expired flow handle" });
const expected: SignInFlowState = {
kind: "failed",
attempt: 0,
reason: "error",
message: "Invalid or expired flow handle",
};
expect(failed).toEqual(expected);
expect(failed.kind === "failed" && signInFailureMessage(failed)).toBe("Invalid or expired flow handle");
expect(signInFlowReducer(failed, { type: "restarted" })).toEqual({ kind: "starting", attempt: 1 });
});
it("ignores a late poll result or error after the flow connected", () => {
const done = connected();
expect(signInFlowReducer(done, { type: "polled", response: { status: "pending", account_id: null } })).toBe(done);
expect(signInFlowReducer(done, { type: "errored", message: "late" })).toBe(done);
expect(signInFlowReducer(done, { type: "timed_out" })).toBe(done);
});
it("ignores a second start response once the flow is already awaiting", () => {
const awaiting = started();
const again = signInFlowReducer(awaiting, {
type: "started",
response: { user_code: "OTHER", verification_uri: "", expires_in: 1, interval: 1, flow_handle: "fh-2" },
now: 0,
});
expect(again).toBe(awaiting);
});
});
describe("chatGPTStartRequest", () => {
it("sets only the team id for a team owner", () => {
expect(chatGPTStartRequest("team", "team-1", "")).toEqual({
ownerTeamId: "team-1",
ownerKeyHash: null,
displayName: null,
});
});
it("sets only the key hash for a virtual key owner and trims the display name", () => {
expect(chatGPTStartRequest("key", "hash-1", " Shared ChatGPT ")).toEqual({
ownerTeamId: null,
ownerKeyHash: "hash-1",
displayName: "Shared ChatGPT",
});
});
});

View file

@ -0,0 +1,159 @@
import type {
ChatGPTLoginPollResponse,
ChatGPTLoginStartRequest,
ChatGPTLoginStartResponse,
CredentialItem,
} from "@/components/networking";
export const CHATGPT_DEVICE_LOGIN_URL = "https://auth.openai.com/codex/device";
const CHATGPT_DEVICE_LOGIN_HOST = "auth.openai.com";
export type ChatGPTOwnerKind = "team" | "key";
export const CHATGPT_OWNER_FIELDS: Readonly<Record<ChatGPTOwnerKind, string>> = {
team: "chatgpt_owner_team_id",
key: "chatgpt_owner_key_hash",
};
export type SignInFailureReason = "expired" | "error";
export type SignInFlowState =
| { readonly kind: "starting"; readonly attempt: number }
| {
readonly kind: "awaiting";
readonly attempt: number;
readonly pollCount: number;
readonly userCode: string;
readonly verificationUri: string;
readonly expiresAt: number;
readonly intervalSeconds: number;
readonly flowHandle: string;
}
| { readonly kind: "connected"; readonly attempt: number; readonly accountId: string | null }
| {
readonly kind: "failed";
readonly attempt: number;
readonly reason: SignInFailureReason;
readonly message?: string;
};
export type SignInFlowEvent =
| { readonly type: "started"; readonly response: ChatGPTLoginStartResponse; readonly now: number }
| { readonly type: "polled"; readonly response: ChatGPTLoginPollResponse }
| { readonly type: "timed_out" }
| { readonly type: "errored"; readonly message: string }
| { readonly type: "restarted" };
export const initialSignInFlowState: SignInFlowState = { kind: "starting", attempt: 0 };
export const chatGPTStartRequest = (
ownerKind: ChatGPTOwnerKind,
ownerId: string,
displayName: string,
): ChatGPTLoginStartRequest => ({
ownerTeamId: ownerKind === "team" ? ownerId : null,
ownerKeyHash: ownerKind === "key" ? ownerId : null,
displayName: displayName.trim() === "" ? null : displayName.trim(),
});
export interface StoredChatGPTOwner {
readonly teamId: string | null;
readonly keyHash: string | null;
readonly kind: ChatGPTOwnerKind;
}
export const storedChatGPTOwner = (credential: CredentialItem | null): StoredChatGPTOwner => {
const teamId = credential?.credential_info.chatgpt_owner_team_id ?? null;
const keyHash = credential?.credential_info.chatgpt_owner_key_hash ?? null;
return { teamId, keyHash, kind: keyHash ? "key" : "team" };
};
export interface ChatGPTSignIn {
readonly credentialName: string;
readonly request: ChatGPTLoginStartRequest;
}
export const chatGPTSignInFrom = (
ownerKind: ChatGPTOwnerKind,
values: Readonly<Record<string, unknown>>,
): ChatGPTSignIn => {
const ownerId = values[CHATGPT_OWNER_FIELDS[ownerKind]];
const displayName = values.display_name;
return {
credentialName: String(values.credential_name),
request: chatGPTStartRequest(
ownerKind,
typeof ownerId === "string" ? ownerId.trim() : "",
typeof displayName === "string" ? displayName : "",
),
};
};
const SUBMIT_LABELS = {
chatgpt: { add: "Sign in with ChatGPT", edit: "Sign in again" },
credential: { add: "Add Credential", edit: "Update Credential" },
} as const;
export const credentialSubmitLabel = (signsInWithChatGPT: boolean, mode: "add" | "edit"): string =>
SUBMIT_LABELS[signsInWithChatGPT ? "chatgpt" : "credential"][mode];
const safeVerificationUri = (uri: string): string => {
try {
const url = new URL(uri);
const hasCredentials = url.username !== "" || url.password !== "";
const isDefaultHttps = url.protocol === "https:" && url.port === "";
const isSafeUrl = isDefaultHttps && !hasCredentials && url.hostname === CHATGPT_DEVICE_LOGIN_HOST;
return isSafeUrl ? uri : CHATGPT_DEVICE_LOGIN_URL;
} catch {
return CHATGPT_DEVICE_LOGIN_URL;
}
};
const afterPoll = (
state: Extract<SignInFlowState, { kind: "awaiting" }>,
response: ChatGPTLoginPollResponse,
): SignInFlowState => {
switch (response.status) {
case "pending":
return { ...state, pollCount: state.pollCount + 1 };
case "connected":
return { kind: "connected", attempt: state.attempt, accountId: response.account_id };
}
};
export const signInFlowReducer = (state: SignInFlowState, event: SignInFlowEvent): SignInFlowState => {
switch (event.type) {
case "restarted":
return { kind: "starting", attempt: state.attempt + 1 };
case "errored":
return state.kind === "connected"
? state
: { kind: "failed", attempt: state.attempt, reason: "error", message: event.message };
case "started":
return state.kind !== "starting"
? state
: {
kind: "awaiting",
attempt: state.attempt,
pollCount: 0,
userCode: event.response.user_code,
verificationUri: safeVerificationUri(event.response.verification_uri),
expiresAt: event.now + event.response.expires_in * 1000,
intervalSeconds: event.response.interval,
flowHandle: event.response.flow_handle,
};
case "polled":
return state.kind === "awaiting" ? afterPoll(state, event.response) : state;
case "timed_out":
return state.kind === "awaiting" ? { kind: "failed", attempt: state.attempt, reason: "expired" } : state;
}
};
export const signInFailureMessage = (state: Extract<SignInFlowState, { kind: "failed" }>): string => {
switch (state.reason) {
case "expired":
return "The sign-in code expired before it was used. Start again to get a new code.";
case "error":
return state.message ?? "Something went wrong while signing in to ChatGPT.";
}
};

View file

@ -351,6 +351,8 @@ export interface CredentialItem {
custom_llm_provider?: string;
description?: string;
required?: boolean;
chatgpt_owner_team_id?: string;
chatgpt_owner_key_hash?: string;
};
}
@ -2731,6 +2733,48 @@ export const userConnectionPollCall = (
export const userConnectionDeleteCall = (accessToken: string, credentialName: string): Promise<void> =>
apiClient.delete<void>(userConnectionPath(credentialName), { accessToken });
export interface ChatGPTLoginStartRequest {
ownerTeamId: string | null;
ownerKeyHash: string | null;
displayName: string | null;
}
export interface ChatGPTLoginStartResponse {
user_code: string;
verification_uri: string;
expires_in: number;
interval: number;
flow_handle: string;
}
export interface ChatGPTLoginPollResponse {
status: "pending" | "connected";
account_id: string | null;
}
const chatGPTLoginPath = (credentialName: string): string =>
`/credentials/${encodeURIComponent(credentialName)}/chatgpt_login`;
export const chatGPTLoginStartCall = (
accessToken: string,
credentialName: string,
{ ownerTeamId, ownerKeyHash, displayName }: ChatGPTLoginStartRequest,
): Promise<ChatGPTLoginStartResponse> =>
apiClient.post<ChatGPTLoginStartResponse>(`${chatGPTLoginPath(credentialName)}/start`, {
accessToken,
body: { owner_team_id: ownerTeamId, owner_key_hash: ownerKeyHash, display_name: displayName },
});
export const chatGPTLoginPollCall = (
accessToken: string,
credentialName: string,
flowHandle: string,
): Promise<ChatGPTLoginPollResponse> =>
apiClient.post<ChatGPTLoginPollResponse>(`${chatGPTLoginPath(credentialName)}/poll`, {
accessToken,
body: { flow_handle: flowHandle },
});
export const credentialUpdateCall = async (
accessToken: string,
credentialName: string,

View file

@ -145,6 +145,11 @@ describe("provider_info_helpers", () => {
expect(fromEnumKey.logo).toContain("openai_small");
});
it("should resolve an exact enum key to its own provider even when it lowercases to another provider's slug", () => {
expect(getProviderLogoAndName("SageMaker").displayName).toBe(Providers.SageMaker);
expect(getProviderLogoAndName("sagemaker").displayName).toBe(Providers.SAGEMAKER_LEGACY);
});
it("should handle provider values case-insensitively", () => {
const result = getProviderLogoAndName("OPENAI");
expect(result.displayName).toBe(Providers.OpenAI);

View file

@ -440,6 +440,15 @@ export const providerLogoMap: Partial<Record<Providers, string>> = {
[Providers.XINFERENCE]: xinferenceLogo.src,
};
export const providerEnumKeyOf = (providerValue: string): string | undefined => {
const lowered = providerValue.toLowerCase();
const enumKeys = Object.keys(provider_map);
return Object.hasOwn(provider_map, providerValue)
? providerValue
: enumKeys.find((key) => provider_map[key].toLowerCase() === lowered) ??
enumKeys.find((key) => key.toLowerCase() === lowered);
};
export const getProviderLogoAndName = (providerValue: string): { logo: string; displayName: string } => {
if (!providerValue) {
return { logo: "", displayName: "-" };
@ -452,12 +461,7 @@ export const getProviderLogoAndName = (providerValue: string): { logo: string; d
return { logo, displayName };
}
// Resolve by the litellm provider slug (e.g. "bedrock_mantle"); fall back to
// the enum key (e.g. "BedrockMantle") for callers like the Add Model dropdown
// that pass the key instead of the slug.
const enumKey =
Object.keys(provider_map).find((key) => provider_map[key].toLowerCase() === providerValue.toLowerCase()) ??
Object.keys(provider_map).find((key) => key.toLowerCase() === providerValue.toLowerCase());
const enumKey = providerEnumKeyOf(providerValue);
if (!enumKey) {
return { logo: "", displayName: providerValue };

View file

@ -3864,6 +3864,46 @@ export interface paths {
patch: operations["update_credential_credentials__credential_name__patch"];
trace?: never;
};
"/credentials/{credential_name}/chatgpt_login/poll": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Poll Chatgpt Login
* @description Poll a ChatGPT sign-in once and save the login on the credential when it completes. Proxy admin only.
*/
post: operations["poll_chatgpt_login_credentials__credential_name__chatgpt_login_poll_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/credentials/{credential_name}/chatgpt_login/start": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Start Chatgpt Login
* @description Start a ChatGPT device-code sign-in whose login only the given team or virtual key can use. Proxy admin only.
*/
post: operations["start_chatgpt_login_credentials__credential_name__chatgpt_login_start_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/credentials/{credential_name}/jwks": {
parameters: {
query?: never;
@ -29135,6 +29175,27 @@ export interface components {
/** Url */
url: string;
};
/** ChatGPTLoginPollResponse */
ChatGPTLoginPollResponse: {
/** Account Id */
account_id?: string | null;
/** Interval */
interval?: number | null;
/**
* Status
* @enum {string}
*/
status: "pending" | "connected";
};
/** ChatGPTLoginStartRequest */
ChatGPTLoginStartRequest: {
/** Display Name */
display_name?: string | null;
/** Owner Key Hash */
owner_key_hash?: string | null;
/** Owner Team Id */
owner_team_id?: string | null;
};
/** ChoiceAnswer */
ChoiceAnswer: {
/** Choice */
@ -57120,6 +57181,78 @@ export interface operations {
};
};
};
poll_chatgpt_login_credentials__credential_name__chatgpt_login_poll_post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The credential name, percent-decoded */
credential_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["UserConnectionPollRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ChatGPTLoginPollResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
start_chatgpt_login_credentials__credential_name__chatgpt_login_start_post: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The credential name, percent-decoded */
credential_name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["ChatGPTLoginStartRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["UserConnectionStartResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_credential_internal_issuer_jwks_credentials__credential_name__jwks_get: {
parameters: {
query?: never;