codex-proxy / web /src /components /AccountCard.tsx
icebear
feat: detect banned accounts and show status in dashboard (#142)
ba262d0 unverified
raw
history blame
12.1 kB
import { useCallback } from "preact/hooks";
import { useT, useI18n } from "../../../shared/i18n/context";
import type { TranslationKey } from "../../../shared/i18n/translations";
import { formatNumber, formatResetTime, formatWindowDuration } from "../../../shared/utils/format";
import type { Account, ProxyEntry } from "../../../shared/types";
const avatarColors = [
["bg-purple-100 dark:bg-[#2a1a3f]", "text-purple-600 dark:text-purple-400"],
["bg-amber-100 dark:bg-[#3d2c16]", "text-amber-600 dark:text-amber-500"],
["bg-blue-100 dark:bg-[#1a2a3f]", "text-blue-600 dark:text-blue-400"],
["bg-emerald-100 dark:bg-[#112a1f]", "text-emerald-600 dark:text-emerald-400"],
["bg-red-100 dark:bg-[#3f1a1a]", "text-red-600 dark:text-red-400"],
];
const statusStyles: Record<string, [string, string]> = {
active: [
"bg-green-100 text-green-700 border-green-200 dark:bg-[#11281d] dark:text-primary dark:border-[#1a442e]",
"active",
],
expired: [
"bg-red-100 text-red-600 border-red-200 dark:bg-red-900/20 dark:text-red-400 dark:border-red-800/30",
"expired",
],
rate_limited: [
"bg-amber-100 text-amber-700 border-amber-200 dark:bg-amber-900/20 dark:text-amber-400 dark:border-amber-800/30",
"rateLimited",
],
refreshing: [
"bg-blue-100 text-blue-600 border-blue-200 dark:bg-blue-900/20 dark:text-blue-400 dark:border-blue-800/30",
"refreshing",
],
disabled: [
"bg-slate-100 text-slate-500 border-slate-200 dark:bg-slate-800/30 dark:text-slate-400 dark:border-slate-700/30",
"disabled",
],
banned: [
"bg-rose-100 text-rose-700 border-rose-300 dark:bg-rose-900/30 dark:text-rose-400 dark:border-rose-800/40",
"banned",
],
};
interface AccountCardProps {
account: Account;
index: number;
onDelete: (id: string) => Promise<string | null>;
proxies?: ProxyEntry[];
onProxyChange?: (accountId: string, proxyId: string) => void;
selected?: boolean;
onToggleSelect?: (id: string) => void;
}
export function AccountCard({ account, index, onDelete, proxies, onProxyChange, selected, onToggleSelect }: AccountCardProps) {
const t = useT();
const { lang } = useI18n();
const email = account.email || "Unknown";
const initial = email.charAt(0).toUpperCase();
const [bgColor, textColor] = avatarColors[index % avatarColors.length];
const usage = account.usage || {};
const requests = usage.request_count ?? 0;
const tokens = (usage.input_tokens ?? 0) + (usage.output_tokens ?? 0);
const winRequests = usage.window_request_count ?? 0;
const winTokens = (usage.window_input_tokens ?? 0) + (usage.window_output_tokens ?? 0);
const plan = account.planType || t("freeTier");
const windowSec = account.quota?.rate_limit?.limit_window_seconds;
const windowDur = windowSec ? formatWindowDuration(windowSec, lang === "zh") : null;
const [statusCls, statusKey] = statusStyles[account.status] || statusStyles.disabled;
const handleDelete = useCallback(async () => {
if (!confirm(t("removeConfirm"))) return;
const err = await onDelete(account.id);
if (err) alert(err);
}, [account.id, onDelete, t]);
// Quota — primary window
const q = account.quota;
const rl = q?.rate_limit;
const pct = rl?.used_percent != null ? Math.round(rl.used_percent) : null;
const barColor =
pct == null ? "bg-primary" : pct >= 90 ? "bg-red-500" : pct >= 60 ? "bg-amber-500" : "bg-primary";
const pctColor =
pct == null
? "text-primary"
: pct >= 90
? "text-red-500"
: pct >= 60
? "text-amber-600 dark:text-amber-500"
: "text-primary";
const resetAt = rl?.reset_at ? formatResetTime(rl.reset_at, lang === "zh") : null;
// Quota — secondary window (e.g. weekly)
const srl = q?.secondary_rate_limit;
const sPct = srl?.used_percent != null ? Math.round(srl.used_percent) : null;
const sBarColor =
sPct == null ? "bg-indigo-500" : sPct >= 90 ? "bg-red-500" : sPct >= 60 ? "bg-amber-500" : "bg-indigo-500";
const sPctColor =
sPct == null
? "text-indigo-500"
: sPct >= 90
? "text-red-500"
: sPct >= 60
? "text-amber-600 dark:text-amber-500"
: "text-indigo-500";
const sResetAt = srl?.reset_at ? formatResetTime(srl.reset_at, lang === "zh") : null;
const sWindowSec = srl?.limit_window_seconds;
const sWindowDur = sWindowSec ? formatWindowDuration(sWindowSec, lang === "zh") : null;
const handleToggle = useCallback(() => {
onToggleSelect?.(account.id);
}, [account.id, onToggleSelect]);
return (
<div class={`bg-white dark:bg-card-dark border rounded-xl p-4 shadow-sm hover:shadow-md transition-all ${selected ? "border-primary ring-1 ring-primary/30" : "border-gray-200 dark:border-border-dark hover:border-primary/30 dark:hover:border-primary/50"}`}>
{/* Header */}
<div class="flex justify-between items-start mb-4">
<div class="flex items-center gap-3">
{onToggleSelect && (
<input
type="checkbox"
checked={selected}
onChange={handleToggle}
class="size-4 rounded border-gray-300 dark:border-border-dark text-primary focus:ring-primary/50 cursor-pointer shrink-0"
/>
)}
<div class={`size-10 rounded-full ${bgColor} ${textColor} flex items-center justify-center font-bold text-lg`}>
{initial}
</div>
<div>
<h3 class="text-[0.82rem] font-semibold leading-tight">{email}</h3>
<p class="text-xs text-slate-500 dark:text-text-dim">
{plan}
{windowDur && (
<span class="ml-1.5 px-1.5 py-0.5 rounded bg-slate-100 dark:bg-border-dark text-slate-500 dark:text-text-dim text-[0.65rem] font-medium">
{windowDur}
</span>
)}
</p>
</div>
</div>
<div class="flex items-center gap-2">
<span class={`px-2.5 py-1 rounded-full ${statusCls} text-xs font-medium border`}>
{t(statusKey as TranslationKey)}
</span>
<button
onClick={handleDelete}
class="p-1.5 text-slate-400 dark:text-text-dim hover:text-red-500 transition-colors rounded-md hover:bg-red-50 dark:hover:bg-red-900/20"
title={t("deleteAccount")}
>
<svg class="size-[18px]" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
<path stroke-linecap="round" stroke-linejoin="round" d="M14.74 9l-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 01-2.244 2.077H8.084a2.25 2.25 0 01-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 00-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 013.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 00-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 00-7.5 0" />
</svg>
</button>
</div>
</div>
{/* Stats */}
<div class="space-y-2">
<div class="flex justify-between text-[0.78rem]">
<span class="text-slate-500 dark:text-text-dim">{t("windowRequests")}</span>
<span class="font-medium">{formatNumber(winRequests)}</span>
</div>
<div class="flex justify-between text-[0.78rem]">
<span class="text-slate-500 dark:text-text-dim">{t("windowTokens")}</span>
<span class="font-medium">{formatNumber(winTokens)}</span>
</div>
<div class="flex justify-between text-[0.68rem]">
<span class="text-slate-400 dark:text-text-dim/70">{t("totalAll")}</span>
<span class="text-slate-400 dark:text-text-dim/70">{formatNumber(requests)} req · {formatNumber(tokens)} tok</span>
</div>
</div>
{/* Proxy selector */}
{proxies && onProxyChange && (
<div class="flex items-center justify-between text-[0.78rem] mt-2 pt-2 border-t border-slate-100 dark:border-border-dark">
<span class="text-slate-500 dark:text-text-dim">{t("proxyAssignment")}</span>
<select
value={account.proxyId || "global"}
onChange={(e) =>
onProxyChange(account.id, (e.target as HTMLSelectElement).value)
}
class="text-xs px-2 py-1 rounded-md border border-gray-200 dark:border-border-dark bg-white dark:bg-bg-dark text-slate-700 dark:text-text-main focus:outline-none focus:ring-1 focus:ring-primary cursor-pointer"
>
<option value="global">{t("globalDefault")}</option>
<option value="direct">{t("directNoProxy")}</option>
<option value="auto">{t("autoRoundRobin")}</option>
{proxies.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
{p.health?.exitIp ? ` (${p.health.exitIp})` : ""}
</option>
))}
</select>
</div>
)}
{/* Quota bars */}
{(rl || srl) && (
<div class="pt-3 mt-3 border-t border-slate-100 dark:border-border-dark space-y-3">
{/* Primary window */}
{rl && (
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-slate-500 dark:text-text-dim">
{t("rateLimit")}
{windowDur && (
<span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({windowDur})</span>
)}
</span>
{rl.limit_reached ? (
<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
{t("limitReached")}
</span>
) : pct != null ? (
<span class={`font-medium ${pctColor}`}>
{pct}% {t("used")}
</span>
) : (
<span class="font-medium text-primary">{t("ok")}</span>
)}
</div>
{pct != null && (
<div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
<div class={`${barColor} h-2 rounded-full transition-all`} style={{ width: `${pct}%` }} />
</div>
)}
{resetAt && (
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">
{t("resetsAt")} {resetAt}
</p>
)}
</div>
)}
{/* Secondary window (e.g. weekly) */}
{srl && (
<div>
<div class="flex justify-between text-[0.78rem] mb-1.5">
<span class="text-slate-500 dark:text-text-dim">
{t("secondaryRateLimit")}
{sWindowDur && (
<span class="ml-1 text-slate-400 dark:text-text-dim/70 text-[0.65rem]">({sWindowDur})</span>
)}
</span>
{srl.limit_reached ? (
<span class="px-2 py-0.5 rounded-full bg-red-100 dark:bg-red-900/20 text-red-600 dark:text-red-400 text-xs font-medium">
{t("limitReached")}
</span>
) : sPct != null ? (
<span class={`font-medium ${sPctColor}`}>
{sPct}% {t("used")}
</span>
) : (
<span class="font-medium text-indigo-500">{t("ok")}</span>
)}
</div>
{sPct != null && (
<div class="w-full bg-slate-100 dark:bg-border-dark rounded-full h-2 overflow-hidden">
<div class={`${sBarColor} h-2 rounded-full transition-all`} style={{ width: `${sPct}%` }} />
</div>
)}
{sResetAt && (
<p class="text-xs text-slate-400 dark:text-text-dim mt-1">
{t("resetsAt")} {sResetAt}
</p>
)}
</div>
)}
</div>
)}
</div>
);
}