diff --git a/changelog.d/features/15746-account-manager-import.md b/changelog.d/features/15746-account-manager-import.md new file mode 100644 index 000000000000..917907ab9820 --- /dev/null +++ b/changelog.d/features/15746-account-manager-import.md @@ -0,0 +1 @@ +- **feat(oauth):** Import Antigravity Manager account exports with verified identities, bounded request bodies, account selection, and per-account results ([#15746](https://github.com/diegosouzapw/OmniRoute/pull/15746)). diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 2ff161562650..cd4b37cdfbd7 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -11586,6 +11586,123 @@ paths: responses: "200": description: OK + /api/oauth/{provider}/import-export: + post: + tags: + - Oauth + summary: Import OAuth accounts from a third-party account-manager export + description: > + Bulk-imports OAuth accounts from a plaintext account-manager export (for example + Antigravity Manager's cloud-accounts export). The body is the export document itself: + `{ "accounts": [...] }`, a bare array, or a single record, with at most 100 accounts and + at most 5 MiB. Tokens may live under a nested `token` object or flat on the record, in + snake_case or camelCase. Each record is refreshed and finalized through the same path as + `paste-credentials`, only an upstream-verified account email takes part in de-duplication, + and re-importing refreshes an existing connection in place. Supported providers are those + accepted by `paste-credentials` (`antigravity`, `agy`). Requires management authentication. + security: + - BearerAuth: [] + - ManagementSessionAuth: [] + parameters: + - name: provider + in: path + required: true + schema: + type: string + enum: + - antigravity + - agy + requestBody: + required: true + content: + application/json: + schema: + oneOf: + - type: object + properties: + accounts: + type: array + maxItems: 100 + items: + type: object + additionalProperties: true + - type: array + maxItems: 100 + items: + type: object + additionalProperties: true + - type: object + additionalProperties: true + responses: + "200": + description: Per-record import summary (also returned when some or all records failed) + content: + application/json: + schema: + type: object + required: + - success + - imported + - failed + - total + - results + properties: + success: + type: boolean + description: True only when no record failed. + imported: + type: integer + failed: + type: integer + total: + type: integer + results: + type: array + items: + type: object + required: + - index + - ok + properties: + index: + type: integer + email: + type: string + ok: + type: boolean + connectionId: + type: string + connectionEmail: + type: string + error: + type: string + "400": + description: Unsupported provider, invalid or empty JSON body, or no accounts in the payload + content: + application/json: + schema: + type: object + properties: + error: + type: string + "401": + description: Missing or invalid management credentials + content: + application/json: + schema: + type: object + properties: + error: + type: string + "413": + description: Account export exceeds the 5 MiB request body limit + content: + application/json: + schema: + type: object + properties: + error: + type: string /api/oauth/cliproxy-import: get: tags: diff --git a/src/app/(dashboard)/dashboard/providers/components/OAuthExportImportModal.tsx b/src/app/(dashboard)/dashboard/providers/components/OAuthExportImportModal.tsx new file mode 100644 index 000000000000..06cf35537830 --- /dev/null +++ b/src/app/(dashboard)/dashboard/providers/components/OAuthExportImportModal.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { useRef, useState } from "react"; +import { useTranslations } from "next-intl"; +import { Button, Modal } from "@/shared/components"; + +interface OAuthExportImportModalProps { + isOpen: boolean; + onClose: () => void; + onImported: () => Promise; +} + +type PreviewAccount = { + index: number; + provider: string; + email: string; + hasRefreshToken: boolean; + selected: boolean; +}; + +type ImportOutcome = { + ok: boolean; + imported: number; + failed: number; + total: number; + errorLines: string[]; +}; + +/** + * Pull the account records out of an export document. Accepts the raw + * third-party export shape (`{ accounts: [...] }` — e.g. Antigravity + * Manager's cloud-accounts export), a bare top-level array, or a single + * record object. Returns an empty array when nothing usable is found. + */ +function extractAccounts(doc: unknown): Record[] { + let candidate: unknown = doc; + if (doc && typeof doc === "object" && !Array.isArray(doc)) { + const maybe = (doc as Record).accounts; + if (maybe !== undefined) candidate = maybe; + } + if (!candidate) return []; + const list = Array.isArray(candidate) ? candidate : [candidate]; + return list.filter((item): item is Record => !!item && typeof item === "object"); +} + +function accountEmail(account: Record): string { + if (typeof account.email === "string" && account.email) return account.email; + const token = account.token as Record | undefined; + if (token && typeof token.email === "string" && token.email) return token.email; + return ""; +} + +function accountHasRefreshToken(account: Record): boolean { + const token = (account.token as Record | undefined) ?? account; + const refreshToken = token.refresh_token ?? token.refreshToken; + return typeof refreshToken === "string" && refreshToken.length > 0; +} + +/** Label for the preview table: the export's own provider field if present. */ +function accountProviderLabel(account: Record): string { + if (typeof account.provider === "string" && account.provider) return account.provider; + return "antigravity"; +} + +/** + * Wizard: upload an account-manager export JSON (plaintext tokens), review the + * detected accounts, then bulk-import them as OAuth connections through + * POST /api/oauth//import-export — the same persistence path as the + * credential-blob paste flow, minus the blob dance. Stale access tokens are + * refreshed server-side from the export's refresh tokens before finalizing. + */ +export function OAuthExportImportModal({ + isOpen, + onClose, + onImported, +}: OAuthExportImportModalProps) { + const fileInputRef = useRef(null); + const [fileName, setFileName] = useState(""); + const [provider, setProvider] = useState("antigravity"); + const [accounts, setAccounts] = useState[]>([]); + const [preview, setPreview] = useState([]); + const [parseError, setParseError] = useState(""); + const [importing, setImporting] = useState(false); + const [outcome, setOutcome] = useState(null); + + const t = useTranslations("providers"); + + const resetFile = () => { + setFileName(""); + setAccounts([]); + setPreview([]); + setParseError(""); + setOutcome(null); + if (fileInputRef.current) fileInputRef.current.value = ""; + }; + + const handleFile = async (file: File) => { + setFileName(file.name); + setOutcome(null); + setParseError(""); + try { + if (file.size > 5 * 1024 * 1024) throw new Error("file too large"); + const doc = JSON.parse(await file.text()); + const parsed = extractAccounts(doc); + if (parsed.length === 0 || parsed.length > 100) { + setParseError(t("oauthExportParseError")); + setAccounts([]); + setPreview([]); + return; + } + setAccounts(parsed); + setPreview( + parsed.map((account, index) => ({ + index, + provider: accountProviderLabel(account), + email: accountEmail(account), + hasRefreshToken: accountHasRefreshToken(account), + selected: true, + })) + ); + } catch { + setParseError(t("oauthExportParseError")); + setAccounts([]); + setPreview([]); + } + }; + + const toggleRow = (index: number) => { + setPreview((prev) => + prev.map((row) => (row.index === index ? { ...row, selected: !row.selected } : row)) + ); + }; + + const toggleAll = (checked: boolean) => { + setPreview((prev) => prev.map((row) => ({ ...row, selected: checked }))); + }; + + const selectedCount = preview.filter((row) => row.selected).length; + + const handleImport = async () => { + const toImport = accounts.filter((_, idx) => preview[idx]?.selected); + if (toImport.length === 0) return; + setImporting(true); + setOutcome(null); + try { + const res = await fetch(`/api/oauth/${encodeURIComponent(provider)}/import-export`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ accounts: toImport }), + }); + const data = (await res.json().catch(() => null)) as { + success?: boolean; + imported?: number; + failed?: number; + total?: number; + error?: string; + results?: Array<{ ok?: boolean; index?: number; email?: string; error?: string }>; + } | null; + if (!res.ok || !data) { + setOutcome({ + ok: false, + imported: 0, + failed: toImport.length, + total: toImport.length, + errorLines: [data?.error ?? `HTTP ${res.status}`], + }); + return; + } + setOutcome({ + ok: !!data.success, + imported: data.imported ?? 0, + failed: data.failed ?? 0, + total: data.total ?? toImport.length, + errorLines: (data.results ?? []) + .filter((r) => !r.ok) + .slice(0, 8) + .map((r) => `${r.email ?? `#${r.index}`}: ${r.error ?? t("oauthExportUnknownError")}`), + }); + if ((data.imported ?? 0) > 0) { + try { + await onImported(); + } catch { + /* refresh failure must not replace the import result */ + } + } + } catch (err) { + setOutcome({ + ok: false, + imported: 0, + failed: toImport.length, + total: toImport.length, + errorLines: [err instanceof Error ? err.message : String(err)], + }); + } finally { + setImporting(false); + } + }; + + const handleClose = () => { + if (importing) return; + resetFile(); + onClose(); + }; + + const failed = !!outcome && (outcome.failed > 0 || !outcome.ok); + + return ( + +
+

{t("oauthExportDescription")}

+

{t("oauthExportSchemaHint")}

+ +
+ + + { + const file = e.target.files?.[0]; + if (file) void handleFile(file); + }} + /> + + {fileName && {fileName}} +
+ + {parseError && ( +
+ {parseError} +
+ )} + + {preview.length > 0 && ( + <> +
+ {t("oauthExportDetected", { count: preview.length })} +
+
+ + + + + + + + + + + {preview.map((row) => ( + + + + + + + ))} + +
+ toggleAll(e.target.checked)} + /> + {t("oauthExportColEmail")}{t("oauthExportColProvider")}{t("oauthExportColRefresh")}
+ toggleRow(row.index)} + /> + + {row.email || t("oauthExportNoEmail")} + {row.provider} + {row.hasRefreshToken ? t("oauthExportYes") : t("oauthExportNo")} +
+
+ + )} + + {outcome && ( +
+ {t("oauthExportResult", { + imported: outcome.imported, + total: outcome.total, + failed: outcome.failed, + })} + {outcome.errorLines.length > 0 && ( +
    + {outcome.errorLines.map((line, i) => ( +
  • {line}
  • + ))} +
+ )} +
+ )} + +
+ + +
+
+
+ ); +} diff --git a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx index 451db6eee35a..834f39ce5079 100644 --- a/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx +++ b/src/app/(dashboard)/dashboard/providers/components/ProviderSummaryCard.tsx @@ -43,6 +43,7 @@ interface ProviderSummaryCardProps { onDisplayModeChange(mode: ProviderDisplayMode): void; onNewProvider(): void; onImportFromFile(): void; + onImportOAuthExport?(): void; searchQuery: string; setModelSearchQuery(value: string): void; setSearchQuery(value: string): void; @@ -98,6 +99,7 @@ export default function ProviderSummaryCard({ onDisplayModeChange, onNewProvider, onImportFromFile, + onImportOAuthExport, searchQuery, setModelSearchQuery, setSearchQuery, @@ -222,6 +224,11 @@ export default function ProviderSummaryCard({ + {onImportOAuthExport && ( + + )}