| import { useState } from "react"; |
| import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; |
| import { |
| Shield, Users, Image as ImageIcon, Key, Trash2, Crown, CrownIcon, |
| Settings, RefreshCw, Eye, EyeOff, ChevronDown, ChevronUp, AlertCircle, |
| CheckCircle, Lock, Zap, Bookmark, Clock, Terminal, Copy, Check, |
| Database, Plus, Pencil, X, ToggleLeft, ToggleRight, UserPlus, Activity, |
| } from "lucide-react"; |
| import { Button } from "@/components/ui/button"; |
| import { Input } from "@/components/ui/input"; |
| import { Badge } from "@/components/ui/badge"; |
| import { useToast } from "@/hooks/use-toast"; |
| import { useLang } from "@/contexts/LanguageContext"; |
| import { useAuth } from "@/contexts/AuthContext"; |
| import { useLocation } from "wouter"; |
|
|
| const BASE = import.meta.env.BASE_URL.replace(/\/$/, ""); |
| const API_BASE = `${BASE}/api/admin`; |
|
|
| interface Stats { users: number; images: number; apiKeys: number } |
| interface AdminUser { |
| id: number; email: string; displayName: string | null; |
| isAdmin: boolean; createdAt: string; |
| } |
| interface ConfigRow { id: number; key: string; value: string; updatedAt: string } |
|
|
| async function fetchStats(): Promise<Stats> { |
| const r = await fetch(`${API_BASE}/stats`, { credentials: "include" }); |
| if (!r.ok) throw new Error("Forbidden"); |
| return r.json(); |
| } |
| async function fetchUsers(): Promise<AdminUser[]> { |
| const r = await fetch(`${API_BASE}/users`, { credentials: "include" }); |
| if (!r.ok) throw new Error("Forbidden"); |
| return (await r.json()).users; |
| } |
| async function fetchConfig(): Promise<ConfigRow[]> { |
| const r = await fetch(`${API_BASE}/config`, { credentials: "include" }); |
| if (!r.ok) throw new Error("Forbidden"); |
| return (await r.json()).config; |
| } |
|
|
| function StatCard({ icon: Icon, label, value, color }: { icon: any; label: string; value: number; color: string }) { |
| return ( |
| <div className="flex items-center gap-4 p-5 rounded-xl border border-border/60 bg-card/50"> |
| <div className={`w-12 h-12 rounded-xl flex items-center justify-center ${color}`}> |
| <Icon className="w-6 h-6" /> |
| </div> |
| <div> |
| <div className="text-2xl font-bold text-foreground">{value.toLocaleString()}</div> |
| <div className="text-sm text-muted-foreground">{label}</div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function UserRow({ user, currentUserId, onUpdate, onDelete }: { |
| user: AdminUser; |
| currentUserId: number; |
| onUpdate: (id: number, data: Record<string, unknown>) => Promise<void>; |
| onDelete: (id: number) => Promise<void>; |
| }) { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const [expanded, setExpanded] = useState(false); |
| const [newPass, setNewPass] = useState(""); |
| const [showPass, setShowPass] = useState(false); |
| const [busy, setBusy] = useState(false); |
| const isSelf = user.id === currentUserId; |
|
|
| const handleToggleAdmin = async () => { |
| if (isSelf) return; |
| setBusy(true); |
| try { |
| await onUpdate(user.id, { isAdmin: !user.isAdmin }); |
| toast({ description: t.adminUserUpdated }); |
| } catch { toast({ variant: "destructive", description: t.adminError }); } |
| setBusy(false); |
| }; |
|
|
| const handleResetPass = async () => { |
| if (!newPass || newPass.length < 6) { |
| toast({ variant: "destructive", description: t.adminPassTooShort }); |
| return; |
| } |
| setBusy(true); |
| try { |
| await onUpdate(user.id, { password: newPass }); |
| setNewPass(""); |
| toast({ description: t.adminPassReset }); |
| } catch { toast({ variant: "destructive", description: t.adminError }); } |
| setBusy(false); |
| }; |
|
|
| const handleDelete = async () => { |
| if (!confirm(t.adminDeleteConfirm)) return; |
| setBusy(true); |
| try { |
| await onDelete(user.id); |
| toast({ description: t.adminUserDeleted }); |
| } catch { toast({ variant: "destructive", description: t.adminError }); } |
| setBusy(false); |
| }; |
|
|
| return ( |
| <div className="border border-border/50 rounded-xl overflow-hidden"> |
| <div className="flex items-center gap-3 p-3 hover:bg-card/60 transition-colors"> |
| <div className="w-8 h-8 rounded-full bg-primary/15 border border-primary/20 flex items-center justify-center flex-shrink-0 text-xs font-bold text-primary"> |
| {(user.displayName || user.email)[0].toUpperCase()} |
| </div> |
| <div className="flex-1 min-w-0"> |
| <div className="flex items-center gap-2"> |
| <span className="text-sm font-medium text-foreground truncate">{user.displayName || user.email.split("@")[0]}</span> |
| {user.isAdmin && <Crown className="w-3.5 h-3.5 text-yellow-400 flex-shrink-0" />} |
| {isSelf && <Badge variant="outline" className="text-[10px] px-1 py-0">{t.adminYou}</Badge>} |
| </div> |
| <div className="text-xs text-muted-foreground truncate">{user.email}</div> |
| </div> |
| <div className="text-xs text-muted-foreground hidden sm:block"> |
| {new Date(user.createdAt).toLocaleDateString()} |
| </div> |
| <button |
| onClick={() => setExpanded(!expanded)} |
| className="p-1.5 rounded-md text-muted-foreground hover:text-foreground hover:bg-secondary transition-colors" |
| > |
| {expanded ? <ChevronUp className="w-4 h-4" /> : <ChevronDown className="w-4 h-4" />} |
| </button> |
| </div> |
| {expanded && ( |
| <div className="border-t border-border/40 p-4 bg-black/10 space-y-3"> |
| <div className="flex flex-wrap gap-2"> |
| {!isSelf && ( |
| <Button |
| size="sm" |
| variant="outline" |
| disabled={busy} |
| onClick={handleToggleAdmin} |
| className={user.isAdmin ? "border-yellow-500/40 text-yellow-400 hover:bg-yellow-500/10" : ""} |
| > |
| <CrownIcon className="w-3.5 h-3.5 mr-1.5" /> |
| {user.isAdmin ? t.adminRevokeAdmin : t.adminGrantAdmin} |
| </Button> |
| )} |
| {!isSelf && ( |
| <Button |
| size="sm" |
| variant="outline" |
| disabled={busy} |
| onClick={handleDelete} |
| className="border-destructive/30 text-destructive hover:bg-destructive/10" |
| > |
| <Trash2 className="w-3.5 h-3.5 mr-1.5" /> |
| {t.adminDeleteUser} |
| </Button> |
| )} |
| </div> |
| <div className="flex gap-2 items-end"> |
| <div className="flex-1 space-y-1"> |
| <label className="text-xs text-muted-foreground">{t.adminResetPass}</label> |
| <div className="relative"> |
| <Input |
| type={showPass ? "text" : "password"} |
| value={newPass} |
| onChange={(e) => setNewPass(e.target.value)} |
| placeholder={t.adminNewPassPlaceholder} |
| className="bg-background/50 border-border/60 pr-9 text-sm" |
| /> |
| <button |
| type="button" |
| onClick={() => setShowPass(!showPass)} |
| className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" |
| > |
| {showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />} |
| </button> |
| </div> |
| </div> |
| <Button size="sm" disabled={busy || !newPass} onClick={handleResetPass}> |
| {t.adminResetPassBtn} |
| </Button> |
| </div> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
| const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, ""); |
|
|
| function TokenGuide() { |
| const { t } = useLang(); |
| const [open, setOpen] = useState(false); |
|
|
| return ( |
| <div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-4 space-y-3"> |
| <div className="flex items-start justify-between gap-3"> |
| <div> |
| <h3 className="text-sm font-semibold text-blue-300 flex items-center gap-2"> |
| <span>📖</span> {t.adminGuideTitle} |
| </h3> |
| <p className="text-xs text-muted-foreground mt-0.5">{t.adminGuideSubtitle}</p> |
| </div> |
| <button |
| onClick={() => setOpen(!open)} |
| className="text-xs px-3 py-1.5 rounded-lg border border-blue-500/40 text-blue-400 hover:bg-blue-500/10 transition-colors flex-shrink-0" |
| > |
| {open ? t.adminGuideHide : t.adminGuideShow} |
| </button> |
| </div> |
| |
| {open && ( |
| <div className="space-y-4"> |
| <ol className="space-y-2 text-sm text-muted-foreground"> |
| {(t.adminGuideSteps as string[]).map((step, i) => ( |
| <li key={i} className="flex gap-3"> |
| <span className="flex-shrink-0 w-5 h-5 rounded-full bg-blue-500/20 border border-blue-500/40 text-blue-300 text-xs flex items-center justify-center font-bold"> |
| {i + 1} |
| </span> |
| <span dangerouslySetInnerHTML={{ __html: step }} /> |
| </li> |
| ))} |
| </ol> |
| <div className="rounded-lg overflow-hidden border border-border/40"> |
| <img |
| src={`${BASE_URL}/token-guide.png`} |
| alt="Token guide screenshot" |
| className="w-full h-auto" |
| /> |
| </div> |
| <p className="text-xs text-muted-foreground/70 italic">{t.adminGuideNote}</p> |
| </div> |
| )} |
| </div> |
| ); |
| } |
|
|
|
|
| type AccountRow = { |
| id: number; |
| label: string; |
| tokenPreview: string | null; |
| hasRefreshToken: boolean; |
| isActive: boolean; |
| lastUsedAt: string | null; |
| createdAt: string; |
| }; |
|
|
| type AddMode = "console" | "bookmark" | "manual"; |
|
|
| function AccountPoolCard() { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, ""); |
|
|
| |
| const [editingId, setEditingId] = useState<number | null>(null); |
| const [editLabel, setEditLabel] = useState(""); |
|
|
| |
| const [addMode, setAddMode] = useState<AddMode | null>(null); |
|
|
| |
| const [newLabel, setNewLabel] = useState(""); |
| const [newBearer, setNewBearer] = useState(""); |
| const [newRefresh, setNewRefresh] = useState(""); |
| const [adding, setAdding] = useState(false); |
|
|
| |
| const [syncLabel, setSyncLabel] = useState(""); |
| const [otp, setOtp] = useState<string | null>(null); |
| const [expiresAt, setExpiresAt] = useState<number | null>(null); |
| const [generating, setGenerating] = useState(false); |
| const [copied, setCopied] = useState(false); |
| const [bookmarkMode, setBookmarkMode] = useState<"console" | "bookmark">("console"); |
|
|
| const receiveUrl = `${window.location.origin}${BASE_URL}/api/public/receive-tokens`; |
|
|
| const { data: accounts = [], refetch } = useQuery<AccountRow[]>({ |
| queryKey: ["gemini-accounts"], |
| queryFn: async () => { |
| const r = await fetch(`${BASE_URL}/api/admin/accounts`, { credentials: "include" }); |
| if (!r.ok) return []; |
| return r.json(); |
| }, |
| }); |
|
|
| |
| const buildCode = (otpVal: string, label: string) => { |
| const safeLabel = (label || t.poolUnnamed).replace(/'/g, "\\'"); |
| return `(function(){var t=null;for(var k in localStorage){try{var v=JSON.parse(localStorage.getItem(k)||'');if(v&&v.access_token&&v.refresh_token){t=v;break;}}catch(e){}}if(!t){var at=localStorage.getItem('access_token'),rt=localStorage.getItem('refresh_token');if(at&&rt)t={access_token:at,refresh_token:rt};}if(!t){alert('找不到 Token,請先登入 geminigen.ai');return;}fetch('${receiveUrl}',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({otp:'${otpVal}',access_token:t.access_token,refresh_token:t.refresh_token,label:'${safeLabel}'})}).then(function(r){return r.json()}).then(function(d){if(d.success)alert('✅ Token 已成功同步到星光工坊!');else alert('❌ 同步失敗:'+(d.error||'未知錯誤'));}).catch(function(){alert('❌ 無法連接星光工坊');});})();`; |
| }; |
| const buildBookmarklet = (otpVal: string, label: string) => |
| `javascript:${encodeURIComponent(buildCode(otpVal, label))}`; |
|
|
| const handleGenerateOtp = async () => { |
| setGenerating(true); |
| setCopied(false); |
| try { |
| const r = await fetch(`${BASE_URL}/api/admin/bookmarklet-otp`, { method: "POST", credentials: "include" }); |
| const data = await r.json(); |
| if (!r.ok) throw new Error(data.error || "Failed"); |
| setOtp(data.otp); |
| setExpiresAt(Date.now() + data.expiresInSeconds * 1000); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.poolGenFailed }); |
| } |
| setGenerating(false); |
| }; |
|
|
| const handleCopyCode = async () => { |
| if (!otp) return; |
| try { |
| await navigator.clipboard.writeText(buildCode(otp, syncLabel)); |
| setCopied(true); |
| toast({ description: t.poolCopySuccess }); |
| setTimeout(() => setCopied(false), 3000); |
| } catch { |
| toast({ variant: "destructive", description: t.poolCopyFailed }); |
| } |
| }; |
|
|
| |
| const handleAdd = async () => { |
| if (!newBearer.trim()) return; |
| setAdding(true); |
| try { |
| const r = await fetch(`${BASE_URL}/api/admin/accounts`, { |
| method: "POST", credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ label: newLabel || t.poolUnnamed, bearerToken: newBearer.trim(), refreshToken: newRefresh.trim() || undefined }), |
| }); |
| if (!r.ok) throw new Error((await r.json()).error || "Failed"); |
| toast({ description: t.poolAddSuccess }); |
| setNewLabel(""); setNewBearer(""); setNewRefresh(""); |
| setAddMode(null); |
| refetch(); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message }); |
| } |
| setAdding(false); |
| }; |
|
|
| const handleToggle = async (id: number) => { |
| await fetch(`${BASE_URL}/api/admin/accounts/${id}/toggle`, { method: "PATCH", credentials: "include" }); |
| refetch(); |
| }; |
|
|
| const handleDelete = async (id: number) => { |
| await fetch(`${BASE_URL}/api/admin/accounts/${id}`, { method: "DELETE", credentials: "include" }); |
| toast({ description: t.poolDeleteSuccess }); |
| refetch(); |
| }; |
|
|
| const handleRename = async (id: number) => { |
| if (!editLabel.trim()) return; |
| await fetch(`${BASE_URL}/api/admin/accounts/${id}/label`, { |
| method: "PATCH", credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ label: editLabel }), |
| }); |
| setEditingId(null); |
| refetch(); |
| }; |
|
|
| const activeCount = accounts.filter((a) => a.isActive).length; |
| const isExpired = expiresAt !== null && Date.now() > expiresAt; |
| const minutesLeft = expiresAt ? Math.max(0, Math.floor((expiresAt - Date.now()) / 60000)) : 0; |
|
|
| return ( |
| <div className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-4 space-y-4"> |
| {/* ── Header ── */} |
| <div className="flex items-start justify-between gap-3"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 bg-blue-500/20 border border-blue-500/30"> |
| <Database className="w-4 h-4 text-blue-400" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-blue-300 flex items-center gap-2"> |
| {t.poolTitle} |
| <span className="text-[10px] font-normal px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-400 border border-blue-500/30"> |
| {activeCount}/{accounts.length} |
| </span> |
| </h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {t.poolDesc} |
| </p> |
| </div> |
| </div> |
| <Button size="sm" variant="ghost" className="h-7 px-2 text-xs" onClick={() => refetch()}> |
| <RefreshCw className="w-3 h-3" /> |
| </Button> |
| </div> |
| |
| {/* ── Account list ── */} |
| {accounts.length === 0 ? ( |
| <div className="py-4 flex flex-col items-center gap-1.5 text-muted-foreground"> |
| <UserPlus className="w-7 h-7 opacity-30" /> |
| <p className="text-sm">{t.poolEmpty}</p> |
| <p className="text-xs opacity-50">{t.poolEmptyDesc}</p> |
| </div> |
| ) : ( |
| <div className="space-y-2"> |
| {accounts.map((acc) => ( |
| <div key={acc.id} className={`rounded-lg border p-3 transition-colors ${acc.isActive ? "border-blue-500/30 bg-blue-500/5" : "border-border/30 bg-black/10 opacity-60"}`}> |
| <div className="flex items-center gap-3"> |
| <div className={`w-2 h-2 rounded-full flex-shrink-0 ${acc.isActive ? "bg-green-400 shadow-[0_0_6px_rgb(74,222,128)]" : "bg-muted-foreground/30"}`} /> |
| <div className="flex-1 min-w-0"> |
| {editingId === acc.id ? ( |
| <div className="flex gap-1.5"> |
| <input value={editLabel} onChange={(e) => setEditLabel(e.target.value)} |
| onKeyDown={(e) => { if (e.key === "Enter") handleRename(acc.id); if (e.key === "Escape") setEditingId(null); }} |
| className="flex-1 px-2 py-0.5 rounded border border-blue-500/40 bg-black/30 text-sm text-foreground focus:outline-none" autoFocus /> |
| <button onClick={() => handleRename(acc.id)} className="text-green-400 hover:text-green-300"><Check className="w-3.5 h-3.5" /></button> |
| <button onClick={() => setEditingId(null)} className="text-muted-foreground hover:text-foreground"><X className="w-3.5 h-3.5" /></button> |
| </div> |
| ) : ( |
| <div className="flex items-center gap-1.5"> |
| <span className="text-sm font-medium truncate">{acc.label || t.poolUnnamed}</span> |
| <button onClick={() => { setEditingId(acc.id); setEditLabel(acc.label); }} className="text-muted-foreground/40 hover:text-blue-400 transition-colors"> |
| <Pencil className="w-3 h-3" /> |
| </button> |
| </div> |
| )} |
| <div className="flex items-center gap-2 mt-0.5"> |
| <span className="text-[10px] font-mono text-muted-foreground/60">{acc.tokenPreview || "—"}</span> |
| {acc.hasRefreshToken && <span className="text-[9px] px-1 rounded bg-green-500/15 text-green-400 border border-green-500/20">{t.poolHasRefresh}</span>} |
| </div> |
| </div> |
| <div className="text-right hidden sm:block"> |
| {acc.lastUsedAt ? ( |
| <div className="flex items-center gap-1 text-[10px] text-muted-foreground"> |
| <Activity className="w-2.5 h-2.5" /> |
| {new Date(acc.lastUsedAt).toLocaleString("zh-TW", { month: "numeric", day: "numeric", hour: "2-digit", minute: "2-digit" })} |
| </div> |
| ) : <span className="text-[10px] text-muted-foreground/40">{t.poolNeverUsed}</span>} |
| </div> |
| <div className="flex items-center gap-1"> |
| <button onClick={() => handleToggle(acc.id)} title={acc.isActive ? t.poolDisable : t.poolEnable} className="p-1 rounded hover:bg-white/10 transition-colors"> |
| {acc.isActive ? <ToggleRight className="w-5 h-5 text-green-400" /> : <ToggleLeft className="w-5 h-5 text-muted-foreground" />} |
| </button> |
| <button onClick={() => handleDelete(acc.id)} title={t.poolDelete} className="p-1 rounded text-muted-foreground/50 hover:text-red-400 transition-colors"> |
| <Trash2 className="w-3.5 h-3.5" /> |
| </button> |
| </div> |
| </div> |
| </div> |
| ))} |
| <p className="text-[10px] text-muted-foreground/40 text-center pt-1"> |
| {t.poolLRUNote} |
| </p> |
| </div> |
| )} |
| |
| {/* ── Add account section ── */} |
| <div className="border-t border-blue-500/20 pt-3 space-y-3"> |
| {/* Mode picker */} |
| <p className="text-[11px] font-semibold text-blue-300/70 uppercase tracking-wide">{t.poolAddTitle}</p> |
| <div className="grid grid-cols-3 gap-2"> |
| {/* Console sync */} |
| <button |
| onClick={() => { if (addMode === "console") { setAddMode(null); } else { setAddMode("console"); setBookmarkMode("console"); } }} |
| className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs transition-colors ${addMode === "console" ? "border-amber-500/50 bg-amber-500/15 text-amber-300" : "border-border/40 bg-black/20 text-muted-foreground hover:border-amber-500/30 hover:text-amber-400"}`} |
| > |
| <Terminal className="w-4 h-4" /> |
| <span>{t.poolBtnConsole}</span> |
| <span className="text-[9px] opacity-60">{t.poolBtnConsoleDesc}</span> |
| </button> |
| {/* Bookmarklet */} |
| <button |
| onClick={() => { if (addMode === "bookmark") { setAddMode(null); } else { setAddMode("bookmark"); setBookmarkMode("bookmark"); } }} |
| className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs transition-colors ${addMode === "bookmark" ? "border-amber-500/50 bg-amber-500/15 text-amber-300" : "border-border/40 bg-black/20 text-muted-foreground hover:border-amber-500/30 hover:text-amber-400"}`} |
| > |
| <Bookmark className="w-4 h-4" /> |
| <span>{t.poolBtnBookmark}</span> |
| <span className="text-[9px] opacity-60">{t.poolBtnBookmarkDesc}</span> |
| </button> |
| {/* Manual */} |
| <button |
| onClick={() => setAddMode(addMode === "manual" ? null : "manual")} |
| className={`flex flex-col items-center gap-1.5 rounded-lg border p-2.5 text-xs transition-colors ${addMode === "manual" ? "border-blue-500/50 bg-blue-500/15 text-blue-300" : "border-border/40 bg-black/20 text-muted-foreground hover:border-blue-500/30 hover:text-blue-400"}`} |
| > |
| <Plus className="w-4 h-4" /> |
| <span>{t.poolBtnManual}</span> |
| <span className="text-[9px] opacity-60">{t.poolBtnManualDesc}</span> |
| </button> |
| </div> |
| |
| {/* ── Console sync panel ── */} |
| {(addMode === "console" || addMode === "bookmark") && ( |
| <div className="rounded-lg border border-amber-500/30 bg-amber-500/5 p-3 space-y-3"> |
| {/* Sub-mode toggle (console vs bookmark) */} |
| <div className="flex rounded-lg overflow-hidden border border-amber-500/30 text-[11px]"> |
| <button |
| onClick={() => setBookmarkMode("console")} |
| className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 transition-colors ${bookmarkMode === "console" ? "bg-amber-500/20 text-amber-300 font-medium" : "text-muted-foreground hover:text-foreground"}`} |
| > |
| <Terminal className="w-3 h-3" /> {t.poolModeConsole} |
| </button> |
| <button |
| onClick={() => setBookmarkMode("bookmark")} |
| className={`flex-1 flex items-center justify-center gap-1.5 py-1.5 transition-colors ${bookmarkMode === "bookmark" ? "bg-amber-500/20 text-amber-300 font-medium" : "text-muted-foreground hover:text-foreground"}`} |
| > |
| <Bookmark className="w-3 h-3" /> {t.poolModeBookmark} |
| </button> |
| </div> |
| |
| {/* Account label */} |
| <div className="space-y-1"> |
| <label className="text-[11px] text-amber-300/80 font-medium">{t.poolAccountLabel}</label> |
| <input |
| value={syncLabel} |
| onChange={(e) => setSyncLabel(e.target.value)} |
| placeholder={t.poolAccountPlaceholder} |
| className="w-full px-3 py-1.5 rounded-lg border border-amber-500/30 bg-black/20 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:border-amber-500/60" |
| /> |
| </div> |
| |
| {/* Generate OTP / show code */} |
| {(!otp || isExpired) ? ( |
| <Button size="sm" className="w-full bg-amber-600 hover:bg-amber-500 text-white" disabled={generating} onClick={handleGenerateOtp}> |
| {generating |
| ? <><RefreshCw className="w-3.5 h-3.5 animate-spin mr-2" />{t.poolGenerating}</> |
| : <><Terminal className="w-3.5 h-3.5 mr-2" />{t.poolGenerate}</>} |
| </Button> |
| ) : ( |
| <div className="space-y-3"> |
| {/* Expiry */} |
| <div className="flex items-center justify-between"> |
| <div className="flex items-center gap-1.5 text-[11px] text-amber-400/80"> |
| <Clock className="w-3 h-3" /> |
| {t.poolExpiryPrefix} {minutesLeft} {t.poolExpirySuffix} |
| </div> |
| <button onClick={handleGenerateOtp} className="text-[11px] text-muted-foreground hover:text-amber-400 flex items-center gap-1 transition-colors"> |
| <RefreshCw className="w-3 h-3" />{t.poolRegenerate} |
| </button> |
| </div> |
| |
| {bookmarkMode === "console" ? ( |
| <> |
| {/* Steps */} |
| <ol className="text-[11px] text-muted-foreground space-y-1.5"> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">1</span>{t.poolConsoleStep1.replace("geminigen.ai", "")}<a href="https://geminigen.ai" target="_blank" rel="noreferrer" className="text-amber-400 underline mx-1">geminigen.ai</a></li> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">2</span>{t.poolConsoleStep2}</li> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">3</span>{t.poolConsoleStep3}</li> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">4</span>{t.poolConsoleStep4}</li> |
| </ol> |
| {/* Code block */} |
| <div className="rounded-lg border border-border/40 bg-black/50 overflow-hidden"> |
| <div className="flex items-center justify-between px-3 py-1.5 border-b border-border/30 bg-black/30"> |
| <span className="text-[10px] text-muted-foreground font-mono">JavaScript Console</span> |
| <button |
| onClick={handleCopyCode} |
| className={`flex items-center gap-1.5 text-[10px] px-2 py-1 rounded transition-all ${copied ? "text-green-400 bg-green-500/10" : "text-amber-400 hover:bg-amber-500/10"}`} |
| > |
| {copied ? <><Check className="w-3 h-3" />{t.poolCopied}</> : <><Copy className="w-3 h-3" />{t.poolCopyCode}</>} |
| </button> |
| </div> |
| <pre className="p-3 text-[10px] font-mono text-emerald-300/80 whitespace-pre-wrap break-all leading-relaxed max-h-24 overflow-y-auto"> |
| {buildCode(otp!, syncLabel)} |
| </pre> |
| </div> |
| <Button size="sm" className="w-full bg-amber-600 hover:bg-amber-500 text-white" onClick={handleCopyCode}> |
| {copied ? <><Check className="w-3.5 h-3.5 mr-2" />{t.poolCopiedBtn}</> : <><Copy className="w-3.5 h-3.5 mr-2" />{t.poolCopyBtn}</>} |
| </Button> |
| </> |
| ) : ( |
| <> |
| <ol className="text-[11px] text-muted-foreground space-y-1.5"> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">1</span>{t.poolBookmarkStep1}</li> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">2</span>{t.poolBookmarkStep2.replace("geminigen.ai", "")}<a href="https://geminigen.ai" target="_blank" rel="noreferrer" className="text-amber-400 underline mx-1">geminigen.ai</a></li> |
| <li className="flex gap-2"><span className="w-4 h-4 rounded-full bg-amber-500/20 text-amber-300 text-[9px] flex items-center justify-center font-bold flex-shrink-0">3</span>{t.poolBookmarkStep3}</li> |
| </ol> |
| <a |
| href={buildBookmarklet(otp!, syncLabel)} |
| draggable |
| onClick={(e) => { e.preventDefault(); toast({ description: t.poolDragToast }); }} |
| className="flex items-center justify-center gap-2 w-full px-4 py-2.5 rounded-lg border-2 border-dashed border-amber-500/50 bg-amber-500/10 text-amber-300 text-sm font-medium hover:bg-amber-500/20 transition-colors cursor-grab active:cursor-grabbing select-none" |
| > |
| <Bookmark className="w-4 h-4" /> |
| {t.poolBookmarkBtnText} |
| </a> |
| <p className="text-[10px] text-muted-foreground/50 text-center">{t.poolBookmarkDragHint}</p> |
| </> |
| )} |
| </div> |
| )} |
| </div> |
| )} |
|
|
| {} |
| {addMode === "manual" && ( |
| <div className="rounded-lg border border-blue-500/30 bg-black/20 p-3 space-y-2"> |
| <p className="text-[11px] font-medium text-blue-300/80">{t.poolManualTitle}</p> |
| <input |
| value={newLabel} onChange={(e) => setNewLabel(e.target.value)} |
| placeholder={t.poolManualNamePlaceholder} |
| className="w-full px-3 py-1.5 rounded border border-border/40 bg-black/30 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none" |
| /> |
| <input |
| value={newBearer} onChange={(e) => setNewBearer(e.target.value)} |
| placeholder={t.poolManualBearerPlaceholder} |
| className="w-full px-3 py-1.5 rounded border border-border/40 bg-black/30 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none" |
| /> |
| <input |
| value={newRefresh} onChange={(e) => setNewRefresh(e.target.value)} |
| placeholder={t.poolManualRefreshPlaceholder} |
| className="w-full px-3 py-1.5 rounded border border-border/40 bg-black/30 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none" |
| /> |
| <div className="flex gap-2"> |
| <Button size="sm" className="flex-1 bg-blue-600 hover:bg-blue-500 text-white" disabled={adding || !newBearer.trim()} onClick={handleAdd}> |
| {adding ? <RefreshCw className="w-3 h-3 animate-spin mr-1" /> : <Plus className="w-3 h-3 mr-1" />}{t.poolManualAddBtn} |
| </Button> |
| <Button size="sm" variant="ghost" onClick={() => setAddMode(null)}>{t.poolCancel}</Button> |
| </div> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| } |
|
|
| function AutoRenewalCard() { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const qc = useQueryClient(); |
| const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, ""); |
|
|
| |
| const [username, setUsername] = useState(""); |
| const [password, setPassword] = useState(""); |
| const [showPass, setShowPass] = useState(false); |
| const [savingCreds, setSavingCreds] = useState(false); |
|
|
| |
| const { data: credStatus, refetch: refetchCreds } = useQuery({ |
| queryKey: ["credentialStatus"], |
| queryFn: async () => { |
| const r = await fetch(`${BASE_URL}/api/admin/credentials`, { credentials: "include" }); |
| if (!r.ok) return { configured: false, username: null }; |
| return r.json() as Promise<{ configured: boolean; username: string | null }>; |
| }, |
| }); |
|
|
| const handleSaveCreds = async () => { |
| if (!username.trim() || !password.trim()) { |
| toast({ variant: "destructive", description: t.renewalRequireFields }); |
| return; |
| } |
| setSavingCreds(true); |
| try { |
| const r = await fetch(`${BASE_URL}/api/admin/credentials`, { |
| method: "POST", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ username: username.trim(), password: password.trim() }), |
| }); |
| const data = await r.json(); |
| if (!r.ok) throw new Error(data.error || "Failed"); |
| setUsername(""); setPassword(""); |
| await refetchCreds(); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| qc.invalidateQueries({ queryKey: ["setupStatus"] }); |
| toast({ description: t.renewalSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.renewalFailed }); |
| } |
| setSavingCreds(false); |
| }; |
|
|
| const handleDeleteCreds = async () => { |
| await fetch(`${BASE_URL}/api/admin/credentials`, { method: "DELETE", credentials: "include" }); |
| await refetchCreds(); |
| toast({ description: t.renewalClearedCreds }); |
| }; |
|
|
| return ( |
| <div className="space-y-3"> |
| {/* Auto-renewal via credentials (recommended) */} |
| <div className={`rounded-xl border p-4 space-y-3 ${credStatus?.configured ? "border-green-500/40 bg-green-500/5" : "border-primary/40 bg-primary/5"}`}> |
| <div className="flex items-start gap-3"> |
| <div className={`w-8 h-8 rounded-lg flex items-center justify-center flex-shrink-0 ${credStatus?.configured ? "bg-green-500/20 border border-green-500/30" : "bg-primary/20 border border-primary/30"}`}> |
| {credStatus?.configured ? <CheckCircle className="w-4 h-4 text-green-400" /> : <Zap className="w-4 h-4 text-primary" />} |
| </div> |
| <div className="flex-1"> |
| <h3 className={`text-sm font-semibold ${credStatus?.configured ? "text-green-300" : "text-primary"}`}> |
| {credStatus?.configured ? t.renewalTitle : t.renewalSetupTitle} |
| </h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {credStatus?.configured |
| ? <>{credStatus.username && <span className="text-green-300/70 font-mono">{credStatus.username}</span>} — {t.renewalActiveDesc}</> |
| : t.renewalInactiveDesc} |
| </p> |
| </div> |
| {credStatus?.configured && ( |
| <Button size="sm" variant="ghost" className="text-xs text-muted-foreground h-7" onClick={handleDeleteCreds}> |
| {t.renewalRemove} |
| </Button> |
| )} |
| </div> |
| |
| {!credStatus?.configured && ( |
| <div className="space-y-2"> |
| <Input |
| className="text-sm bg-background/50" |
| placeholder={t.renewalEmailPlaceholder} |
| value={username} |
| onChange={(e) => setUsername(e.target.value)} |
| autoComplete="off" |
| /> |
| <div className="relative"> |
| <Input |
| className="text-sm bg-background/50 pr-10" |
| type={showPass ? "text" : "password"} |
| placeholder={t.renewalPassPlaceholder} |
| value={password} |
| onChange={(e) => setPassword(e.target.value)} |
| autoComplete="new-password" |
| /> |
| <button |
| type="button" |
| className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground" |
| onClick={() => setShowPass(!showPass)} |
| > |
| {showPass ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />} |
| </button> |
| </div> |
| <p className="text-xs text-muted-foreground/70 flex items-center gap-1.5"> |
| <Lock className="w-3 h-3" /> |
| {t.renewalSecureNote} |
| </p> |
| <Button |
| size="sm" |
| className="w-full" |
| disabled={!username.trim() || !password.trim() || savingCreds} |
| onClick={handleSaveCreds} |
| > |
| {savingCreds ? <><RefreshCw className="w-3.5 h-3.5 animate-spin mr-2" />{t.renewalSaving}</> : t.renewalSaveBtn} |
| </Button> |
| </div> |
| )} |
| </div> |
|
|
| {} |
| <details className="rounded-xl border border-border/40 overflow-hidden"> |
| <summary className="px-4 py-3 text-xs font-medium text-muted-foreground cursor-pointer hover:text-foreground hover:bg-secondary/30 transition-colors select-none"> |
| {t.renewalManualTitle} |
| </summary> |
| <div className="px-4 pb-4 pt-2 space-y-2 border-t border-border/40 bg-black/10"> |
| <p className="text-xs text-muted-foreground">{t.renewalManualDesc}</p> |
| <ManualTokenInput qc={qc} baseUrl={BASE_URL} /> |
| </div> |
| </details> |
| </div> |
| ); |
| } |
|
|
| function ManualTokenInput({ qc, baseUrl }: { qc: any; baseUrl: string }) { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const [newToken, setNewToken] = useState(""); |
| const [saving, setSaving] = useState(false); |
|
|
| const handleRenew = async () => { |
| if (!newToken.trim() || newToken.trim().length < 20) { |
| toast({ variant: "destructive", description: t.renewalManualInvalid }); |
| return; |
| } |
| setSaving(true); |
| try { |
| const r = await fetch(`${baseUrl}/api/admin/setup`, { |
| method: "POST", credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ refreshToken: newToken.trim() }), |
| }); |
| const data = await r.json(); |
| if (!r.ok) throw new Error(data.error || "Failed"); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| qc.invalidateQueries({ queryKey: ["setupStatus"] }); |
| setNewToken(""); |
| toast({ description: t.renewalManualSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.renewalManualFailed }); |
| } |
| setSaving(false); |
| }; |
|
|
| return ( |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder={t.renewalManualPlaceholder} |
| value={newToken} |
| onChange={(e) => setNewToken(e.target.value)} |
| spellCheck={false} |
| /> |
| <Button size="sm" disabled={!newToken.trim() || saving} onClick={handleRenew} className="flex-shrink-0"> |
| {saving ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.renewalManualBtn} |
| </Button> |
| </div> |
| ); |
| } |
|
|
| function QuickTokenUpdate() { |
| const { toast } = useToast(); |
| const { t } = useLang(); |
| const qc = useQueryClient(); |
| const BASE_URL = import.meta.env.BASE_URL.replace(/\/$/, ""); |
| const [accessToken, setAccessToken] = useState(""); |
| const [capsolverKey, setCapsolverKey] = useState(""); |
| const [yescaptchaKey, setYescaptchaKey] = useState(""); |
| const [playwrightUrl, setPlaywrightUrl] = useState(""); |
| const [playwrightSecret, setPlaywrightSecret] = useState(""); |
| const [savingToken, setSavingToken] = useState(false); |
| const [savingCapsolver, setSavingCapsolver] = useState(false); |
| const [savingYescaptcha, setSavingYescaptcha] = useState(false); |
| const [savingPlaywright, setSavingPlaywright] = useState(false); |
| const [testingPlaywright, setTestingPlaywright] = useState(false); |
|
|
| const saveConfig = async (key: string, value: string): Promise<boolean> => { |
| const r = await fetch(`${BASE_URL}/api/admin/config`, { |
| method: "PUT", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ key, value }), |
| }); |
| const data = await r.json(); |
| if (!r.ok) throw new Error(data.error || "Failed"); |
| return true; |
| }; |
|
|
| const handleSaveToken = async () => { |
| const tok = accessToken.trim(); |
| if (tok.length < 20) return; |
| setSavingToken(true); |
| try { |
| await saveConfig("access_token", tok); |
| setAccessToken(""); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| toast({ description: t.adminTokenSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.adminSaveFailed }); |
| } |
| setSavingToken(false); |
| }; |
|
|
| const handleSaveCapsolver = async () => { |
| const key = capsolverKey.trim(); |
| if (!key) return; |
| setSavingCapsolver(true); |
| try { |
| await saveConfig("capsolver_api_key", key); |
| setCapsolverKey(""); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| toast({ description: t.adminCapSolverSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.adminSaveFailed }); |
| } |
| setSavingCapsolver(false); |
| }; |
|
|
| const handleSaveYescaptcha = async () => { |
| const key = yescaptchaKey.trim(); |
| if (!key) return; |
| setSavingYescaptcha(true); |
| try { |
| await saveConfig("yescaptcha_api_key", key); |
| setYescaptchaKey(""); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| toast({ description: t.adminYesCaptchaSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.adminSaveFailed }); |
| } |
| setSavingYescaptcha(false); |
| }; |
|
|
| const handleSavePlaywright = async () => { |
| const url = playwrightUrl.trim(); |
| if (!url) return; |
| setSavingPlaywright(true); |
| try { |
| await saveConfig("playwright_solver_url", url); |
| if (playwrightSecret.trim()) { |
| await saveConfig("playwright_solver_secret", playwrightSecret.trim()); |
| } |
| setPlaywrightUrl(""); |
| setPlaywrightSecret(""); |
| qc.invalidateQueries({ queryKey: ["adminConfig"] }); |
| toast({ description: t.adminPlaywrightSuccess }); |
| } catch (e: any) { |
| toast({ variant: "destructive", description: e.message || t.adminSaveFailed }); |
| } |
| setSavingPlaywright(false); |
| }; |
|
|
| const handleTestPlaywright = async () => { |
| const urlToTest = playwrightUrl.trim(); |
| if (!urlToTest) return; |
| setTestingPlaywright(true); |
| try { |
| const headers: Record<string, string> = { "Content-Type": "application/json" }; |
| if (playwrightSecret.trim()) headers["X-Solver-Secret"] = playwrightSecret.trim(); |
| const r = await fetch(urlToTest, { method: "POST", headers, body: "{}", signal: AbortSignal.timeout(60000) }); |
| const data = await r.json(); |
| if (r.ok && data.token) { |
| toast({ description: `✅ ${t.adminTestSuccess}:${data.token.substring(0, 20)}... (cached=${data.cached})` }); |
| } else { |
| toast({ variant: "destructive", description: `${t.adminTestFailed}:${data.error || r.status}` }); |
| } |
| } catch (e: any) { |
| toast({ variant: "destructive", description: `${t.videoConnectionFailed}:${e.message}` }); |
| } |
| setTestingPlaywright(false); |
| }; |
|
|
| return ( |
| <div className="space-y-3"> |
| {/* Access Token */} |
| <div className="rounded-xl border border-orange-500/40 bg-orange-500/5 p-4 space-y-3"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-lg bg-orange-500/20 border border-orange-500/30 flex items-center justify-center flex-shrink-0"> |
| <Key className="w-4 h-4 text-orange-400" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-orange-300">{t.adminTokenTitle}</h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {t.adminTokenDesc} |
| </p> |
| </div> |
| </div> |
| <div className="rounded-lg border border-border/40 bg-black/20 p-3 text-xs text-muted-foreground space-y-1"> |
| <p className="font-medium text-foreground/80">{t.adminTokenHowTo} <code className="bg-black/30 px-1 rounded">Authorization: Bearer eyJ...</code></p> |
| </div> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder="eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." |
| value={accessToken} |
| onChange={(e) => setAccessToken(e.target.value)} |
| spellCheck={false} |
| /> |
| <Button |
| size="sm" |
| disabled={accessToken.trim().length < 20 || savingToken} |
| onClick={handleSaveToken} |
| className="bg-orange-500 hover:bg-orange-600 text-white shrink-0" |
| > |
| {savingToken ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.adminTokenUpdate} |
| </Button> |
| </div> |
| </div> |
| |
| {/* Playwright Solver */} |
| <div className="rounded-xl border border-green-500/40 bg-green-500/5 p-4 space-y-3"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-lg bg-green-500/20 border border-green-500/30 flex items-center justify-center flex-shrink-0"> |
| <Zap className="w-4 h-4 text-green-400" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-green-300">{t.adminPlaywrightTitle}</h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {t.adminPlaywrightDesc} |
| </p> |
| </div> |
| </div> |
| <div className="rounded-lg border border-border/40 bg-black/20 p-3 text-xs text-muted-foreground space-y-1"> |
| <p className="font-medium text-foreground/80 mb-1">{t.adminPlaywrightStepsLabel}</p> |
| <ol className="list-decimal list-inside space-y-0.5 pl-1"> |
| <li>{t.adminPlaywrightStep1}</li> |
| <li><a href="https://vercel.com" target="_blank" rel="noopener noreferrer" className="text-green-400 underline">Vercel</a> — {t.adminPlaywrightStep2}</li> |
| <li>{t.adminPlaywrightStep3}</li> |
| <li>{t.adminPlaywrightStep4}</li> |
| </ol> |
| </div> |
| <div className="space-y-2"> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder="https://your-solver.vercel.app/api/solve" |
| value={playwrightUrl} |
| onChange={(e) => setPlaywrightUrl(e.target.value)} |
| spellCheck={false} |
| /> |
| </div> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder={t.adminPlaywrightSecretPlaceholder} |
| value={playwrightSecret} |
| onChange={(e) => setPlaywrightSecret(e.target.value)} |
| spellCheck={false} |
| type="password" |
| /> |
| </div> |
| <div className="flex gap-2"> |
| <Button |
| size="sm" |
| variant="outline" |
| disabled={playwrightUrl.trim().length < 10 || testingPlaywright} |
| onClick={handleTestPlaywright} |
| className="border-green-500/40 text-green-300 hover:bg-green-500/10 shrink-0" |
| > |
| {testingPlaywright ? <RefreshCw className="w-3.5 h-3.5 animate-spin mr-1" /> : null} |
| {t.adminTestConn} |
| </Button> |
| <Button |
| size="sm" |
| disabled={playwrightUrl.trim().length < 10 || savingPlaywright} |
| onClick={handleSavePlaywright} |
| className="bg-green-600 hover:bg-green-700 text-white shrink-0" |
| > |
| {savingPlaywright ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.adminSave} |
| </Button> |
| </div> |
| </div> |
| </div> |
| |
| {/* YesCaptcha API Key */} |
| <div className="rounded-xl border border-purple-500/40 bg-purple-500/5 p-4 space-y-3"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-lg bg-purple-500/20 border border-purple-500/30 flex items-center justify-center flex-shrink-0"> |
| <CheckCircle className="w-4 h-4 text-purple-400" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-purple-300">{t.adminYesCaptchaTitle}</h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {t.adminYesCaptchaDesc} |
| </p> |
| </div> |
| </div> |
| <div className="rounded-lg border border-border/40 bg-black/20 p-3 text-xs text-muted-foreground"> |
| <p>{t.adminYesCaptchaHowTo}</p> |
| </div> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder="xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" |
| value={yescaptchaKey} |
| onChange={(e) => setYescaptchaKey(e.target.value)} |
| spellCheck={false} |
| /> |
| <Button |
| size="sm" |
| disabled={yescaptchaKey.trim().length < 10 || savingYescaptcha} |
| onClick={handleSaveYescaptcha} |
| className="bg-purple-600 hover:bg-purple-700 text-white shrink-0" |
| > |
| {savingYescaptcha ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.adminYesCaptchaSet} |
| </Button> |
| </div> |
| </div> |
| |
| {/* CapSolver API Key */} |
| <div className="rounded-xl border border-blue-500/40 bg-blue-500/5 p-4 space-y-3"> |
| <div className="flex items-start gap-3"> |
| <div className="w-8 h-8 rounded-lg bg-blue-500/20 border border-blue-500/30 flex items-center justify-center flex-shrink-0"> |
| <Shield className="w-4 h-4 text-blue-400" /> |
| </div> |
| <div> |
| <h3 className="text-sm font-semibold text-blue-300">{t.adminCapSolverTitle}</h3> |
| <p className="text-xs text-muted-foreground mt-0.5"> |
| {t.adminCapSolverDesc} |
| </p> |
| </div> |
| </div> |
| <div className="rounded-lg border border-border/40 bg-black/20 p-3 text-xs text-muted-foreground"> |
| <p>{t.adminCapSolverHowTo}</p> |
| </div> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50" |
| placeholder="CAP-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx" |
| value={capsolverKey} |
| onChange={(e) => setCapsolverKey(e.target.value)} |
| spellCheck={false} |
| /> |
| <Button |
| size="sm" |
| disabled={capsolverKey.trim().length < 10 || savingCapsolver} |
| onClick={handleSaveCapsolver} |
| className="bg-blue-500 hover:bg-blue-600 text-white shrink-0" |
| > |
| {savingCapsolver ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.adminCapSolverSet} |
| </Button> |
| </div> |
| </div> |
| </div> |
| ); |
| } |
|
|
| function ConfigPanel() { |
| const { t } = useLang(); |
| const { toast } = useToast(); |
| const { data: config = [], refetch } = useQuery({ queryKey: ["adminConfig"], queryFn: fetchConfig }); |
| const [editing, setEditing] = useState<Record<string, string>>({}); |
| const [saving, setSaving] = useState<string | null>(null); |
| const [visible, setVisible] = useState<Record<string, boolean>>({}); |
|
|
| const handleSave = async (key: string) => { |
| const value = editing[key]; |
| if (!value) return; |
| setSaving(key); |
| try { |
| const r = await fetch(`${API_BASE}/config`, { |
| method: "PUT", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify({ key: KEY_MAP[key] ?? key, value }), |
| }); |
| if (!r.ok) throw new Error("Failed"); |
| await refetch(); |
| setEditing((p) => { const n = { ...p }; delete n[key]; return n; }); |
| toast({ description: t.adminConfigSaved }); |
| } catch { |
| toast({ variant: "destructive", description: t.adminError }); |
| } |
| setSaving(null); |
| }; |
|
|
| const maskValue = (v: string) => v.length > 20 ? v.slice(0, 12) + "..." + v.slice(-6) : v; |
| const LABELS: Record<string, string> = { |
| geminigen_refresh_token: "Refresh Token", |
| geminigen_bearer_token: "Access Token", |
| }; |
| const KEY_MAP: Record<string, string> = { |
| geminigen_refresh_token: "refresh_token", |
| geminigen_bearer_token: "access_token", |
| }; |
|
|
| return ( |
| <div className="space-y-4"> |
| <QuickTokenUpdate /> |
| <AccountPoolCard /> |
| <AutoRenewalCard /> |
| <TokenGuide /> |
| {config.length === 0 && ( |
| <div className="text-center py-8 text-muted-foreground text-sm">{t.adminConfigEmpty}</div> |
| )} |
| {config.map((row) => ( |
| <div key={row.key} className="rounded-xl border border-border/50 p-4 space-y-3"> |
| <div className="flex items-center justify-between"> |
| <div> |
| <span className="font-medium text-sm text-foreground">{LABELS[row.key] || row.key}</span> |
| <span className="text-xs text-muted-foreground ml-2">{t.adminConfigUpdated}: {new Date(row.updatedAt).toLocaleString()}</span> |
| </div> |
| <button |
| onClick={() => setVisible(p => ({ ...p, [row.key]: !p[row.key] }))} |
| className="text-muted-foreground hover:text-foreground" |
| > |
| {visible[row.key] ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />} |
| </button> |
| </div> |
| <div className="font-mono text-xs bg-black/30 rounded-lg px-3 py-2 text-emerald-300 break-all"> |
| {visible[row.key] ? row.value : maskValue(row.value)} |
| </div> |
| <div className="flex gap-2"> |
| <Input |
| className="flex-1 text-xs font-mono bg-background/50 border-border/60" |
| placeholder={t.adminConfigNewValue} |
| value={editing[row.key] || ""} |
| onChange={(e) => setEditing(p => ({ ...p, [row.key]: e.target.value }))} |
| /> |
| <Button |
| size="sm" |
| disabled={!editing[row.key] || saving === row.key} |
| onClick={() => handleSave(row.key)} |
| > |
| {saving === row.key ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : t.adminConfigSaveBtn} |
| </Button> |
| </div> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| export function Admin() { |
| const { t } = useLang(); |
| const { user, isAdmin, isLoaded } = useAuth(); |
| const [, navigate] = useLocation(); |
| const qc = useQueryClient(); |
| const { toast } = useToast(); |
| const [tab, setTab] = useState<"users" | "config">("users"); |
|
|
| const { data: stats } = useQuery({ queryKey: ["adminStats"], queryFn: fetchStats, enabled: isAdmin }); |
| const { data: users = [], isLoading: usersLoading } = useQuery({ |
| queryKey: ["adminUsers"], queryFn: fetchUsers, enabled: isAdmin && tab === "users", |
| }); |
|
|
| const updateUser = async (id: number, data: Record<string, unknown>) => { |
| const r = await fetch(`${API_BASE}/users/${id}`, { |
| method: "PUT", |
| credentials: "include", |
| headers: { "Content-Type": "application/json" }, |
| body: JSON.stringify(data), |
| }); |
| if (!r.ok) throw new Error("Failed"); |
| qc.invalidateQueries({ queryKey: ["adminUsers"] }); |
| }; |
|
|
| const deleteUser = async (id: number) => { |
| const r = await fetch(`${API_BASE}/users/${id}`, { method: "DELETE", credentials: "include" }); |
| if (!r.ok) { |
| const d = await r.json(); |
| throw new Error(d.error || "Failed"); |
| } |
| qc.invalidateQueries({ queryKey: ["adminUsers"] }); |
| }; |
|
|
| if (isLoaded && !isAdmin) { |
| return ( |
| <div className="container mx-auto px-4 py-16 max-w-lg text-center space-y-4"> |
| <AlertCircle className="w-12 h-12 mx-auto text-destructive/60" /> |
| <h1 className="text-xl font-bold text-foreground">{t.adminForbiddenTitle}</h1> |
| <p className="text-muted-foreground text-sm">{t.adminForbiddenDesc}</p> |
| <Button onClick={() => navigate("/")}>{t.adminBackHome}</Button> |
| </div> |
| ); |
| } |
|
|
| return ( |
| <div className="container mx-auto px-4 py-8 max-w-4xl space-y-8"> |
| <div className="flex items-center gap-3"> |
| <div className="w-10 h-10 rounded-xl bg-yellow-500/15 border border-yellow-500/30 flex items-center justify-center"> |
| <Shield className="w-5 h-5 text-yellow-400" /> |
| </div> |
| <div> |
| <h1 className="text-2xl font-bold text-foreground">{t.adminTitle}</h1> |
| <p className="text-muted-foreground text-sm">{t.adminSubtitle}</p> |
| </div> |
| </div> |
| |
| {stats && ( |
| <div className="grid grid-cols-1 sm:grid-cols-3 gap-4"> |
| <StatCard icon={Users} label={t.adminStatUsers} value={stats.users} color="bg-blue-500/15 border border-blue-500/20 text-blue-400" /> |
| <StatCard icon={ImageIcon} label={t.adminStatImages} value={stats.images} color="bg-purple-500/15 border border-purple-500/20 text-purple-400" /> |
| <StatCard icon={Key} label={t.adminStatApiKeys} value={stats.apiKeys} color="bg-green-500/15 border border-green-500/20 text-green-400" /> |
| </div> |
| )} |
| |
| <div className="flex border-b border-border/50 gap-1"> |
| {(["users", "config"] as const).map((t2) => ( |
| <button |
| key={t2} |
| onClick={() => setTab(t2)} |
| className={`px-4 py-2 text-sm font-medium rounded-t-lg transition-colors ${ |
| tab === t2 |
| ? "bg-card border border-b-card border-border/60 text-foreground -mb-px" |
| : "text-muted-foreground hover:text-foreground" |
| }`} |
| > |
| {t2 === "users" ? ( |
| <span className="flex items-center gap-2"><Users className="w-4 h-4" />{t.adminTabUsers}</span> |
| ) : ( |
| <span className="flex items-center gap-2"><Settings className="w-4 h-4" />{t.adminTabConfig}</span> |
| )} |
| </button> |
| ))} |
| </div> |
| |
| {tab === "users" && ( |
| <div className="space-y-2"> |
| {usersLoading ? ( |
| <div className="text-center py-8 text-muted-foreground text-sm">Loading...</div> |
| ) : users.length === 0 ? ( |
| <div className="text-center py-8 text-muted-foreground text-sm">{t.adminNoUsers}</div> |
| ) : ( |
| users.map((u) => ( |
| <UserRow |
| key={u.id} |
| user={u} |
| currentUserId={user!.id} |
| onUpdate={updateUser} |
| onDelete={deleteUser} |
| /> |
| )) |
| )} |
| </div> |
| )} |
| |
| {tab === "config" && <ConfigPanel />} |
| </div> |
| ); |
| } |
|
|