From a68a9f84ceb1c8afab9a86f5ffe8c6fde606551c Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Sat, 15 Aug 2026 20:10:11 +0900 Subject: [PATCH 1/3] feat(gui): add manual paste fallback for OAuth add-account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Expose the existing /api/oauth/login/code path in the GUI: while a login is in progress the account panel shows a paste box that accepts a redirect URL / authorization code / raw Command Code API key (Command Code uses password masking). Keeps the server-side rotation/pool logic untouched — minimal surface to let users add a second Command Code account without fighting the localhost callback. GUI: ProviderAuthPanel + types + use-providers-oauth hook + Providers wiring, paste styles. i18n: en/de/ja/ko/ru/tr/zh/zh-TW (command-code placeholder + hint, plus the refined redirect hint from #1552). Test: provider-auth-manual-code (password type + role=status/alert feedback). --- .../provider-workspace/ProviderAuthPanel.tsx | 70 ++++++++++++ .../components/provider-workspace/types.ts | 2 + gui/src/i18n/de.ts | 4 +- gui/src/i18n/en.ts | 2 + gui/src/i18n/ja.ts | 4 +- gui/src/i18n/ko.ts | 4 +- gui/src/i18n/ru.ts | 4 +- gui/src/i18n/tr.ts | 4 +- gui/src/i18n/zh-TW.ts | 4 +- gui/src/i18n/zh.ts | 4 +- gui/src/pages/Providers.tsx | 3 +- gui/src/pages/use-providers-oauth.ts | 21 +++- .../styles/provider-workspace-settings.css | 1 + gui/tests/provider-auth-manual-code.test.tsx | 102 ++++++++++++++++++ 14 files changed, 220 insertions(+), 9 deletions(-) create mode 100644 gui/tests/provider-auth-manual-code.test.tsx diff --git a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx index 60c15225de..2799391764 100644 --- a/gui/src/components/provider-workspace/ProviderAuthPanel.tsx +++ b/gui/src/components/provider-workspace/ProviderAuthPanel.tsx @@ -119,6 +119,10 @@ export default function ProviderAuthPanel({ const [addingKey, setAddingKey] = useState(false); const [newKey, setNewKey] = useState(""); const [keyBusy, setKeyBusy] = useState(false); + const [manualCode, setManualCode] = useState(""); + const [manualCodeBusy, setManualCodeBusy] = useState(false); + const [manualCodeMsg, setManualCodeMsg] = useState(""); + const [manualCodeOk, setManualCodeOk] = useState(true); const [importBusy, setImportBusy] = useState(false); const [importStatus, setImportStatus] = useState<"idle" | "invalid" | "failed" | "complete">("idle"); const [importResult, setImportResult] = useState(null); @@ -195,6 +199,25 @@ export default function ProviderAuthPanel({ } }; + const submitManualCode = async () => { + const input = manualCode.trim(); + if (!input || manualCodeBusy || !authHandlers.onSubmitManualCode) return; + setManualCodeBusy(true); + setManualCodeMsg(""); + try { + await authHandlers.onSubmitManualCode(item.name, input); + setManualCode(""); + setManualCodeOk(true); + setManualCodeMsg(t("prov.pasteOk")); + } catch (error) { + setManualCodeOk(false); + const message = error instanceof Error && error.message.trim() ? error.message : t("prov.networkError"); + setManualCodeMsg(t("prov.pasteFail", { error: message })); + } finally { + setManualCodeBusy(false); + } + }; + const importCockpitFile = async (file: File | undefined) => { if (!file || importBusy) return; setImportBusy(true); @@ -326,6 +349,53 @@ export default function ProviderAuthPanel({ )} + {authHandlers.onSubmitManualCode && ( +
+
+ {item.name === "command-code" + ? t("prov.pasteCommandCodeHint") + : t("prov.pasteRedirectHint")} +
+
+ { setManualCode(e.target.value); setManualCodeMsg(""); }} + onKeyDown={e => { + if (e.key === "Enter" && manualCode.trim()) { + e.preventDefault(); + void submitManualCode(); + } + }} + placeholder={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + aria-label={item.name === "command-code" ? t("prov.pasteCommandCodePlaceholder") : t("prov.pasteRedirect")} + disabled={manualCodeBusy} + className="input text-label" + style={{ flex: 1 }} + /> + +
+ {manualCodeMsg && ( +
+ {manualCodeMsg} +
+ )} +
+ )} {authHandlers.onCancelLogin && ( )} diff --git a/gui/src/i18n/fr.ts b/gui/src/i18n/fr.ts index 1382ed2ee9..34ac10361c 100644 --- a/gui/src/i18n/fr.ts +++ b/gui/src/i18n/fr.ts @@ -390,6 +390,8 @@ export const fr: Record = { "prov.accountId": "ID", "prov.pasteRedirect": "Coller l’URL de redirection ou le code", "prov.pasteRedirectHint": "Si le navigateur affiche une erreur localhost, copiez l’URL complète depuis sa barre d’adresse et collez-la ici (ou collez le code d’autorisation).", + "prov.pasteCommandCodePlaceholder": "Coller la clé API Command Code ou l’URL de redirection", + "prov.pasteCommandCodeHint": "Collez une clé API Command Code (user_… depuis ~/.commandcode/auth.json) pour l’ajouter comme autre compte, ou l’URL de redirection du navigateur.", "prov.pasteSubmit": "Envoyer", "prov.pasteSubmitting": "Envoi…", "prov.pasteOk": "Code envoyé — finalisation de la connexion…", diff --git a/gui/tests/provider-auth-manual-code.test.tsx b/gui/tests/provider-auth-manual-code.test.tsx index ed2aa56773..9714562839 100644 --- a/gui/tests/provider-auth-manual-code.test.tsx +++ b/gui/tests/provider-auth-manual-code.test.tsx @@ -99,4 +99,44 @@ test("masks pasted Command Code credentials and preserves rejection feedback", a await new Promise(resolve => setTimeout(resolve, 0)); }); expect(host.querySelector('[role="status"]')?.textContent).toContain("Code submitted — finishing login…"); + + // Ending the flow must clear the credential and its feedback even when the + // provider panel remains mounted for the next Add account attempt. + await act(async () => { + root!.render( + + {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }} + /> + , + ); + }); + expect(host.querySelector('input[type="password"]')).toBeNull(); + await act(async () => { + root!.render( + + {}, onLogout: () => {}, onReauth: () => {}, onSwitchAccount: () => {}, onRemoveAccount: () => {}, + onAddApiKey: async () => true, onSwitchApiKey: () => {}, onRemoveApiKey: () => {}, onEditAlias: () => {}, + onSubmitManualCode: submit, + }} + /> + , + ); + }); + expect((host.querySelector('input[type="password"]') as HTMLInputElement).value).toBe(""); + expect(host.querySelector('[role="status"]')).toBeNull(); });