diff --git a/package-lock.json b/package-lock.json index c85b894..f6247d0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-proxy", - "version": "1.0.59", + "version": "1.0.64", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-proxy", - "version": "1.0.59", + "version": "1.0.64", "hasInstallScript": true, "workspaces": [ "packages/*" @@ -7364,7 +7364,7 @@ }, "packages/electron": { "name": "@codex-proxy/electron", - "version": "1.0.58", + "version": "1.0.64", "dependencies": { "electron-updater": "^6.3.0" }, diff --git a/shared/hooks/use-accounts.ts b/shared/hooks/use-accounts.ts index 95e33c4..ea1365e 100644 --- a/shared/hooks/use-accounts.ts +++ b/shared/hooks/use-accounts.ts @@ -1,6 +1,76 @@ import { useState, useEffect, useCallback } from "preact/hooks"; import type { Account } from "../types"; +interface AccountImportResult { + success: boolean; + added: number; + updated: number; + failed: number; + errors: string[]; +} + +interface ImportableAccountPayload { + token: string; + refreshToken?: string | null; +} + +interface JsonTokenFileEntry { + token?: unknown; + refreshToken?: unknown; + access_token?: unknown; + refresh_token?: unknown; +} + +function normalizeImportEntry(entry: unknown): ImportableAccountPayload | null { + if (!entry || typeof entry !== "object") return null; + + const candidate = entry as JsonTokenFileEntry; + const token = + typeof candidate.token === "string" + ? candidate.token.trim() + : typeof candidate.access_token === "string" + ? candidate.access_token.trim() + : ""; + + if (!token) return null; + + const refreshToken = + typeof candidate.refreshToken === "string" + ? candidate.refreshToken.trim() + : typeof candidate.refresh_token === "string" + ? candidate.refresh_token.trim() + : null; + + return { + token, + refreshToken: refreshToken || null, + }; +} + +function parseImportAccounts(parsed: unknown): ImportableAccountPayload[] | null { + if (Array.isArray(parsed)) { + const accounts = parsed + .map(normalizeImportEntry) + .filter((entry): entry is ImportableAccountPayload => !!entry); + return accounts.length > 0 ? accounts : null; + } + + if (parsed && typeof parsed === "object") { + const maybeAccounts = (parsed as { accounts?: unknown }).accounts; + if (Array.isArray(maybeAccounts)) { + const accounts = maybeAccounts + .map(normalizeImportEntry) + .filter((entry): entry is ImportableAccountPayload => !!entry); + return accounts.length > 0 ? accounts : null; + } + + const single = normalizeImportEntry(parsed); + return single ? [single] : null; + } + + return null; +} + export function useAccounts() { const [list, setList] = useState([]); const [loading, setLoading] = useState(true); @@ -181,13 +251,7 @@ export function useAccounts() { URL.revokeObjectURL(url); }, []); - const importAccounts = useCallback(async (file: File): Promise<{ - success: boolean; - added: number; - updated: number; - failed: number; - errors: string[]; - }> => { + const importAccounts = useCallback(async (file: File): Promise => { const text = await file.text(); let parsed: unknown; try { @@ -195,14 +259,16 @@ export function useAccounts() { } catch { return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid JSON file"] }; } - // Support both { accounts: [...] } (export format) and raw array - const accounts = Array.isArray(parsed) - ? parsed - : Array.isArray(parsed.accounts) - ? parsed.accounts - : null; + + const accounts = parseImportAccounts(parsed); if (!accounts) { - return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] }; + return { + success: false, + added: 0, + updated: 0, + failed: 0, + errors: ["Invalid format: expected a token JSON object, an array, or { accounts: [...] }"], + }; } const resp = await fetch("/auth/accounts/import", { @@ -217,6 +283,37 @@ export function useAccounts() { return result; }, [loadAccounts]); + const importTokenJsonFile = useCallback(async (file: File): Promise => { + setAddInfo(""); + setAddError(""); + + try { + const result = await importAccounts(file); + if (!result.success) { + setAddError(result.errors[0] || "Import failed"); + return result; + } + + setAddInfo( + `Imported: ${result.added} added, ${result.updated} updated, ${result.failed} failed`, + ); + if (result.failed > 0 && result.errors.length > 0) { + setAddError(result.errors[0]); + } + return result; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + setAddError("Import failed: " + message); + return { + success: false, + added: 0, + updated: 0, + failed: 0, + errors: [message], + }; + } + }, [importAccounts]); + return { list, loading, @@ -232,5 +329,6 @@ export function useAccounts() { deleteAccount, exportAccounts, importAccounts, + importTokenJsonFile, }; } diff --git a/shared/i18n/translations.ts b/shared/i18n/translations.ts index 0ccf802..345398a 100644 --- a/shared/i18n/translations.ts +++ b/shared/i18n/translations.ts @@ -2,6 +2,8 @@ export const translations = { en: { serverOnline: "Server Online", addAccount: "Add Account", + batchAddJsonTokenFile: "Batch Add via JSON Token File", + importingJsonTokenFile: "Importing JSON...", toggleTheme: "Toggle theme", connectedAccounts: "Connected Accounts", connectedAccountsDesc: @@ -140,7 +142,6 @@ export const translations = { nextPage: "Next", totalItems: "total", shiftSelectHint: "Shift+click to select range", - selectAll: "Select all", exportSuccess: "Export complete", importFile: "Select JSON file", downloadJson: "Download JSON", @@ -205,6 +206,8 @@ export const translations = { zh: { serverOnline: "\u670d\u52a1\u8fd0\u884c\u4e2d", addAccount: "\u6dfb\u52a0\u8d26\u6237", + batchAddJsonTokenFile: "JSON Token \u6279\u91cf\u5bfc\u5165", + importingJsonTokenFile: "JSON \u5bfc\u5165\u4e2d...", toggleTheme: "\u5207\u6362\u4e3b\u9898", connectedAccounts: "\u5df2\u8fde\u63a5\u8d26\u6237", connectedAccountsDesc: @@ -346,7 +349,6 @@ export const translations = { nextPage: "\u4e0b\u4e00\u9875", totalItems: "\u5171", shiftSelectHint: "Shift+\u70b9\u51fb\u8fde\u7eed\u591a\u9009", - selectAll: "\u5168\u9009", exportSuccess: "\u5bfc\u51fa\u6210\u529f", importFile: "\u9009\u62e9 JSON \u6587\u4ef6", downloadJson: "\u4e0b\u8f7d JSON", diff --git a/web/src/App.tsx b/web/src/App.tsx index a824716..cda4174 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -94,6 +94,7 @@ function Dashboard() { <>
setShowModal(true)} checking={update.checking} diff --git a/web/src/components/Header.tsx b/web/src/components/Header.tsx index 6af2d80..00a5194 100644 --- a/web/src/components/Header.tsx +++ b/web/src/components/Header.tsx @@ -1,3 +1,4 @@ +import { useCallback, useRef, useState } from "preact/hooks"; import { useI18n } from "../../../shared/i18n/context"; import { translations, type TranslationKey } from "../../../shared/i18n/translations"; import { useTheme } from "../../../shared/theme/context"; @@ -30,6 +31,7 @@ function StableText({ tKey, children, class: cls }: { tKey: TranslationKey; chil interface HeaderProps { onAddAccount: () => void; + onBatchImport?: (file: File) => Promise; onCheckUpdate: () => void; onOpenUpdateModal?: () => void; checking: boolean; @@ -41,12 +43,39 @@ interface HeaderProps { hasUpdate?: boolean; } -export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) { +export function Header({ onAddAccount, onBatchImport, onCheckUpdate, onOpenUpdateModal, checking, updateStatusMsg, updateStatusColor, version, commit, isProxySettings, hasUpdate }: HeaderProps) { const { lang, toggleLang, t } = useI18n(); const { isDark, toggle: toggleTheme } = useTheme(); + const fileRef = useRef(null); + const [importingJson, setImportingJson] = useState(false); + + const triggerBatchImport = useCallback(() => { + if (!onBatchImport || importingJson) return; + fileRef.current?.click(); + }, [importingJson, onBatchImport]); + + const handleFileChange = useCallback(async () => { + const file = fileRef.current?.files?.[0]; + if (!file || !onBatchImport) return; + + setImportingJson(true); + try { + await onBatchImport(file); + } finally { + setImportingJson(false); + if (fileRef.current) fileRef.current.value = ""; + } + }, [onBatchImport]); return (
+
{/* Logo & Title */} @@ -150,6 +179,19 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin {t("addAccount")} + )}