Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

126 changes: 112 additions & 14 deletions shared/hooks/use-accounts.ts
Original file line number Diff line number Diff line change
@@ -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<Account[]>([]);
const [loading, setLoading] = useState(true);
Expand Down Expand Up @@ -181,28 +251,24 @@ 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<AccountImportResult> => {
const text = await file.text();
let parsed: unknown;
try {
parsed = JSON.parse(text);
} 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", {
Expand All @@ -217,6 +283,37 @@ export function useAccounts() {
return result;
}, [loadAccounts]);

const importTokenJsonFile = useCallback(async (file: File): Promise<AccountImportResult> => {
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,
Expand All @@ -232,5 +329,6 @@ export function useAccounts() {
deleteAccount,
exportAccounts,
importAccounts,
importTokenJsonFile,
};
}
6 changes: 4 additions & 2 deletions shared/i18n/translations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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",
Expand Down
1 change: 1 addition & 0 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ function Dashboard() {
<>
<Header
onAddAccount={accounts.startAdd}
onBatchImport={accounts.importTokenJsonFile}
onCheckUpdate={update.checkForUpdate}
onOpenUpdateModal={() => setShowModal(true)}
checking={update.checking}
Expand Down
44 changes: 43 additions & 1 deletion web/src/components/Header.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -30,6 +31,7 @@ function StableText({ tKey, children, class: cls }: { tKey: TranslationKey; chil

interface HeaderProps {
onAddAccount: () => void;
onBatchImport?: (file: File) => Promise<unknown>;
onCheckUpdate: () => void;
onOpenUpdateModal?: () => void;
checking: boolean;
Expand All @@ -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<HTMLInputElement>(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 (
<header class="sticky top-0 z-50 w-full bg-white dark:bg-card-dark border-b border-gray-200 dark:border-border-dark shadow-sm transition-colors">
<input
ref={fileRef}
type="file"
accept=".json,application/json"
onChange={handleFileChange}
class="hidden"
/>
<div class="px-4 md:px-8 lg:px-40 flex h-14 items-center justify-center">
<div class="flex w-full max-w-[960px] items-center justify-between">
{/* Logo & Title */}
Expand Down Expand Up @@ -150,6 +179,19 @@ export function Header({ onAddAccount, onCheckUpdate, onOpenUpdateModal, checkin
</svg>
<StableText tKey="addAccount">{t("addAccount")}</StableText>
</button>
<button
onClick={triggerBatchImport}
disabled={!onBatchImport || importingJson}
class="flex items-center gap-2 px-4 py-2 border border-gray-200 dark:border-border-dark bg-white dark:bg-card-dark text-slate-700 dark:text-text-main text-xs font-semibold rounded-lg transition-colors shadow-sm hover:bg-slate-50 dark:hover:bg-border-dark disabled:opacity-50 disabled:cursor-not-allowed"
title={t("batchAddJsonTokenFile")}
>
<svg class="size-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M19.5 14.25v-2.625a3.375 3.375 0 0 0-3.375-3.375h-1.5A1.125 1.125 0 0 1 13.5 7.125v-1.5A3.375 3.375 0 0 0 10.125 2.25H6.75A2.25 2.25 0 0 0 4.5 4.5v15A2.25 2.25 0 0 0 6.75 21.75h10.5A2.25 2.25 0 0 0 19.5 19.5v-1.5m-6-4.5h3m-1.5-1.5v3" />
</svg>
<StableText tKey="batchAddJsonTokenFile">
{importingJson ? t("importingJsonTokenFile") : t("batchAddJsonTokenFile")}
</StableText>
</button>
</>
)}
</div>
Expand Down