mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 21:08:34 +03:00
feat(webui): support remote Codex OAuth login (#5174)
This commit is contained in:
@@ -661,11 +661,12 @@ export function SettingsView({
|
||||
const [nanobotFeatureConfirm, setNanobotFeatureConfirm] = useState<NanobotFeatureInfo | null>(null);
|
||||
const [mcpPresetAction, setMcpPresetAction] = useState<string | null>(null);
|
||||
const [providerSaving, setProviderSaving] = useState<string | null>(null);
|
||||
const [xaiOAuthFlow, setXaiOAuthFlow] =
|
||||
const [providerOAuthFlow, setProviderOAuthFlow] =
|
||||
useState<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const xaiOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const [xaiOAuthCode, setXaiOAuthCode] = useState("");
|
||||
const [xaiOAuthCompleting, setXaiOAuthCompleting] = useState(false);
|
||||
const providerOAuthFlowRef = useRef<ProviderOAuthAuthorizationRequired | null>(null);
|
||||
const [providerOAuthResponse, setProviderOAuthResponse] = useState("");
|
||||
const [providerOAuthCompleting, setProviderOAuthCompleting] = useState(false);
|
||||
const [providerOAuthDialogError, setProviderOAuthDialogError] = useState<string | null>(null);
|
||||
const [webSearchSaving, setWebSearchSaving] = useState(false);
|
||||
const [imageGenerationSaving, setImageGenerationSaving] = useState(false);
|
||||
const [transcriptionSaving, setTranscriptionSaving] = useState(false);
|
||||
@@ -765,37 +766,44 @@ export function SettingsView({
|
||||
[onSettingsChange],
|
||||
);
|
||||
|
||||
const closeXaiOAuthFlow = useCallback(() => {
|
||||
xaiOAuthFlowRef.current = null;
|
||||
setXaiOAuthFlow(null);
|
||||
setXaiOAuthCode("");
|
||||
setXaiOAuthCompleting(false);
|
||||
const closeProviderOAuthFlow = useCallback(() => {
|
||||
providerOAuthFlowRef.current = null;
|
||||
setProviderOAuthFlow(null);
|
||||
setProviderOAuthResponse("");
|
||||
setProviderOAuthCompleting(false);
|
||||
setProviderOAuthDialogError(null);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!xaiOAuthFlow) return;
|
||||
if (!providerOAuthFlow) return;
|
||||
let cancelled = false;
|
||||
let timer: number | null = null;
|
||||
const poll = async () => {
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
getToken(),
|
||||
xaiOAuthFlow.provider,
|
||||
xaiOAuthFlow.flow_id,
|
||||
providerOAuthFlow.provider,
|
||||
providerOAuthFlow.flow_id,
|
||||
);
|
||||
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
if (isProviderOAuthPending(payload)) {
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
return;
|
||||
}
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(xaiOAuthFlow.provider);
|
||||
setExpandedProvider(providerOAuthFlow.provider);
|
||||
setError(null);
|
||||
closeXaiOAuthFlow();
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (cancelled || xaiOAuthFlowRef.current?.flow_id !== xaiOAuthFlow.flow_id) return;
|
||||
if (
|
||||
cancelled
|
||||
|| providerOAuthFlowRef.current?.flow_id !== providerOAuthFlow.flow_id
|
||||
) return;
|
||||
setError((err as Error).message);
|
||||
closeXaiOAuthFlow();
|
||||
closeProviderOAuthFlow();
|
||||
}
|
||||
};
|
||||
timer = window.setTimeout(() => void poll(), 1000);
|
||||
@@ -803,7 +811,7 @@ export function SettingsView({
|
||||
cancelled = true;
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [applyPayload, closeXaiOAuthFlow, getToken, xaiOAuthFlow]);
|
||||
}, [applyPayload, closeProviderOAuthFlow, getToken, providerOAuthFlow]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!initialSettings || settings !== null) return;
|
||||
@@ -1612,7 +1620,11 @@ export function SettingsView({
|
||||
const runProviderOAuth = async (providerName: string, action: "login" | "logout") => {
|
||||
if (providerSaving) return;
|
||||
let popup: Window | null = null;
|
||||
if (action === "login" && providerName === "xai_grok" && !remoteBrowserAccess) {
|
||||
if (
|
||||
action === "login"
|
||||
&& providerName === "xai_grok"
|
||||
&& !remoteBrowserAccess
|
||||
) {
|
||||
try {
|
||||
popup = window.open("about:blank", "_blank");
|
||||
if (popup) popup.opener = null;
|
||||
@@ -1624,7 +1636,12 @@ export function SettingsView({
|
||||
try {
|
||||
const payload =
|
||||
action === "login"
|
||||
? await loginProviderOAuth(token, providerName)
|
||||
? await loginProviderOAuth(
|
||||
token,
|
||||
providerName,
|
||||
"",
|
||||
providerName === "openai_codex" && remoteBrowserAccess,
|
||||
)
|
||||
: await logoutProviderOAuth(token, providerName);
|
||||
if (isProviderOAuthAuthorizationRequired(payload)) {
|
||||
try {
|
||||
@@ -1632,15 +1649,16 @@ export function SettingsView({
|
||||
} catch {
|
||||
// The dialog keeps the authorization link available when the popup was closed.
|
||||
}
|
||||
xaiOAuthFlowRef.current = payload;
|
||||
setXaiOAuthFlow(payload);
|
||||
setXaiOAuthCode("");
|
||||
providerOAuthFlowRef.current = payload;
|
||||
setProviderOAuthFlow(payload);
|
||||
setProviderOAuthResponse("");
|
||||
setProviderOAuthDialogError(null);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
return;
|
||||
}
|
||||
popup?.close();
|
||||
closeXaiOAuthFlow();
|
||||
closeProviderOAuthFlow();
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(providerName);
|
||||
setError(null);
|
||||
@@ -1652,31 +1670,31 @@ export function SettingsView({
|
||||
}
|
||||
};
|
||||
|
||||
const completeXaiOAuth = async () => {
|
||||
const flow = xaiOAuthFlowRef.current;
|
||||
const authorizationCode = xaiOAuthCode.trim();
|
||||
if (!flow || !authorizationCode || xaiOAuthCompleting) return;
|
||||
setXaiOAuthCompleting(true);
|
||||
const completeProviderOAuthResponse = async () => {
|
||||
const flow = providerOAuthFlowRef.current;
|
||||
const authorizationResponse = providerOAuthResponse.trim();
|
||||
if (!flow || !authorizationResponse || providerOAuthCompleting) return;
|
||||
setProviderOAuthCompleting(true);
|
||||
setProviderOAuthDialogError(null);
|
||||
try {
|
||||
const payload = await completeProviderOAuth(
|
||||
token,
|
||||
flow.provider,
|
||||
flow.flow_id,
|
||||
authorizationCode,
|
||||
authorizationResponse,
|
||||
);
|
||||
if (xaiOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||
if (providerOAuthFlowRef.current?.flow_id !== flow.flow_id) return;
|
||||
if (isProviderOAuthPending(payload)) return;
|
||||
applyPayload(payload);
|
||||
setExpandedProvider(flow.provider);
|
||||
setError(null);
|
||||
closeXaiOAuthFlow();
|
||||
closeProviderOAuthFlow();
|
||||
} catch (err) {
|
||||
if (xaiOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
||||
setError((err as Error).message);
|
||||
closeXaiOAuthFlow();
|
||||
if (providerOAuthFlowRef.current?.flow_id === flow.flow_id) {
|
||||
setProviderOAuthDialogError((err as Error).message);
|
||||
}
|
||||
} finally {
|
||||
setXaiOAuthCompleting(false);
|
||||
setProviderOAuthCompleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -2317,19 +2335,33 @@ export function SettingsView({
|
||||
onConfirm={handleDeleteModelConfiguration}
|
||||
/>
|
||||
|
||||
<XaiOAuthLoginDialog
|
||||
flow={xaiOAuthFlow}
|
||||
authorizationCode={xaiOAuthCode}
|
||||
completing={xaiOAuthCompleting}
|
||||
<ProviderOAuthLoginDialog
|
||||
flow={providerOAuthFlow}
|
||||
providerLabel={
|
||||
providerOAuthFlow
|
||||
? settings?.providers.find((provider) => provider.name === providerOAuthFlow.provider)
|
||||
?.label ?? providerOAuthFlow.provider
|
||||
: ""
|
||||
}
|
||||
authorizationResponse={providerOAuthResponse}
|
||||
completing={providerOAuthCompleting}
|
||||
error={providerOAuthDialogError}
|
||||
remoteBrowserAccess={remoteBrowserAccess}
|
||||
onAuthorizationCodeChange={setXaiOAuthCode}
|
||||
onAuthorizationResponseChange={(value) => {
|
||||
setProviderOAuthResponse(value);
|
||||
setProviderOAuthDialogError(null);
|
||||
}}
|
||||
onOpenAuthorization={() => {
|
||||
if (!xaiOAuthFlow) return;
|
||||
const opened = window.open(xaiOAuthFlow.authorization_url, "_blank", "noopener,noreferrer");
|
||||
if (!providerOAuthFlow) return;
|
||||
const opened = window.open(
|
||||
providerOAuthFlow.authorization_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
if (opened) opened.opener = null;
|
||||
}}
|
||||
onComplete={() => void completeXaiOAuth()}
|
||||
onClose={closeXaiOAuthFlow}
|
||||
onComplete={() => void completeProviderOAuthResponse()}
|
||||
onClose={closeProviderOAuthFlow}
|
||||
/>
|
||||
|
||||
<NanobotFeatureInstallDialog
|
||||
@@ -2980,26 +3012,35 @@ function AppearanceSettings({
|
||||
);
|
||||
}
|
||||
|
||||
function XaiOAuthLoginDialog({
|
||||
function ProviderOAuthLoginDialog({
|
||||
flow,
|
||||
authorizationCode,
|
||||
providerLabel,
|
||||
authorizationResponse,
|
||||
completing,
|
||||
error,
|
||||
remoteBrowserAccess,
|
||||
onAuthorizationCodeChange,
|
||||
onAuthorizationResponseChange,
|
||||
onOpenAuthorization,
|
||||
onComplete,
|
||||
onClose,
|
||||
}: {
|
||||
flow: ProviderOAuthAuthorizationRequired | null;
|
||||
authorizationCode: string;
|
||||
providerLabel: string;
|
||||
authorizationResponse: string;
|
||||
completing: boolean;
|
||||
error: string | null;
|
||||
remoteBrowserAccess: boolean;
|
||||
onAuthorizationCodeChange: (value: string) => void;
|
||||
onAuthorizationResponseChange: (value: string) => void;
|
||||
onOpenAuthorization: () => void;
|
||||
onComplete: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const expectsCallbackUrl = flow?.completion_input === "callback_url";
|
||||
const inputId = expectsCallbackUrl ? "provider-oauth-callback" : "provider-oauth-code";
|
||||
const inputLabel = expectsCallbackUrl
|
||||
? t("settings.oauth.callbackUrl")
|
||||
: t("settings.oauth.authorizationCode");
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
@@ -3017,36 +3058,75 @@ function XaiOAuthLoginDialog({
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>xAI Grok</DialogTitle>
|
||||
<DialogTitle>{providerLabel}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{remoteBrowserAccess
|
||||
? t("settings.oauth.remoteCodeHelp")
|
||||
: t("settings.oauth.localCodeHelp")}
|
||||
{expectsCallbackUrl
|
||||
? remoteBrowserAccess
|
||||
? t("settings.oauth.remoteCallbackHelp")
|
||||
: t("settings.oauth.localCallbackHelp")
|
||||
: remoteBrowserAccess
|
||||
? t("settings.oauth.remoteCodeHelp")
|
||||
: t("settings.oauth.localCodeHelp")}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex items-center gap-2 rounded-[14px] border border-border/45 bg-muted/35 px-3 py-2.5 text-[12px] text-muted-foreground">
|
||||
{expectsCallbackUrl && remoteBrowserAccess ? (
|
||||
<Clipboard className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
) : (
|
||||
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin" aria-hidden />
|
||||
)}
|
||||
<span>
|
||||
{expectsCallbackUrl && remoteBrowserAccess
|
||||
? t("settings.oauth.pasteCallbackToContinue")
|
||||
: t("settings.oauth.waitingForCallback")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="xai-oauth-code"
|
||||
htmlFor={inputId}
|
||||
className="block text-xs font-medium text-foreground"
|
||||
>
|
||||
{t("settings.oauth.authorizationCode")}
|
||||
{inputLabel}
|
||||
</label>
|
||||
<Input
|
||||
id="xai-oauth-code"
|
||||
value={authorizationCode}
|
||||
onChange={(event) => onAuthorizationCodeChange(event.target.value)}
|
||||
placeholder={t("settings.oauth.authorizationCode")}
|
||||
aria-label={t("settings.oauth.authorizationCode")}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{expectsCallbackUrl ? (
|
||||
<Textarea
|
||||
id={inputId}
|
||||
value={authorizationResponse}
|
||||
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
|
||||
placeholder={t("settings.oauth.callbackUrlPlaceholder")}
|
||||
aria-label={inputLabel}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
className="min-h-[88px] resize-none break-all font-mono text-[12px] leading-5"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
id={inputId}
|
||||
value={authorizationResponse}
|
||||
onChange={(event) => onAuthorizationResponseChange(event.target.value)}
|
||||
placeholder={inputLabel}
|
||||
aria-label={inputLabel}
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{error ? (
|
||||
<p
|
||||
role="alert"
|
||||
className="rounded-[14px] border border-destructive/20 bg-destructive/5 px-3 py-2.5 text-[12px] text-destructive"
|
||||
>
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
<DialogFooter className="gap-2 sm:space-x-0">
|
||||
<Button type="button" variant="outline" onClick={onOpenAuthorization}>
|
||||
<ExternalLink className="mr-2 h-4 w-4" aria-hidden />
|
||||
{t("settings.oauth.signIn")}
|
||||
{expectsCallbackUrl
|
||||
? t("settings.oauth.openChatGPT")
|
||||
: t("settings.oauth.signIn")}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!authorizationCode.trim() || completing}>
|
||||
<Button type="submit" disabled={!authorizationResponse.trim() || completing}>
|
||||
{completing ? t("settings.oauth.signingIn") : t("settings.oauth.finishSignIn")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
@@ -4255,7 +4335,12 @@ function ProvidersSettings({
|
||||
account: provider.oauth_account || provider.label,
|
||||
defaultValue: "Signed in as {{account}}",
|
||||
})
|
||||
: provider.name === "xai_grok" && remoteBrowserAccess
|
||||
: provider.name === "openai_codex" && remoteBrowserAccess
|
||||
? tx(
|
||||
"settings.oauth.codexRemoteSignInHelp",
|
||||
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||
)
|
||||
: provider.name === "xai_grok" && remoteBrowserAccess
|
||||
? tx(
|
||||
"settings.oauth.remoteSignInHelp",
|
||||
"Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
"signedInAs": "Signed in as {{account}}",
|
||||
"signInHelp": "Sign in from this device; no API key is stored in config.",
|
||||
"remoteSignInHelp": "Select Sign in to open xAI on your computer, then paste the authorization code shown after login.",
|
||||
"codexRemoteSignInHelp": "Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||
"signInRequired": "Sign in required",
|
||||
"signInBeforeSaving": "Sign in before saving this provider in the preset.",
|
||||
"signedIn": "Signed in",
|
||||
@@ -782,7 +783,14 @@
|
||||
"saveProxy": "Save proxy",
|
||||
"localCodeHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, paste the authorization code below.",
|
||||
"remoteCodeHelp": "Select Sign in to open xAI on your computer. After signing in, paste the authorization code shown by xAI below.",
|
||||
"localCallbackHelp": "Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
|
||||
"remoteCallbackHelp": "Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
|
||||
"authorizationCode": "Authorization code",
|
||||
"callbackUrl": "Full callback URL",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Open ChatGPT",
|
||||
"pasteCallbackToContinue": "Paste the callback URL to continue.",
|
||||
"waitingForCallback": "Waiting for the browser callback…",
|
||||
"finishSignIn": "Finish sign-in"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -760,6 +760,7 @@
|
||||
"signedInAs": "Sesión iniciada como {{account}}",
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora y luego pega el código de autorización que se muestra tras iniciar sesión.",
|
||||
"codexRemoteSignInHelp": "Inicia sesión en este navegador y pega en nanobot la URL completa de devolución de localhost.",
|
||||
"signInRequired": "Inicio de sesión requerido",
|
||||
"signInBeforeSaving": "Inicia sesión en este proveedor antes de guardar el preajuste.",
|
||||
"signedIn": "Sesión iniciada",
|
||||
@@ -769,7 +770,14 @@
|
||||
"saveProxy": "Guardar proxy",
|
||||
"localCodeHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, pega el código de autorización abajo.",
|
||||
"remoteCodeHelp": "Selecciona Iniciar sesión para abrir xAI en tu computadora. Después de iniciar sesión, pega abajo el código de autorización que muestra xAI.",
|
||||
"localCallbackHelp": "Completa el inicio de sesión en el navegador. nanobot suele finalizar automáticamente; si no lo hace, copia de la barra de direcciones la URL completa de devolución de localhost y pégala abajo.",
|
||||
"remoteCallbackHelp": "Abre ChatGPT en este navegador y completa el inicio de sesión. Cuando la página de localhost no cargue, copia la URL completa de la barra de direcciones y pégala abajo.",
|
||||
"authorizationCode": "Código de autorización",
|
||||
"callbackUrl": "URL completa de devolución",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Abrir ChatGPT",
|
||||
"pasteCallbackToContinue": "Pega la URL de devolución para continuar.",
|
||||
"waitingForCallback": "Esperando la devolución del navegador…",
|
||||
"finishSignIn": "Completar inicio de sesión"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "Connecté en tant que {{account}}",
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur, puis collez le code d’autorisation affiché après la connexion.",
|
||||
"codexRemoteSignInHelp": "Connectez-vous dans ce navigateur, puis recollez dans nanobot l’URL complète de rappel localhost.",
|
||||
"signInRequired": "Connexion requise",
|
||||
"signInBeforeSaving": "Connectez-vous à ce fournisseur avant d’enregistrer le préréglage.",
|
||||
"signedIn": "Connecté",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "Enregistrer le proxy",
|
||||
"localCodeHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, collez le code d’autorisation ci-dessous.",
|
||||
"remoteCodeHelp": "Sélectionnez Se connecter pour ouvrir xAI sur votre ordinateur. Après la connexion, collez ci-dessous le code d’autorisation affiché par xAI.",
|
||||
"localCallbackHelp": "Terminez la connexion dans votre navigateur. nanobot termine généralement automatiquement ; sinon, copiez l’URL complète de rappel localhost depuis la barre d’adresse et collez-la ci-dessous.",
|
||||
"remoteCallbackHelp": "Ouvrez ChatGPT dans ce navigateur et terminez la connexion. Lorsque la page localhost ne se charge pas, copiez l’URL complète de la barre d’adresse et collez-la ci-dessous.",
|
||||
"authorizationCode": "Code d’autorisation",
|
||||
"callbackUrl": "URL complète de rappel",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Ouvrir ChatGPT",
|
||||
"pasteCallbackToContinue": "Collez l’URL de rappel pour continuer.",
|
||||
"waitingForCallback": "En attente du rappel du navigateur…",
|
||||
"finishSignIn": "Terminer la connexion"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "Masuk sebagai {{account}}",
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Pilih Masuk untuk membuka xAI di komputer Anda, lalu tempel kode otorisasi yang ditampilkan setelah masuk.",
|
||||
"codexRemoteSignInHelp": "Masuk melalui browser ini, lalu tempel URL callback localhost lengkap kembali ke nanobot.",
|
||||
"signInRequired": "Perlu masuk",
|
||||
"signInBeforeSaving": "Masuk ke penyedia ini sebelum menyimpan preset.",
|
||||
"signedIn": "Sudah masuk",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "Simpan proksi",
|
||||
"localCodeHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, tempel kode otorisasi di bawah.",
|
||||
"remoteCodeHelp": "Pilih Masuk untuk membuka xAI di komputer Anda. Setelah masuk, tempel kode otorisasi yang ditampilkan xAI di bawah.",
|
||||
"localCallbackHelp": "Selesaikan proses masuk di browser. nanobot biasanya menyelesaikannya secara otomatis; jika tidak, salin URL callback localhost lengkap dari bilah alamat dan tempel di bawah.",
|
||||
"remoteCallbackHelp": "Buka ChatGPT di browser ini dan selesaikan proses masuk. Saat halaman localhost gagal dimuat, salin URL lengkap dari bilah alamat dan tempel di bawah.",
|
||||
"authorizationCode": "Kode otorisasi",
|
||||
"callbackUrl": "URL callback lengkap",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Buka ChatGPT",
|
||||
"pasteCallbackToContinue": "Tempel URL callback untuk melanjutkan.",
|
||||
"waitingForCallback": "Menunggu callback browser…",
|
||||
"finishSignIn": "Selesaikan masuk"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "{{account}} としてサインイン済み",
|
||||
"signInHelp": "このデバイスからサインインします。API key は config に保存されません。",
|
||||
"remoteSignInHelp": "「サインイン」を選択して自分のコンピューターで xAI を開き、サインイン後に表示される認証コードを貼り付けてください。",
|
||||
"codexRemoteSignInHelp": "このブラウザーでサインインし、localhost の完全なコールバック URL を nanobot に貼り付けてください。",
|
||||
"signInRequired": "サインインが必要です",
|
||||
"signInBeforeSaving": "プリセットを保存する前に、このプロバイダーへサインインしてください。",
|
||||
"signedIn": "サインイン済み",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "プロキシを保存",
|
||||
"localCodeHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、認証コードを下に貼り付けてください。",
|
||||
"remoteCodeHelp": "「サインイン」を選択して自分のコンピューターで xAI を開いてください。サインイン後、xAI に表示された認証コードを下に貼り付けてください。",
|
||||
"localCallbackHelp": "ブラウザーでサインインを完了してください。通常は nanobot が自動で完了します。完了しない場合は、アドレスバーから localhost の完全なコールバック URL をコピーして下に貼り付けてください。",
|
||||
"remoteCallbackHelp": "このブラウザーで ChatGPT を開いてサインインを完了してください。localhost ページを開けない場合は、アドレスバーの完全な URL をコピーして下に貼り付けてください。",
|
||||
"authorizationCode": "認証コード",
|
||||
"callbackUrl": "完全なコールバック URL",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "ChatGPT を開く",
|
||||
"pasteCallbackToContinue": "続行するにはコールバック URL を貼り付けてください。",
|
||||
"waitingForCallback": "ブラウザーのコールバックを待機中…",
|
||||
"finishSignIn": "サインインを完了"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "{{account}}로 로그인됨",
|
||||
"signInHelp": "이 기기에서 로그인합니다. API key는 config에 저장되지 않습니다.",
|
||||
"remoteSignInHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 연 다음, 로그인 후 표시되는 인증 코드를 붙여 넣으세요.",
|
||||
"codexRemoteSignInHelp": "이 브라우저에서 로그인한 다음 전체 localhost 콜백 URL을 nanobot에 붙여 넣으세요.",
|
||||
"signInRequired": "로그인이 필요합니다",
|
||||
"signInBeforeSaving": "프리셋을 저장하기 전에 이 제공자에 로그인하세요.",
|
||||
"signedIn": "로그인됨",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "프록시 저장",
|
||||
"localCodeHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 인증 코드를 아래에 붙여 넣으세요.",
|
||||
"remoteCodeHelp": "로그인을 선택하여 사용자 컴퓨터에서 xAI를 여세요. 로그인 후 xAI에 표시된 인증 코드를 아래에 붙여 넣으세요.",
|
||||
"localCallbackHelp": "브라우저에서 로그인을 완료하세요. 일반적으로 nanobot이 자동으로 완료합니다. 완료되지 않으면 주소 표시줄에서 전체 localhost 콜백 URL을 복사해 아래에 붙여 넣으세요.",
|
||||
"remoteCallbackHelp": "이 브라우저에서 ChatGPT를 열고 로그인을 완료하세요. localhost 페이지가 열리지 않으면 주소 표시줄의 전체 URL을 복사해 아래에 붙여 넣으세요.",
|
||||
"authorizationCode": "인증 코드",
|
||||
"callbackUrl": "전체 콜백 URL",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "ChatGPT 열기",
|
||||
"pasteCallbackToContinue": "계속하려면 콜백 URL을 붙여 넣으세요.",
|
||||
"waitingForCallback": "브라우저 콜백 대기 중…",
|
||||
"finishSignIn": "로그인 완료"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
"signedInAs": "Conectado como {{account}}",
|
||||
"signInHelp": "Entre por este dispositivo; nenhuma chave de API é armazenada em config.",
|
||||
"remoteSignInHelp": "Selecione Entrar para abrir a xAI no seu computador e depois cole o código de autorização exibido após o login.",
|
||||
"codexRemoteSignInHelp": "Entre por este navegador e cole no nanobot a URL completa de callback do localhost.",
|
||||
"signInRequired": "Login necessário",
|
||||
"signInBeforeSaving": "Entre neste provedor antes de salvar a predefinição.",
|
||||
"signedIn": "Conectado",
|
||||
@@ -782,7 +783,14 @@
|
||||
"saveProxy": "Salvar proxy",
|
||||
"localCodeHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, cole o código de autorização abaixo.",
|
||||
"remoteCodeHelp": "Selecione Entrar para abrir a xAI no seu computador. Após o login, cole abaixo o código de autorização exibido pela xAI.",
|
||||
"localCallbackHelp": "Conclua o login no navegador. O nanobot geralmente termina automaticamente; caso contrário, copie da barra de endereço a URL completa de callback do localhost e cole abaixo.",
|
||||
"remoteCallbackHelp": "Abra o ChatGPT neste navegador e conclua o login. Quando a página localhost não carregar, copie a URL completa da barra de endereço e cole abaixo.",
|
||||
"authorizationCode": "Código de autorização",
|
||||
"callbackUrl": "URL completa de callback",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Abrir ChatGPT",
|
||||
"pasteCallbackToContinue": "Cole a URL de callback para continuar.",
|
||||
"waitingForCallback": "Aguardando o callback do navegador…",
|
||||
"finishSignIn": "Concluir login"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "Đã đăng nhập bằng {{account}}",
|
||||
"signInHelp": "Inicia sesión desde este dispositivo; no se guarda API key en config.",
|
||||
"remoteSignInHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn, sau đó dán mã ủy quyền được hiển thị sau khi đăng nhập.",
|
||||
"codexRemoteSignInHelp": "Đăng nhập trong trình duyệt này, sau đó dán lại URL callback localhost đầy đủ vào nanobot.",
|
||||
"signInRequired": "Cần đăng nhập",
|
||||
"signInBeforeSaving": "Hãy đăng nhập nhà cung cấp này trước khi lưu cấu hình đặt trước.",
|
||||
"signedIn": "Đã đăng nhập",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "Lưu proxy",
|
||||
"localCodeHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy dán mã ủy quyền bên dưới.",
|
||||
"remoteCodeHelp": "Chọn Đăng nhập để mở xAI trên máy tính của bạn. Sau khi đăng nhập, hãy dán mã ủy quyền do xAI hiển thị bên dưới.",
|
||||
"localCallbackHelp": "Hoàn tất đăng nhập trong trình duyệt. nanobot thường tự động hoàn tất; nếu không, hãy sao chép URL callback localhost đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
|
||||
"remoteCallbackHelp": "Mở ChatGPT trong trình duyệt này và hoàn tất đăng nhập. Khi trang localhost không tải được, hãy sao chép URL đầy đủ từ thanh địa chỉ và dán vào bên dưới.",
|
||||
"authorizationCode": "Mã ủy quyền",
|
||||
"callbackUrl": "URL callback đầy đủ",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "Mở ChatGPT",
|
||||
"pasteCallbackToContinue": "Dán URL callback để tiếp tục.",
|
||||
"waitingForCallback": "Đang chờ callback từ trình duyệt…",
|
||||
"finishSignIn": "Hoàn tất đăng nhập"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -773,6 +773,7 @@
|
||||
"signedInAs": "已登录为 {{account}}",
|
||||
"signInHelp": "从这台设备登录;不会在配置中保存 API key。",
|
||||
"remoteSignInHelp": "点击“登录”在你的电脑上打开 xAI,完成登录后粘贴页面显示的授权码。",
|
||||
"codexRemoteSignInHelp": "在此浏览器中登录,然后将完整的 localhost 回调 URL 粘贴回 nanobot。",
|
||||
"signInRequired": "需要登录",
|
||||
"signInBeforeSaving": "请先登录此提供商,再保存模型预设。",
|
||||
"signedIn": "已登录",
|
||||
@@ -782,7 +783,14 @@
|
||||
"saveProxy": "保存代理",
|
||||
"localCodeHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请将授权码粘贴到下方。",
|
||||
"remoteCodeHelp": "点击“登录”在你的电脑上打开 xAI。完成登录后,请将 xAI 显示的授权码粘贴到下方。",
|
||||
"localCallbackHelp": "请在浏览器中完成登录。nanobot 通常会自动完成;若未自动完成,请复制地址栏中的完整 localhost 回调 URL 并粘贴到下方。",
|
||||
"remoteCallbackHelp": "在此浏览器中打开 ChatGPT 并完成登录。当 localhost 页面无法打开时,请复制地址栏中的完整 URL 并粘贴到下方。",
|
||||
"authorizationCode": "授权码",
|
||||
"callbackUrl": "完整回调 URL",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "打开 ChatGPT",
|
||||
"pasteCallbackToContinue": "粘贴回调 URL 以继续。",
|
||||
"waitingForCallback": "正在等待浏览器回调…",
|
||||
"finishSignIn": "完成登录"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
@@ -759,6 +759,7 @@
|
||||
"signedInAs": "已使用 {{account}} 登入",
|
||||
"signInHelp": "請從這臺裝置登入;系統不會將 API 金鑰儲存在設定中。",
|
||||
"remoteSignInHelp": "點擊「登入」在你的電腦上開啟 xAI,完成登入後貼上頁面顯示的授權碼。",
|
||||
"codexRemoteSignInHelp": "請在此瀏覽器中登入,然後將完整的 localhost 回呼 URL 貼回 nanobot。",
|
||||
"signInRequired": "需要登入",
|
||||
"signInBeforeSaving": "請先登入此供應商,再儲存模型預設。",
|
||||
"signedIn": "已登入",
|
||||
@@ -768,7 +769,14 @@
|
||||
"saveProxy": "儲存代理",
|
||||
"localCodeHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請將授權碼貼到下方。",
|
||||
"remoteCodeHelp": "點擊「登入」在你的電腦上開啟 xAI。完成登入後,請將 xAI 顯示的授權碼貼到下方。",
|
||||
"localCallbackHelp": "請在瀏覽器中完成登入。nanobot 通常會自動完成;若未自動完成,請複製網址列中的完整 localhost 回呼 URL 並貼到下方。",
|
||||
"remoteCallbackHelp": "請在此瀏覽器中開啟 ChatGPT 並完成登入。當 localhost 頁面無法開啟時,請複製網址列中的完整 URL 並貼到下方。",
|
||||
"authorizationCode": "授權碼",
|
||||
"callbackUrl": "完整回呼 URL",
|
||||
"callbackUrlPlaceholder": "http://localhost:1455/auth/callback?code=…&state=…",
|
||||
"openChatGPT": "開啟 ChatGPT",
|
||||
"pasteCallbackToContinue": "貼上回呼 URL 以繼續。",
|
||||
"waitingForCallback": "正在等待瀏覽器回呼…",
|
||||
"finishSignIn": "完成登入"
|
||||
},
|
||||
"skills": {
|
||||
|
||||
+10
-2
@@ -61,6 +61,7 @@ function isSlashCommandLifecycle(value: unknown): value is SlashCommandLifecycle
|
||||
const CHANNEL_VALUES_HEADER = "X-Nanobot-Channel-Values";
|
||||
const API_SERVICE_VALUES_HEADER = "X-Nanobot-API-Service-Values";
|
||||
const OAUTH_CODE_HEADER = "X-Nanobot-OAuth-Code";
|
||||
const OAUTH_CALLBACK_HEADER = "X-Nanobot-OAuth-Callback";
|
||||
const PROVIDER_VALUES_HEADER = "X-Nanobot-Provider-Values";
|
||||
|
||||
export class ApiError extends Error {
|
||||
@@ -992,9 +993,11 @@ export async function loginProviderOAuth(
|
||||
token: string,
|
||||
provider: string,
|
||||
base: string = "",
|
||||
remoteBrowserAccess: boolean = false,
|
||||
): Promise<ProviderOAuthLoginResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
if (remoteBrowserAccess) query.set("remote_browser", "true");
|
||||
return request<ProviderOAuthLoginResult>(
|
||||
`${base}/api/settings/provider/oauth-login?${query}`,
|
||||
token,
|
||||
@@ -1006,13 +1009,18 @@ export async function completeProviderOAuth(
|
||||
token: string,
|
||||
provider: string,
|
||||
flowId: string,
|
||||
authorizationCode?: string,
|
||||
authorizationResponse?: string,
|
||||
base: string = "",
|
||||
): Promise<ProviderOAuthCompletionResult> {
|
||||
const query = new URLSearchParams();
|
||||
query.set("provider", provider);
|
||||
query.set("flow_id", flowId);
|
||||
const headers = authorizationCode ? { [OAUTH_CODE_HEADER]: authorizationCode } : undefined;
|
||||
const responseHeader = provider === "openai_codex"
|
||||
? OAUTH_CALLBACK_HEADER
|
||||
: OAUTH_CODE_HEADER;
|
||||
const headers = authorizationResponse
|
||||
? { [responseHeader]: authorizationResponse }
|
||||
: undefined;
|
||||
return request<ProviderOAuthCompletionResult>(
|
||||
`${base}/api/settings/provider/oauth-login/complete?${query}`,
|
||||
token,
|
||||
|
||||
@@ -458,6 +458,7 @@ export interface ProviderOAuthAuthorizationRequired {
|
||||
flow_id: string;
|
||||
authorization_url: string;
|
||||
expires_in: number;
|
||||
completion_input?: "authorization_code" | "callback_url";
|
||||
}
|
||||
|
||||
export interface ProviderOAuthPending {
|
||||
|
||||
@@ -651,6 +651,14 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await loginProviderOAuth("tok", "openai_codex", "", true);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
);
|
||||
|
||||
await completeProviderOAuth("tok", "xai_grok", "flow-123");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=xai_grok&flow_id=flow-123",
|
||||
@@ -675,6 +683,23 @@ describe("webui API helpers", () => {
|
||||
}),
|
||||
);
|
||||
|
||||
await completeProviderOAuth(
|
||||
"tok",
|
||||
"openai_codex",
|
||||
"flow-codex",
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
);
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||
expect.objectContaining({
|
||||
headers: {
|
||||
Authorization: "Bearer tok",
|
||||
"X-Nanobot-OAuth-Callback":
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
await logoutProviderOAuth("tok", "openai_codex");
|
||||
expect(fetch).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-logout?provider=openai_codex",
|
||||
|
||||
@@ -2753,6 +2753,219 @@ describe("SettingsView Apps catalog", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("polls local OpenAI Codex sign-in until the loopback callback completes", async () => {
|
||||
const base = settingsPayload();
|
||||
const codexProvider = {
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth" as const,
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://chatgpt.com/backend-api",
|
||||
model_catalog: "builtin",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
};
|
||||
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
|
||||
const signedIn: SettingsPayload = {
|
||||
...payload,
|
||||
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
|
||||
};
|
||||
const authorization = {
|
||||
status: "authorization_required",
|
||||
provider: "openai_codex",
|
||||
flow_id: "flow-codex-local",
|
||||
authorization_url: "https://auth.openai.com/oauth/authorize?state=local",
|
||||
expires_in: 600,
|
||||
completion_input: "callback_url",
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (url === "/api/settings/provider/oauth-login?provider=openai_codex") {
|
||||
return jsonResponse(authorization);
|
||||
}
|
||||
if (
|
||||
url ===
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex-local"
|
||||
) {
|
||||
expect(init?.headers).not.toHaveProperty("X-Nanobot-OAuth-Callback");
|
||||
return jsonResponse(signedIn);
|
||||
}
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const openMock = vi.fn();
|
||||
vi.stubGlobal("open", openMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||
|
||||
await chooseProviderToConfigure("OpenAI Codex");
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex",
|
||||
expect.objectContaining({ headers: { Authorization: "Bearer tok" } }),
|
||||
);
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
"Complete sign-in in your browser. Nanobot usually finishes automatically; if it does not, copy the full localhost callback URL from the address bar and paste it below.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Waiting for the browser callback…")).toBeInTheDocument();
|
||||
expect(
|
||||
within(dialog).queryByText("Paste the callback URL to continue."),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Signed in as acct-codex", {}, { timeout: 2500 }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("completes remote OpenAI Codex sign-in with the full callback URL", async () => {
|
||||
const happyWindow = window as typeof window & {
|
||||
happyDOM: { setURL: (url: string) => void };
|
||||
};
|
||||
const originalUrl = window.location.href;
|
||||
happyWindow.happyDOM.setURL("http://203.0.113.10:18887/#/settings?section=models");
|
||||
|
||||
try {
|
||||
const base = settingsPayload();
|
||||
const codexProvider = {
|
||||
name: "openai_codex",
|
||||
label: "OpenAI Codex",
|
||||
configured: false,
|
||||
auth_type: "oauth" as const,
|
||||
api_key_required: false,
|
||||
api_key_hint: null,
|
||||
api_base: null,
|
||||
default_api_base: "https://chatgpt.com/backend-api",
|
||||
model_catalog: "builtin",
|
||||
oauth_account: null,
|
||||
oauth_expires_at: null,
|
||||
oauth_login_supported: true,
|
||||
};
|
||||
const payload: SettingsPayload = { ...base, providers: [codexProvider] };
|
||||
const signedIn: SettingsPayload = {
|
||||
...payload,
|
||||
providers: [{ ...codexProvider, configured: true, oauth_account: "acct-codex" }],
|
||||
};
|
||||
const authorization = {
|
||||
status: "authorization_required",
|
||||
provider: "openai_codex",
|
||||
flow_id: "flow-codex",
|
||||
authorization_url: "https://auth.openai.com/oauth/authorize?state=test",
|
||||
expires_in: 600,
|
||||
completion_input: "callback_url",
|
||||
};
|
||||
const callbackUrl =
|
||||
"http://localhost:1455/auth/callback?code=secret&state=test";
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url === "/api/settings") return jsonResponse(payload);
|
||||
if (
|
||||
url ===
|
||||
"/api/settings/provider/oauth-login?provider=openai_codex&remote_browser=true"
|
||||
) {
|
||||
return jsonResponse(authorization);
|
||||
}
|
||||
if (
|
||||
url ===
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex"
|
||||
) {
|
||||
const headers = init?.headers as Record<string, string>;
|
||||
if (headers?.["X-Nanobot-OAuth-Callback"]) {
|
||||
expect(headers["X-Nanobot-OAuth-Callback"]).toBe(callbackUrl);
|
||||
return jsonResponse(signedIn);
|
||||
}
|
||||
return jsonResponse({
|
||||
status: "pending",
|
||||
provider: "openai_codex",
|
||||
flow_id: "flow-codex",
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return jsonResponse({});
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const popup = {
|
||||
opener: window,
|
||||
location: { href: "about:blank" },
|
||||
close: vi.fn(),
|
||||
};
|
||||
const openMock = vi.fn(() => popup);
|
||||
vi.stubGlobal("open", openMock);
|
||||
|
||||
renderSettingsView({ initialSection: "models", initialSettings: payload });
|
||||
|
||||
await chooseProviderToConfigure("OpenAI Codex");
|
||||
expect(
|
||||
screen.getByText(
|
||||
"Sign in through this browser, then paste the full localhost callback URL back into nanobot.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Sign in" }));
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
|
||||
expect(openMock).not.toHaveBeenCalled();
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
"Open ChatGPT in this browser and finish signing in. When the localhost page fails to load, copy the full URL from the address bar and paste it below.",
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
expect(within(dialog).getByText("Paste the callback URL to continue.")).toBeInTheDocument();
|
||||
const callbackInput = within(dialog).getByRole("textbox", {
|
||||
name: "Full callback URL",
|
||||
});
|
||||
expect(callbackInput).toHaveAttribute(
|
||||
"placeholder",
|
||||
"http://localhost:1455/auth/callback?code=…&state=…",
|
||||
);
|
||||
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Open ChatGPT" }));
|
||||
expect(openMock).toHaveBeenCalledWith(
|
||||
authorization.authorization_url,
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
expect(popup.opener).toBeNull();
|
||||
|
||||
fireEvent.change(callbackInput, { target: { value: callbackUrl } });
|
||||
fireEvent.click(within(dialog).getByRole("button", { name: "Finish sign-in" }));
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/provider/oauth-login/complete?provider=openai_codex&flow_id=flow-codex",
|
||||
expect.objectContaining({
|
||||
headers: expect.objectContaining({
|
||||
"X-Nanobot-OAuth-Callback": callbackUrl,
|
||||
}),
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Signed in as acct-codex")).toBeInTheDocument();
|
||||
} finally {
|
||||
happyWindow.happyDOM.setURL(originalUrl);
|
||||
}
|
||||
});
|
||||
|
||||
it("saves scoped proxies for xAI and OpenAI Codex OAuth providers", async () => {
|
||||
const base = settingsPayload();
|
||||
const providers: SettingsPayload["providers"] = [
|
||||
|
||||
Reference in New Issue
Block a user