-
Notifications
You must be signed in to change notification settings - Fork 699
feat(oauth): support importing Google Antigravity accounts from Cockpit Tools JSON (#1076) #1077
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d91feb2
8f1a74e
4d1277c
29463aa
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -51,6 +51,10 @@ export default function ProviderAuthPanel({ | |
| const [addingKey, setAddingKey] = useState(false); | ||
| const [newKey, setNewKey] = useState(""); | ||
| const [keyBusy, setKeyBusy] = useState(false); | ||
| const [importingJson, setImportingJson] = useState(false); | ||
| const [importJsonText, setImportJsonText] = useState(""); | ||
| const [importBusy, setImportBusy] = useState(false); | ||
| const [importResult, setImportResult] = useState<{ imported: number; failed: number } | null>(null); | ||
| const [reserveQuotaSlots, setReserveQuotaSlots] = useState(false); | ||
| const deviceCodeCopy = useCopyFeedback<string>(); | ||
|
|
||
|
|
@@ -278,11 +282,93 @@ export default function ProviderAuthPanel({ | |
| {accountLoadState === "ready" && loggedIn && accounts.length === 0 && ( | ||
| <div className="pwi-auth-state pwi-auth-state--empty">{t("pws.noAccounts")}</div> | ||
| )} | ||
| {loggedIn && ( | ||
| <button type="button" className="btn btn-ghost btn-sm" style={{ marginTop: 8 }} | ||
| onClick={() => void authHandlers.onLogin(item.name, true)} disabled={busy || Boolean(switchingAccountId)}> | ||
| {t("pws.addAccount")} | ||
| </button> | ||
| {importingJson ? ( | ||
| <div className="pwi-auth-add-key" style={{ marginTop: 8, flexDirection: "column", alignItems: "stretch" }}> | ||
| <textarea | ||
| className="input" | ||
| rows={4} | ||
| value={importJsonText} | ||
| onChange={e => setImportJsonText(e.target.value)} | ||
| placeholder='[{"email":"user@gmail.com","refresh_token":"1//..."}]' | ||
| disabled={importBusy} | ||
| style={{ fontFamily: "var(--font-mono, monospace)", fontSize: 12 }} | ||
| /> | ||
|
Comment on lines
+287
to
+295
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The new JSON paste textarea has no associated label or AGENTS.md reference: gui/AGENTS.md:L31-L33 Useful? React with 👍 / 👎. |
||
| {importResult && ( | ||
| <div className="muted faint" style={{ fontSize: 12, marginTop: 4 }}> | ||
| {t("pws.importResultSummary", { imported: importResult.imported, failed: importResult.failed })} | ||
| </div> | ||
| )} | ||
| <div style={{ display: "flex", gap: 8, marginTop: 8 }}> | ||
| <button | ||
| type="button" | ||
| className="btn btn-primary btn-sm" | ||
| disabled={importBusy || !importJsonText.trim()} | ||
| onClick={async () => { | ||
| const text = importJsonText.trim(); | ||
| if (!text) return; | ||
| setImportBusy(true); | ||
| setImportResult(null); | ||
| try { | ||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(text); | ||
| } catch (err) { | ||
| void err; | ||
| setImportResult({ imported: 0, failed: 1 }); | ||
| return; | ||
|
Comment on lines
+315
to
+318
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When the pasted JSON is malformed, this catch reduces the error to the same count-only summary used for any other failure, and the later non-OK response path does the same. In those cases users only see “0 imported, 1 failed” and cannot tell whether to fix JSON syntax, choose a different provider, or retry a server-side failure; store and render a localized validation/error message from AGENTS.md reference: gui/AGENTS.md:L31-L33 Useful? React with 👍 / 👎. |
||
| } | ||
| const res = await fetch(`${apiBase}/api/oauth/accounts/import`, { | ||
| method: "POST", | ||
| headers: { "Content-Type": "application/json" }, | ||
| body: JSON.stringify({ | ||
| provider: item.name, | ||
| accounts: Array.isArray(parsed) ? parsed : (parsed as { accounts?: unknown })?.accounts ?? parsed, | ||
| }), | ||
|
Comment on lines
+320
to
+326
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a user imports Google Antigravity JSON from the Accounts tab, this direct POST stores a high-risk OAuth refresh token without going through the existing dashboard warning path ( Useful? React with 👍 / 👎. |
||
| }); | ||
| if (res.ok) { | ||
| const data = (await res.json()) as { importedCount?: number; failedCount?: number }; | ||
| setImportResult({ imported: data.importedCount ?? 0, failed: data.failedCount ?? 0 }); | ||
| if ((data.importedCount ?? 0) > 0) { | ||
| setImportJsonText(""); | ||
| void authHandlers.onRetryAccounts?.(item.name); | ||
| } | ||
| } else { | ||
| setImportResult({ imported: 0, failed: 1 }); | ||
| } | ||
| } catch (err) { | ||
| void err; | ||
| setImportResult({ imported: 0, failed: 1 }); | ||
| } finally { | ||
| setImportBusy(false); | ||
| } | ||
| }} | ||
| > | ||
| {importBusy ? t("pws.saving") : t("pws.importJson")} | ||
| </button> | ||
| <button | ||
| type="button" | ||
| className="btn btn-ghost btn-sm" | ||
| onClick={() => { setImportingJson(false); setImportJsonText(""); setImportResult(null); }} | ||
| > | ||
| {t("common.cancel")} | ||
| </button> | ||
| </div> | ||
| </div> | ||
| ) : ( | ||
| <div style={{ display: "flex", gap: 8, marginTop: 8 }}> | ||
| {loggedIn && ( | ||
| <button type="button" className="btn btn-ghost btn-sm" | ||
| onClick={() => void authHandlers.onLogin(item.name, true)} disabled={busy || Boolean(switchingAccountId)}> | ||
| {t("pws.addAccount")} | ||
| </button> | ||
| )} | ||
| {item.name === "google-antigravity" && ( | ||
| <button type="button" className="btn btn-ghost btn-sm" | ||
| onClick={() => setImportingJson(true)} disabled={busy || Boolean(switchingAccountId)}> | ||
| {t("pws.importJsonCockpit")} | ||
| </button> | ||
| )} | ||
| </div> | ||
| )} | ||
| </> | ||
| )} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -20,7 +20,8 @@ const EXTENDED_USAGE = `Usage: | |
| ocx account alias <provider> <id|main> <display-name|-> [--json] | ||
| ocx account remove <provider> <id|main> --yes [--json] | ||
| ocx account clear-cooldown <provider> <id|main> [--json] | ||
| ocx account add-key <provider> [--label <label>] [--json]`; | ||
| ocx account add-key <provider> [--label <label>] [--json] | ||
| ocx account import <provider> <file-or-json> [--json]`; | ||
| const PIPE_GUIDANCE = `Pipe the API key on stdin, for example: | ||
| ocx account add-key <provider> <<< "$MY_KEY" | ||
| security find-generic-password -w <item> | ocx account add-key <provider>`; | ||
|
|
@@ -348,3 +349,63 @@ export async function cmdAlias(args: string[], deps: AccountDeps): Promise<numbe | |
| else console.log(alias ? `${name}: ${requestedId} is now “${alias}”` : `${name}: cleared alias for ${requestedId}`); | ||
| return 0; | ||
| } | ||
|
|
||
|
|
||
| export async function cmdImport(args: string[], deps: AccountDeps): Promise<number> { | ||
| const wantsJson = flag(args, "--json"); | ||
| const name = args.shift(); | ||
| const input = args.shift(); | ||
| if (!name || !input || args.length) return usage(); | ||
|
|
||
| const classified = configAndType(deps, name); | ||
| if ("error" in classified) return usage(`Error: ${classified.error}`); | ||
| if (classified.type !== "oauth") { | ||
| return usage("Error: account import only applies to OAuth providers (such as google-antigravity)"); | ||
| } | ||
|
|
||
| let jsonText = input; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When AGENTS.md reference: AGENTS.md:L218-L224 Useful? React with 👍 / 👎. |
||
| const { existsSync, readFileSync } = await import("node:fs"); | ||
| if (existsSync(input)) { | ||
| try { | ||
| jsonText = readFileSync(input, "utf8"); | ||
| } catch (err) { | ||
| return usage(`Error reading file "${input}": ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
| } | ||
|
|
||
| let parsed: unknown; | ||
| try { | ||
| parsed = JSON.parse(jsonText); | ||
| } catch (err) { | ||
| return usage(`Error parsing JSON: ${err instanceof Error ? err.message : String(err)}`); | ||
| } | ||
|
|
||
| const baseUrl = await resolveBaseUrl(deps); | ||
| if (!baseUrl) return proxyUnreachable(); | ||
|
|
||
| const response = await apiJson(deps, baseUrl, "POST", "/api/oauth/accounts/import", { | ||
| provider: name, | ||
| accounts: Array.isArray(parsed) ? parsed : (parsed as { accounts?: unknown })?.accounts ?? parsed, | ||
| }); | ||
|
|
||
| if (response.status === 0) return proxyUnreachable(); | ||
| if (response.status !== 200) return apiError(response.json, `failed to import accounts for ${name}`); | ||
|
|
||
| if (wantsJson) { | ||
| console.log(JSON.stringify(response.json, null, 2)); | ||
| } else { | ||
| const imp = response.json.importedCount ?? 0; | ||
| const fail = response.json.failedCount ?? 0; | ||
| console.log(`${name}: imported ${imp} account(s) successfully (${fail} failed)`); | ||
| if (Array.isArray(response.json.results)) { | ||
| for (const res of response.json.results) { | ||
| if (res.status === "imported") { | ||
| console.log(` ✓ ${res.email ?? res.accountId}`); | ||
| } else { | ||
| console.log(` ✗ ${res.email ?? "account"}: ${res.error}`); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| return 0; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When the import API returns HTTP 200 with Useful? React with 👍 / 👎. |
||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -376,6 +376,93 @@ export async function handleOauthAccountRoutes(ctx: ManagementContext): Promise< | |
| return jsonResponse({ ok: true, cleared }); | ||
| } | ||
|
|
||
| if (url.pathname === "/api/oauth/accounts/import" && req.method === "POST") { | ||
| const rawBody = await readManagementJsonBodyOr(req, null); | ||
| let provider = (url.searchParams.get("provider") ?? "").trim().toLowerCase(); | ||
| let accountEntriesRaw: unknown[] = []; | ||
|
|
||
| if (Array.isArray(rawBody)) { | ||
| accountEntriesRaw = rawBody; | ||
| } else if (isPlainRecord(rawBody)) { | ||
| if (typeof rawBody.provider === "string" && rawBody.provider.trim()) { | ||
| provider = rawBody.provider.trim().toLowerCase(); | ||
| } | ||
| if (Array.isArray(rawBody.accounts)) { | ||
| accountEntriesRaw = rawBody.accounts; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| if (!provider) provider = "google-antigravity"; | ||
| if (!isPublicOAuthProvider(provider)) return jsonResponse({ error: "unknown oauth provider" }, 400); | ||
| if (provider !== "google-antigravity") { | ||
| return jsonResponse({ error: "account import is currently only supported for google-antigravity" }, 400); | ||
| } | ||
| if (accountEntriesRaw.length === 0) return jsonResponse({ error: "no valid accounts array provided" }, 400); | ||
| if (accountEntriesRaw.length > 100) return jsonResponse({ error: "batch import exceeds maximum limit of 100 accounts" }, 400); | ||
|
|
||
| const { saveCredential, getAccountSet } = await import("../../oauth/store"); | ||
| const { refreshAntigravityToken } = await import("../../oauth/google-antigravity"); | ||
| const { clearProviderQuotaCache, clearAccountQuotaCache } = await import("../../providers/quota"); | ||
|
|
||
| const results: Array<{ email?: string; accountId?: string; status: "imported" | "failed"; error?: string }> = []; | ||
| let importedCount = 0; | ||
| let failedCount = 0; | ||
|
|
||
| for (const rawItem of accountEntriesRaw) { | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. For a large Cockpit export or accidental JSON array with many objects, this unbounded loop calls Useful? React with 👍 / 👎. |
||
| if (!isPlainRecord(rawItem)) { | ||
| results.push({ status: "failed", error: "invalid account object" }); | ||
| failedCount++; | ||
| continue; | ||
| } | ||
|
|
||
| const entry = rawItem as Record<string, unknown>; | ||
| const refreshToken = ( | ||
| (typeof entry.refresh_token === "string" ? entry.refresh_token : "") | ||
| || (typeof entry.refreshToken === "string" ? entry.refreshToken : "") | ||
| || (typeof entry.refresh === "string" ? entry.refresh : "") | ||
| ).trim(); | ||
| const inputEmail = typeof entry.email === "string" ? entry.email.trim().toLowerCase() : undefined; | ||
|
|
||
| if (!refreshToken) { | ||
| results.push({ email: inputEmail, status: "failed", error: "missing refresh token" }); | ||
| failedCount++; | ||
| continue; | ||
| } | ||
|
|
||
| try { | ||
| const creds = await refreshAntigravityToken(refreshToken); | ||
| if (inputEmail && !creds.email) creds.email = inputEmail; | ||
| const identity = creds.accountId ?? creds.email; | ||
| if (!identity) { | ||
| results.push({ email: inputEmail, status: "failed", error: "account identity required (email or accountId)" }); | ||
| failedCount++; | ||
| continue; | ||
| } | ||
| await saveCredential(provider, creds, { preserveIdentityless: true }); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Because imports now require an account identity, passing Useful? React with 👍 / 👎. |
||
| const set = getAccountSet(provider); | ||
| const activeAcc = set?.accounts.find(a => (a.credential.accountId ?? a.credential.email) === identity); | ||
| results.push({ email: creds.email ?? inputEmail, accountId: activeAcc?.id, status: "imported" }); | ||
| importedCount++; | ||
| } catch (err) { | ||
| results.push({ email: inputEmail, status: "failed", error: err instanceof Error ? err.message : String(err) }); | ||
| failedCount++; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
|
|
||
| reconcileLiveStateStores(); | ||
| clearProviderQuotaCache(); | ||
| clearAccountQuotaCache(provider); | ||
|
|
||
| return jsonResponse({ | ||
| ok: importedCount > 0, | ||
| provider, | ||
| importedCount, | ||
| failedCount, | ||
| totalCount: accountEntriesRaw.length, | ||
| results, | ||
| }); | ||
| } | ||
|
|
||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| if (url.pathname === "/api/oauth/accounts/alias" && req.method === "PUT") { | ||
| const body = await readManagementJsonBodyOr(req, {}) as { provider?: unknown; accountId?: unknown; alias?: unknown }; | ||
| const provider = typeof body.provider === "string" ? body.provider.trim().toLowerCase() : ""; | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.