codex-proxy / shared /hooks /use-accounts.ts
icebear
feat: account management page with batch operations (#146)
7516302 unverified
raw
history blame
8.6 kB
import { useState, useEffect, useCallback } from "preact/hooks";
import type { Account } from "../types";
export function useAccounts() {
const [list, setList] = useState<Account[]>([]);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [addVisible, setAddVisible] = useState(false);
const [addInfo, setAddInfo] = useState("");
const [addError, setAddError] = useState("");
const loadAccounts = useCallback(async (fresh = false) => {
setRefreshing(true);
try {
const url = fresh ? "/auth/accounts?quota=fresh" : "/auth/accounts?quota=true";
const resp = await fetch(url);
const data = await resp.json();
setList(data.accounts || []);
setLastUpdated(new Date());
} catch {
setList([]);
} finally {
setLoading(false);
setRefreshing(false);
}
}, []);
useEffect(() => {
loadAccounts();
}, [loadAccounts]);
// Auto-poll cached quota every 30s
useEffect(() => {
const timer = setInterval(() => loadAccounts(), 30_000);
return () => clearInterval(timer);
}, [loadAccounts]);
// Listen for OAuth callback success
useEffect(() => {
const handler = async (event: MessageEvent) => {
if (event.data?.type === "oauth-callback-success") {
setAddVisible(false);
setAddInfo("accountAdded");
await loadAccounts();
}
};
window.addEventListener("message", handler);
return () => window.removeEventListener("message", handler);
}, [loadAccounts]);
const startAdd = useCallback(async () => {
setAddInfo("");
setAddError("");
try {
const resp = await fetch("/auth/login-start", { method: "POST" });
const data = await resp.json();
if (!resp.ok || !data.authUrl) {
throw new Error(data.error || "failedStartLogin");
}
window.open(data.authUrl, "oauth_add", "width=600,height=700,scrollbars=yes");
setAddVisible(true);
// Poll for new account + focus/visibility detection
const prevResp = await fetch("/auth/accounts");
const prevData = await prevResp.json();
const prevCount = prevData.accounts?.length || 0;
let checking = false;
const checkForNewAccount = async () => {
if (checking) return;
checking = true;
try {
const r = await fetch("/auth/accounts");
const d = await r.json();
if ((d.accounts?.length || 0) > prevCount) {
cleanup();
setAddVisible(false);
setAddInfo("accountAdded");
await loadAccounts();
}
} catch {} finally {
checking = false;
}
};
// Focus event — check immediately when window regains focus
const onFocus = () => { checkForNewAccount(); };
window.addEventListener("focus", onFocus);
// Visibility change — check when tab becomes visible
const onVisible = () => {
if (document.visibilityState === "visible") checkForNewAccount();
};
document.addEventListener("visibilitychange", onVisible);
// Interval polling as fallback
const pollTimer = setInterval(checkForNewAccount, 2000);
const cleanup = () => {
clearInterval(pollTimer);
window.removeEventListener("focus", onFocus);
document.removeEventListener("visibilitychange", onVisible);
};
setTimeout(cleanup, 5 * 60 * 1000);
} catch (err) {
setAddError(err instanceof Error ? err.message : "failedStartLogin");
}
}, [loadAccounts]);
const submitRelay = useCallback(
async (callbackUrl: string) => {
setAddInfo("");
setAddError("");
if (!callbackUrl.trim()) {
setAddError("pleasePassCallback");
return;
}
try {
const resp = await fetch("/auth/code-relay", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ callbackUrl }),
});
const data = await resp.json();
if (resp.ok && data.success) {
setAddVisible(false);
setAddInfo("accountAdded");
await loadAccounts();
} else {
setAddError(data.error || "failedExchangeCode");
}
} catch (err) {
setAddError(
"networkError" + (err instanceof Error ? err.message : String(err))
);
}
},
[loadAccounts]
);
const deleteAccount = useCallback(
async (id: string) => {
try {
const resp = await fetch("/auth/accounts/" + encodeURIComponent(id), {
method: "DELETE",
});
if (!resp.ok) {
const data = await resp.json();
return data.error || "failedDeleteAccount";
}
await loadAccounts();
return null;
} catch (err) {
return "networkError" + (err instanceof Error ? err.message : "");
}
},
[loadAccounts]
);
const patchLocal = useCallback((accountId: string, patch: Partial<Account>) => {
setList((prev) => prev.map((a) => a.id === accountId ? { ...a, ...patch } : a));
}, []);
const exportAccounts = useCallback(async (selectedIds?: string[]) => {
const params = selectedIds && selectedIds.length > 0
? `?ids=${selectedIds.join(",")}`
: "";
const resp = await fetch(`/auth/accounts/export${params}`);
const data = await resp.json() as { accounts: Array<{ id: string }> };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
const date = new Date().toISOString().slice(0, 10);
a.download = `accounts-export-${date}.json`;
a.style.display = "none";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}, []);
const importAccounts = useCallback(async (file: File): Promise<{
success: boolean;
added: number;
updated: number;
failed: number;
errors: string[];
}> => {
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;
if (!accounts) {
return { success: false, added: 0, updated: 0, failed: 0, errors: ["Invalid format: expected { accounts: [...] }"] };
}
const resp = await fetch("/auth/accounts/import", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accounts }),
});
const result = await resp.json();
if (resp.ok) {
await loadAccounts();
}
return result;
}, [loadAccounts]);
const batchDelete = useCallback(async (ids: string[]): Promise<string | null> => {
try {
const resp = await fetch("/auth/accounts/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!resp.ok) {
const data = await resp.json();
return data.error || "Batch delete failed";
}
await loadAccounts();
return null;
} catch (err) {
return "networkError" + (err instanceof Error ? err.message : "");
}
}, [loadAccounts]);
const batchSetStatus = useCallback(async (ids: string[], status: "active" | "disabled"): Promise<string | null> => {
try {
const resp = await fetch("/auth/accounts/batch-status", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids, status }),
});
if (!resp.ok) {
const data = await resp.json();
return data.error || "Batch status change failed";
}
await loadAccounts();
return null;
} catch (err) {
return "networkError" + (err instanceof Error ? err.message : "");
}
}, [loadAccounts]);
return {
list,
loading,
refreshing,
lastUpdated,
addVisible,
addInfo,
addError,
refresh: useCallback(() => loadAccounts(true), [loadAccounts]),
patchLocal,
startAdd,
submitRelay,
deleteAccount,
exportAccounts,
importAccounts,
batchDelete,
batchSetStatus,
};
}