This commit is contained in:
Manchik
2026-05-19 18:28:43 +03:00
parent 48c16b8d3a
commit 68433b74c0
183 changed files with 18415 additions and 192 deletions
+7
View File
@@ -8,6 +8,7 @@
"name": "frontend",
"version": "0.0.0",
"dependencies": {
"flag-icons": "^7.5.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
@@ -2342,6 +2343,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/flag-icons": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/flag-icons/-/flag-icons-7.5.0.tgz",
"integrity": "sha512-kd+MNXviFIg5hijH766tt+3x76ele1AXlo4zDdCxIvqWZhKt4T83bOtxUOOMlTx/EcFdUMH5yvQgYlFh1EqqFg==",
"license": "MIT"
},
"node_modules/flat-cache": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz",
+1
View File
@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"flag-icons": "^7.5.0",
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+22
View File
@@ -8,6 +8,7 @@ import type {
MafileInfo,
MafileExportRequest,
ActionRequest,
ServicesSettings,
ValidationSettings,
DisplaySettings,
ColumnSettings,
@@ -193,6 +194,13 @@ export const api = {
}),
// Settings
getServicesSettings: () =>
request<ServicesSettings>("/settings/services"),
updateServicesSettings: (data: ServicesSettings) =>
request<ServicesSettings>("/settings/services", {
method: "PUT",
body: JSON.stringify(data),
}),
getValidationSettings: () =>
request<ValidationSettings>("/settings/validation"),
updateValidationSettings: (data: ValidationSettings) =>
@@ -223,6 +231,20 @@ export const api = {
// Logs
getLogs: () => request<LogEntry[]>("/logs"),
// Auto-confirm
startAutoConfirm: (ids: number[]) =>
request<{ started: number[] }>("/auto-confirm/start", {
method: "POST",
body: JSON.stringify({ account_ids: ids }),
}),
stopAutoConfirm: (ids: number[]) =>
request<{ stopped: number[] }>("/auto-confirm/stop", {
method: "POST",
body: JSON.stringify({ account_ids: ids }),
}),
getAutoConfirmStatus: () =>
request<{ running: number[]; errors: Record<string, string> }>("/auto-confirm/status"),
// Auto-accept
startAutoAccept: (ids: number[]) =>
request<{ started: number[] }>("/auto-accept/start", {
+34
View File
@@ -17,7 +17,13 @@ export interface Account {
steam_level: number | null;
last_online: string | null;
auto_accept: number;
auto_confirm: number;
ban_status: string | null;
vac_status: string | null;
limit_status: string | null;
vac_games: string | null;
balance: string | null;
country: string | null;
has_cookies: boolean;
has_revocation_code: boolean;
created_at: string;
@@ -131,6 +137,11 @@ export interface ValidationSettings {
auto_validate_on_import_token: boolean;
}
export interface ServicesSettings {
auto_accept_interval: number;
auto_confirm_interval: number;
}
export interface DisplaySettings {
hide_passwords: boolean;
}
@@ -148,12 +159,16 @@ export interface ColumnSettings {
phone: boolean;
status: boolean;
ban: boolean;
vac: boolean;
limit: boolean;
twofa: boolean;
mafile: boolean;
proxy: boolean;
notes: boolean;
actions: boolean;
last_online: boolean;
balance: boolean;
country: boolean;
}
export interface LogpassColumnSettings {
@@ -165,6 +180,8 @@ export interface LogpassColumnSettings {
login_pass: boolean;
status: boolean;
ban: boolean;
vac: boolean;
limit: boolean;
prime: boolean;
trophy: boolean;
behavior: boolean;
@@ -173,6 +190,8 @@ export interface LogpassColumnSettings {
notes: boolean;
actions: boolean;
last_online: boolean;
balance: boolean;
country: boolean;
}
export interface TokenColumnSettings {
@@ -183,9 +202,14 @@ export interface TokenColumnSettings {
login: boolean;
token: boolean;
status: boolean;
ban: boolean;
vac: boolean;
limit: boolean;
proxy: boolean;
notes: boolean;
actions: boolean;
balance: boolean;
country: boolean;
}
export interface LogEntry {
@@ -203,6 +227,11 @@ export interface LogpassAccount {
proxy: string | null;
status: string;
ban_status: string | null;
vac_status: string | null;
limit_status: string | null;
vac_games: string | null;
balance: string | null;
country: string | null;
nickname: string | null;
steam_level: number | null;
prime: string | null;
@@ -242,6 +271,11 @@ export interface TokenAccount {
proxy: string | null;
status: string;
ban_status: string | null;
vac_status: string | null;
limit_status: string | null;
vac_games: string | null;
balance: string | null;
country: string | null;
nickname: string | null;
steam_level: number | null;
avatar_url: string | null;
@@ -4,10 +4,10 @@ import { useAccountStore } from "@/stores/accountStore";
import { copyText } from "@/lib/clipboard";
import { useUiStore } from "@/stores/uiStore";
import { api } from "@/api/client";
import { StatusBadge, BanBadge, MafileBadge } from "./Badges";
import { StatusBadge, BanBadge, MafileBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
import { ActionMenu } from "./ActionMenu";
import { SteamLevelBadge } from "./SteamLevelBadge";
import { IconCheck, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
import { IconCheck, IconCheckCircle, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
@@ -21,10 +21,11 @@ interface Props {
onDelete: (id: number) => void;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onToggleAutoAccept: (id: number) => void;
onToggleAutoConfirm: (id: number) => void;
onOpenBrowser: (id: number) => void;
}
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onToggleAutoConfirm, onOpenBrowser }: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds);
const toggleSelect = useAccountStore((s) => s.toggleSelect);
const addToast = useUiStore((s) => s.addToast);
@@ -192,6 +193,18 @@ export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProces
{v.phone && <td className="px-3 py-2 text-gray-400 text-xs">{a.phone || "—"}</td>}
{v.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
{v.ban && <td className="px-3 py-2"><BanBadge status={a.ban_status} /></td>}
{v.vac && <td className="px-3 py-2"><VacBadge status={a.vac_status} games={a.vac_games} /></td>}
{v.limit && <td className="px-3 py-2"><LimitBadge status={a.limit_status} /></td>}
{v.balance && (
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
{a.balance || "—"}
</td>
)}
{v.country && (
<td className="px-3 py-2">
<CountryBadge country={a.country} />
</td>
)}
{v.twofa && (
<td className="px-3 py-2 whitespace-nowrap">
{a.shared_secret ? (
@@ -269,11 +282,26 @@ export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProces
{v.actions && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<ActionMenu account={a} onAction={onAction} onToggleAutoAccept={onToggleAutoAccept} />
<ActionMenu account={a} onAction={onAction} onToggleAutoAccept={onToggleAutoAccept} onToggleAutoConfirm={onToggleAutoConfirm} />
<button onClick={() => onEdit(a.id)} className="text-gray-400 hover:text-accent" title={t("tip.edit")}><IconEdit size={14} /></button>
<button onClick={() => onDelete(a.id)} className="text-gray-400 hover:text-red-400" title={t("tip.delete")}><IconTrash size={14} /></button>
{a.auto_accept ? (
<span title={t("tip.autoAcceptActive")}><IconRefresh size={14} className="text-accent" /></span>
<span
title={t("tip.autoAcceptActive")}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-accent/20 border border-accent/50 text-accent text-[10px] font-bold leading-none"
>
<IconRefresh size={10} />
AA
</span>
) : null}
{a.auto_confirm ? (
<span
title={t("tip.autoConfirmActive")}
className="inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded bg-green-500/20 border border-green-500/50 text-green-400 text-[10px] font-bold leading-none"
>
<IconCheckCircle size={10} />
AC
</span>
) : null}
</div>
</td>
@@ -19,6 +19,10 @@ const COLUMN_KEYS: (keyof ColumnSettings)[] = [
"phone",
"status",
"ban",
"vac",
"limit",
"balance",
"country",
"twofa",
"mafile",
"proxy",
@@ -40,6 +44,10 @@ const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
phone: "col.phone",
status: "col.status",
ban: "col.ban",
vac: "col.vac",
limit: "col.limit",
balance: "col.balance",
country: "col.country",
twofa: "col.twofa",
mafile: "col.mafile",
proxy: "col.proxy",
@@ -60,6 +68,7 @@ interface Props {
params: Record<string, string>,
) => void;
onToggleAutoAccept: (id: number) => void;
onToggleAutoConfirm: (id: number) => void;
onOpenBrowser: (id: number) => void;
}
@@ -72,6 +81,7 @@ export function AccountTable({
onDelete,
onAction,
onToggleAutoAccept,
onToggleAutoConfirm,
onOpenBrowser,
}: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds);
@@ -101,46 +111,56 @@ export function AccountTable({
const [visibleCount, setVisibleCount] = useState(BATCH);
const sentinelRef = useRef<HTMLTableRowElement>(null);
type SortField = "last_online" | null;
type SortField = "last_online" | "ban" | "vac" | "limit" | null;
type SortDir = "asc" | "desc";
const [sortField, setSortField] = useState<SortField>(null);
const [sortDir, setSortDir] = useState<SortDir>("asc");
const toggleSort = (field: SortField) => {
if (sortField === field) {
if (sortDir === "asc") setSortDir("desc");
else {
setSortField(null);
setSortDir("asc");
}
else { setSortField(null); setSortDir("asc"); }
} else {
setSortField(field);
setSortDir("asc");
}
};
const STATUS_ORDER: Record<string, number> = {
BANNED: 0, "GAME BAN": 0, VAC: 1, Lim: 0, NoLim: 1,
"NO BAN": 2, CLEAN: 2,
};
const sorted = useMemo(() => {
if (!sortField) return accounts;
const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol();
const EMPTY = 999;
return [...accounts].sort((a, b) => {
const parseOnline = (
v: string | null | undefined,
): number | typeof EMPTY => {
if (!v || v === "\u2014") return EMPTY;
if (v === "online") return -1;
const m = v.match(/^(\d+)([mhd])$/i);
if (!m) return EMPTY;
const n = parseInt(m[1], 10);
if (m[2] === "m") return n;
if (m[2] === "h") return n * 60;
return n * 1440;
};
const va = parseOnline(a.last_online);
const vb = parseOnline(b.last_online);
if (va === EMPTY && vb === EMPTY) return 0;
let va: number, vb: number;
if (sortField === "last_online") {
const parse = (v: string | null | undefined): number => {
if (!v || v === "\u2014") return EMPTY;
if (v === "online") return -1;
const m = v.match(/^(\d+)([mhd])$/i);
if (!m) return EMPTY;
const n = parseInt(m[1], 10);
if (m[2] === "m") return n;
if (m[2] === "h") return n * 60;
return n * 1440;
};
va = parse(a.last_online); vb = parse(b.last_online);
} else if (sortField === "ban") {
va = STATUS_ORDER[a.ban_status ?? ""] ?? EMPTY;
vb = STATUS_ORDER[b.ban_status ?? ""] ?? EMPTY;
} else if (sortField === "vac") {
va = STATUS_ORDER[a.vac_status ?? ""] ?? EMPTY;
vb = STATUS_ORDER[b.vac_status ?? ""] ?? EMPTY;
} else {
va = STATUS_ORDER[a.limit_status ?? ""] ?? EMPTY;
vb = STATUS_ORDER[b.limit_status ?? ""] ?? EMPTY;
}
if (va === vb) return 0;
if (va === EMPTY) return 1;
if (vb === EMPTY) return -1;
if (va === vb) return 0;
return (va < vb ? -1 : 1) * dir;
});
}, [accounts, sortField, sortDir]);
@@ -149,6 +169,7 @@ export function AccountTable({
setVisibleCount(BATCH);
}, [accounts, sortField, sortDir]);
const visible = useMemo(
() => sorted.slice(0, visibleCount),
[sorted, visibleCount],
@@ -217,19 +238,18 @@ export function AccountTable({
</th>
<th className="w-5"></th>
{visibleKeys.map((k) => {
if (k === "last_online") {
const sortKey = (k === "ban" || k === "vac" || k === "limit" || k === "last_online")
? k as SortField
: null;
if (sortKey) {
return (
<th
key={k}
className="px-3 py-2 cursor-pointer select-none hover:text-gray-300"
onClick={() => toggleSort("last_online")}
onClick={() => toggleSort(sortKey)}
>
{t(COL_LABEL_KEYS[k])}
{sortField === "last_online"
? sortDir === "asc"
? " ▲"
: " ▼"
: ""}
{sortField === sortKey ? (sortDir === "asc" ? " ▲" : " ▼") : ""}
</th>
);
}
@@ -258,6 +278,7 @@ export function AccountTable({
onDelete={onDelete}
onAction={onAction}
onToggleAutoAccept={onToggleAutoAccept}
onToggleAutoConfirm={onToggleAutoConfirm}
onOpenBrowser={onOpenBrowser}
/>
))}
@@ -306,11 +306,49 @@ export function AccountsTab() {
});
}
const ql = q.toLowerCase();
const ql = q.toLowerCase().trim();
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
if (ql === "gameban" || ql === "game ban") return accounts.filter((a) => a.vac_status === "GAME BAN");
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
if (ql === "noban" || ql === "no ban") return accounts.filter((a) => a.ban_status === "NO BAN");
// Country filter: "country:us" or "country:united states"
const countryMatch = ql.match(/^country:(.+)$/);
if (countryMatch) {
const cv = countryMatch[1].trim().toLowerCase();
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
}
// Balance filter: "balance>10", "usd>10", "balance<5", etc.
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
if (balMatch) {
const op = balMatch[1];
const threshold = parseFloat(balMatch[2]);
const parseNum = (b: string | null) => {
if (!b) return NaN;
const m = b.match(/[\d,]+\.?\d*/);
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
};
return accounts.filter((a) => {
const n = parseNum(a.balance);
if (isNaN(n)) return false;
if (op === ">") return n > threshold;
if (op === ">=") return n >= threshold;
if (op === "<") return n < threshold;
if (op === "<=") return n <= threshold;
return false;
});
}
const words = ql.split(/\s+/).filter(Boolean);
return accounts.filter((a) => {
if (a.login.toLowerCase().includes(ql)) return true;
if (a.steam_id && a.steam_id.toLowerCase().includes(ql)) return true;
if (a.country && a.country.toLowerCase().includes(ql)) return true;
if (a.notes) {
const notesLower = a.notes.toLowerCase();
return words.some((w) => notesLower.includes(w));
@@ -591,6 +629,26 @@ export function AccountsTab() {
}
};
const handleToggleAutoConfirm = async (id: number) => {
const acc = accounts.find((a) => a.id === id);
if (!acc) return;
const enable = !acc.auto_confirm;
try {
if (enable) await api.startAutoConfirm([id]);
else await api.stopAutoConfirm([id]);
addToast(
"info",
enable ? t("action.enableAutoConfirm") : t("action.disableAutoConfirm"),
);
await loadAccounts();
} catch (e: unknown) {
addToast(
"error",
t("misc.error", { error: e instanceof Error ? e.message : String(e) }),
);
}
};
const handleEdit = (id: number) => setEditId(id);
const handleSaveEdit = async (id: number, data: AccountUpdate) => {
@@ -1064,6 +1122,7 @@ export function AccountsTab() {
onDelete={handleDelete}
onAction={handleAction}
onToggleAutoAccept={handleToggleAutoAccept}
onToggleAutoConfirm={handleToggleAutoConfirm}
onOpenBrowser={handleOpenBrowser}
/>
</div>
@@ -38,9 +38,10 @@ interface Props {
account: Account;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onToggleAutoAccept: (id: number) => void;
onToggleAutoConfirm: (id: number) => void;
}
export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
export function ActionMenu({ account, onAction, onToggleAutoAccept, onToggleAutoConfirm }: Props) {
const [open, setOpen] = useState(false);
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
const [paramValue, setParamValue] = useState("");
@@ -109,6 +110,7 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
};
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
const acLabel = account.auto_confirm ? t("action.disableAutoConfirm") : t("action.enableAutoConfirm");
const handleGenerate2FA = async () => {
setOpen(false);
@@ -207,6 +209,13 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
<button onClick={() => { setOpen(false); onToggleAutoAccept(account.id); }} className="action-menu-item">
{aaLabel}
</button>
<button
onClick={() => { setOpen(false); onToggleAutoConfirm(account.id); }}
disabled={!account.identity_secret}
className={`action-menu-item ${!account.identity_secret ? "opacity-40 cursor-not-allowed" : ""}`}
>
{acLabel}
</button>
</div>,
document.body,
)}
@@ -1,3 +1,20 @@
import { useState, useRef } from "react";
export function CountryBadge({ country }: { country: string | null }) {
if (!country) return <span className="text-gray-600 text-xs"></span>;
const isCode = country.length === 2;
return (
<span className="inline-flex items-center gap-1.5 text-xs text-gray-300 whitespace-nowrap">
{isCode && (
<span
className={`fi fi-${country.toLowerCase()}`}
style={{ width: "1.33em", height: "1em", borderRadius: "2px" }}
/>
)}
<span>{country}</span>
</span>
);
}
import { createPortal } from "react-dom";
import { IconLock, IconUnlock } from "@/components/shared/Icons";
import { t } from "@/lib/i18n";
@@ -35,3 +52,58 @@ export function MafileBadge({ hasMafile }: { hasMafile: boolean }) {
? <span className="badge badge-ok"></span>
: <span className="badge badge-unknown"></span>;
}
export function VacBadge({ status, games }: { status: string | null; games?: string | null }) {
const [pos, setPos] = useState<{ top: number; left: number } | null>(null);
const ref = useRef<HTMLSpanElement>(null);
const gameList: string[] = (() => {
if (!games) return [];
try { return JSON.parse(games) as string[]; } catch { return []; }
})();
const handleEnter = () => {
if (!ref.current || gameList.length === 0) return;
const r = ref.current.getBoundingClientRect();
setPos({ top: r.top, left: r.right + 6 });
};
const handleLeave = () => setPos(null);
if (status === "VAC") return <span className="badge badge-error">Vac</span>;
if (status === "GAME BAN") {
return (
<>
<span
ref={ref}
className="badge badge-error cursor-default"
onMouseEnter={handleEnter}
onMouseLeave={handleLeave}
>
Vac{gameList.length > 0 && <span className="ml-0.5 opacity-70">({gameList.length})</span>}
</span>
{pos && gameList.length > 0 && createPortal(
<div
style={{ position: "fixed", top: pos.top, left: pos.left, zIndex: 9999 }}
className="bg-dark-700 border border-dark-600 rounded-lg shadow-xl px-2.5 py-2 min-w-[140px] max-w-[220px] pointer-events-none"
>
<div className="text-[10px] text-gray-400 mb-1 font-semibold uppercase tracking-wide">Game Bans</div>
{gameList.map((g, i) => (
<div key={i} className="text-xs text-gray-200 py-0.5 border-b border-dark-600 last:border-0">{g}</div>
))}
</div>,
document.body,
)}
</>
);
}
if (status === "CLEAN") return <span className="badge badge-ok">NoVac</span>;
return <span className="badge badge-unknown"></span>;
}
export function LimitBadge({ status }: { status: string | null }) {
if (status === "Lim") return <span className="badge badge-running">Lim</span>;
if (status === "NoLim") return <span className="badge badge-ok">NoLim</span>;
return <span className="badge badge-unknown"></span>;
}
@@ -9,7 +9,9 @@ const COL_LABEL_KEYS: Record<keyof ColumnSettings, string> = {
steam_id: "col.steamId", login: "col.login", password: "col.password",
login_pass: "col.loginPass", email: "col.email", email_pass: "col.emailPass",
email_login_pass: "col.emailLoginPass", phone: "col.phone", status: "col.status",
ban: "col.ban", twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
ban: "col.ban", vac: "col.vac", limit: "col.limit",
balance: "col.balance", country: "col.country",
twofa: "col.twofa", mafile: "col.mafile", proxy: "col.proxy",
notes: "col.notes", actions: "col.actions",
};
@@ -8,6 +8,8 @@ const COL_LABEL_KEYS: Record<keyof LogpassColumnSettings, string> = {
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
steam_id: "col.steamId", login: "col.login", password: "col.password",
login_pass: "col.loginPass", status: "col.status", ban: "col.ban",
vac: "col.vac", limit: "col.limit",
balance: "col.balance", country: "col.country",
prime: "col.prime", trophy: "col.trophy", behavior: "col.behavior",
license: "col.license", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
};
@@ -7,7 +7,7 @@ import { LogpassImportModal } from "./LogpassImportModal";
import { LogpassAddModal } from "./LogpassAddModal";
import { LogpassEditModal } from "./LogpassEditModal";
import { LogpassColumnSettingsDropdown } from "./LogpassColumnSettingsDropdown";
import { StatusBadge, BanBadge } from "./Badges";
import { StatusBadge, BanBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
import { SteamLevelBadge } from "./SteamLevelBadge";
import { copyText } from "@/lib/clipboard";
import {
@@ -304,12 +304,47 @@ export function LogpassTab() {
}
const ql = q.toLowerCase();
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
if (ql === "noban" || ql === "no ban") return accounts.filter((a) => a.ban_status === "NO BAN");
const countryM = ql.match(/^country:(.+)$/);
if (countryM) {
const cv = countryM[1].trim();
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
}
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
if (balMatch) {
const op = balMatch[1];
const threshold = parseFloat(balMatch[2]);
const parseNum = (b: string | null) => {
if (!b) return NaN;
const m = b.match(/[\d,]+\.?\d*/);
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
};
return accounts.filter((a) => {
const n = parseNum(a.balance);
if (isNaN(n)) return false;
if (op === ">") return n > threshold;
if (op === ">=") return n >= threshold;
if (op === "<") return n < threshold;
if (op === "<=") return n <= threshold;
return false;
});
}
return accounts.filter(
(a) =>
a.login.toLowerCase().includes(ql) ||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
(a.nickname ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql) ||
(a.country ?? "").toLowerCase().includes(ql) ||
(a.license ?? "").toLowerCase().includes(ql),
);
}, [accounts, searchQuery]);
@@ -705,6 +740,10 @@ export function LogpassTab() {
<th className="px-3 py-2">{t("col.status")}</th>
)}
{cols.ban && <th className="px-3 py-2">{t("col.ban")}</th>}
{cols.vac && <th className="px-3 py-2">{t("col.vac")}</th>}
{cols.limit && <th className="px-3 py-2">{t("col.limit")}</th>}
{cols.balance && <th className="px-3 py-2">{t("col.balance")}</th>}
{cols.country && <th className="px-3 py-2">{t("col.country")}</th>}
{cols.prime && (
<th
className="px-3 py-2 cursor-pointer select-none hover:text-gray-300"
@@ -1015,12 +1054,31 @@ function LogpassRow({
<StatusBadge status={a.status} />
</td>
)}
{/* Ban */}
{cols.ban && (
<td className="px-3 py-2">
<BanBadge status={a.ban_status} />
</td>
)}
{cols.vac && (
<td className="px-3 py-2">
<VacBadge status={a.vac_status} games={a.vac_games} />
</td>
)}
{cols.limit && (
<td className="px-3 py-2">
<LimitBadge status={a.limit_status} />
</td>
)}
{cols.balance && (
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
{a.balance || "—"}
</td>
)}
{cols.country && (
<td className="px-3 py-2">
<CountryBadge country={a.country} />
</td>
)}
{/* Prime, Trophy, Behavior */}
{cols.prime && (
<td className="px-3 py-2 text-xs text-gray-400">{a.prime ?? "—"}</td>
@@ -7,7 +7,9 @@ import { useT } from "@/lib/i18n";
const COL_LABEL_KEYS: Record<keyof TokenColumnSettings, string> = {
browser: "col.browser", profile: "col.profile", last_online: "col.lastOnline",
steam_id: "col.steamId", login: "col.login", token: "col.token",
status: "col.status", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
status: "col.status", ban: "col.ban", vac: "col.vac", limit: "col.limit",
balance: "col.balance", country: "col.country",
proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
};
export function TokenColumnSettingsDropdown() {
+65 -1
View File
@@ -3,7 +3,7 @@ import { api } from "@/api/client";
import { useTokenStore } from "@/stores/tokenStore";
import { useUiStore } from "@/stores/uiStore";
import { ConfirmModal } from "@/components/shared/Modals";
import { StatusBadge } from "./Badges";
import { StatusBadge, BanBadge, VacBadge, LimitBadge, CountryBadge } from "./Badges";
import { SteamLevelBadge } from "./SteamLevelBadge";
import { TokenImportModal } from "./TokenImportModal";
import { TokenAddModal } from "./TokenAddModal";
@@ -403,11 +403,45 @@ export function TokenTab() {
}
const ql = q.toLowerCase();
if (ql === "vac") return accounts.filter((a) => a.vac_status === "VAC" || a.vac_status === "GAME BAN");
if (ql === "clean") return accounts.filter((a) => a.vac_status === "CLEAN");
if (ql === "lim") return accounts.filter((a) => a.limit_status === "Lim");
if (ql === "nolim") return accounts.filter((a) => a.limit_status === "NoLim");
if (ql === "ban" || ql === "banned") return accounts.filter((a) => a.ban_status === "BANNED");
const countryM = ql.match(/^country:(.+)$/);
if (countryM) {
const cv = countryM[1].trim();
return accounts.filter((a) => a.country?.toLowerCase().includes(cv));
}
const balMatch = ql.match(/^(?:balance|usd|eur|rub|cny|gbp|cad|aud|brl|try|jpy|krw|inr|pln|nok|sek|dkk|chf|hkd|sgd|nzd|mxn|vnd)([<>]=?)(\d+\.?\d*)$/);
if (balMatch) {
const op = balMatch[1];
const threshold = parseFloat(balMatch[2]);
const parseNum = (b: string | null) => {
if (!b) return NaN;
const m = b.match(/[\d,]+\.?\d*/);
return m ? parseFloat(m[0].replace(/,/g, "")) : NaN;
};
return accounts.filter((a) => {
const n = parseNum(a.balance);
if (isNaN(n)) return false;
if (op === ">") return n > threshold;
if (op === ">=") return n >= threshold;
if (op === "<") return n < threshold;
if (op === "<=") return n <= threshold;
return false;
});
}
return accounts.filter(
(a) =>
(a.login ?? "").toLowerCase().includes(ql) ||
(a.steam_id ?? "").toLowerCase().includes(ql) ||
(a.nickname ?? "").toLowerCase().includes(ql) ||
(a.country ?? "").toLowerCase().includes(ql) ||
(a.notes ?? "").toLowerCase().includes(ql),
);
}, [accounts, searchQuery]);
@@ -616,6 +650,11 @@ export function TokenTab() {
{cols.status && (
<th className="px-3 py-2">{t("col.status")}</th>
)}
{cols.ban && <th className="px-3 py-2">{t("col.ban")}</th>}
{cols.vac && <th className="px-3 py-2">{t("col.vac")}</th>}
{cols.limit && <th className="px-3 py-2">{t("col.limit")}</th>}
{cols.balance && <th className="px-3 py-2">{t("col.balance")}</th>}
{cols.country && <th className="px-3 py-2">{t("col.country")}</th>}
{cols.proxy && <th className="px-3 py-2">{t("col.proxy")}</th>}
{cols.notes && <th className="px-3 py-2">{t("col.notes")}</th>}
{cols.actions && (
@@ -740,6 +779,31 @@ export function TokenTab() {
<StatusBadge status={a.status} />
</td>
)}
{cols.ban && (
<td className="px-3 py-2">
<BanBadge status={a.ban_status} />
</td>
)}
{cols.vac && (
<td className="px-3 py-2">
<VacBadge status={a.vac_status} games={a.vac_games} />
</td>
)}
{cols.limit && (
<td className="px-3 py-2">
<LimitBadge status={a.limit_status} />
</td>
)}
{cols.balance && (
<td className="px-3 py-2 text-xs font-mono text-gray-300 whitespace-nowrap">
{a.balance || "—"}
</td>
)}
{cols.country && (
<td className="px-3 py-2">
<CountryBadge country={a.country} />
</td>
)}
{cols.proxy && (
<td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">
{proxyLabels.get(a.id) ?? "—"}
+2 -2
View File
@@ -6,10 +6,10 @@ export function ToolsTab() {
return (
<div className="max-w-5xl mx-auto flex flex-col gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TwoFAGenerator />
<ValidationSettings />
<ProxyManager />
</div>
<ProxyManager />
<TwoFAGenerator />
</div>
);
}
@@ -39,7 +39,7 @@ export function TwoFAGenerator() {
const q = search.toLowerCase();
return !q || a.login.toLowerCase().includes(q) || (a.steam_id ?? "").includes(q);
})
.slice(0, 12);
.slice(0, 10);
const generate = async () => {
try {
@@ -70,68 +70,87 @@ export function TwoFAGenerator() {
}`;
return (
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden flex flex-col">
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
<IconKey size={14} className="text-accent shrink-0" />
<div className="bg-dark-800 border border-dark-600 rounded-xl flex flex-col">
<div className="px-5 py-3.5 border-b border-dark-600 flex items-center gap-2.5">
<IconKey size={15} className="text-accent shrink-0" />
<span className="text-sm font-semibold text-gray-100">{t("tools.2faGenerator")}</span>
</div>
<div className="px-4 pt-3 pb-2">
<div className="flex gap-1 bg-dark-900 rounded p-0.5 mb-3">
<button type="button" className={tabCls("secret")} onClick={() => setMode("secret")}>Shared secret</button>
<button type="button" className={tabCls("account")} onClick={() => setMode("account")}>Из Mafile</button>
<div className="flex flex-1 gap-0">
{/* Left: controls */}
<div className="flex-1 px-5 py-5 flex flex-col gap-4">
<div className="flex gap-1 bg-dark-900 rounded p-0.5">
<button type="button" className={tabCls("secret")} onClick={() => setMode("secret")}>Shared secret</button>
<button type="button" className={tabCls("account")} onClick={() => setMode("account")}>Из Mafile</button>
</div>
{mode === "secret" ? (
<div className="flex gap-2">
<input
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && generate()}
placeholder="shared_secret"
className="flex-1 bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<button type="button" onClick={generate} className="btn-primary text-sm px-4 py-2">
{t("btn.generate")}
</button>
</div>
) : (
<div className="flex gap-2">
<div className="relative flex-1" ref={dropdownRef}>
<input
value={selected ? `${selected.login}${selected.steam_id ? " · " + selected.steam_id : ""}` : search}
onChange={(e) => { setSearch(e.target.value); setSelected(null); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder="Логин или SteamID..."
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
{open && filtered.length > 0 && (
<div className="absolute top-full mt-1 left-0 right-0 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto">
{filtered.map((a) => (
<button
key={a.id}
type="button"
className="w-full text-left px-3 py-1.5 text-sm hover:bg-dark-600 transition-colors flex items-center justify-between gap-2"
onMouseDown={() => { setSelected(a); setSearch(""); setOpen(false); }}
>
<span className="text-gray-200 truncate">{a.login}</span>
{a.steam_id && <span className="text-gray-500 text-xs shrink-0">{a.steam_id}</span>}
</button>
))}
</div>
)}
</div>
<button
type="button"
onClick={generate}
disabled={!selected}
className="btn-primary text-sm px-4 py-2 disabled:opacity-40 disabled:cursor-not-allowed"
>
{t("btn.generate")}
</button>
</div>
)}
</div>
{mode === "secret" ? (
<div className="flex gap-2">
<input
value={secret}
onChange={(e) => setSecret(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && generate()}
placeholder="shared_secret"
className="flex-1 bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
/>
<button type="button" onClick={generate} className="btn-primary text-sm px-3 py-1.5">{t("btn.generate")}</button>
</div>
) : (
<div className="flex gap-2">
<div className="relative flex-1" ref={dropdownRef}>
<input
value={selected ? `${selected.login}${selected.steam_id ? " · " + selected.steam_id : ""}` : search}
onChange={(e) => { setSearch(e.target.value); setSelected(null); setOpen(true); }}
onFocus={() => setOpen(true)}
placeholder="Логин или SteamID..."
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-1.5 text-sm"
/>
{open && filtered.length > 0 && (
<div className="absolute top-full mt-1 left-0 right-0 bg-dark-800 border border-dark-600 rounded-lg shadow-xl z-50 max-h-48 overflow-y-auto">
{filtered.map((a) => (
<button
key={a.id}
type="button"
className="w-full text-left px-3 py-1.5 text-sm hover:bg-dark-600 transition-colors flex items-center justify-between gap-2"
onMouseDown={() => { setSelected(a); setSearch(""); setOpen(false); }}
>
<span className="text-gray-200 truncate">{a.login}</span>
{a.steam_id && <span className="text-gray-500 text-xs shrink-0">{a.steam_id}</span>}
</button>
))}
</div>
)}
</div>
<button type="button" onClick={generate} disabled={!selected} className="btn-primary text-sm px-3 py-1.5 disabled:opacity-40 disabled:cursor-not-allowed">{t("btn.generate")}</button>
</div>
)}
{code && (
<div
className="mt-3 text-2xl font-mono text-accent cursor-pointer tabular-nums tracking-widest"
onClick={handleCopy}
title={t("tools.clickCopy")}
>
{code}
</div>
)}
{/* Right: code display */}
<div
className="border-l border-dark-600 flex flex-col items-center justify-center w-52 shrink-0 cursor-pointer select-none group gap-2"
onClick={handleCopy}
title={t("tools.clickCopy")}
>
<p className="text-xs text-gray-600">{t("tools.clickCopy")}</p>
{code ? (
<span className="text-4xl font-mono text-accent tabular-nums tracking-widest group-hover:text-accent/80 transition-colors">
{code}
</span>
) : (
<span className="text-2xl font-mono text-dark-500 tracking-widest"> </span>
)}
</div>
</div>
</div>
);
@@ -1,5 +1,5 @@
import { useState, useEffect, useRef } from "react";
import type { ValidationSettings as VS } from "@/api/types";
import type { ValidationSettings as VS, ServicesSettings as SS } from "@/api/types";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n";
@@ -46,19 +46,24 @@ function SettingRow({
);
}
function ThreadStepper({
function NumericStepper({
label,
value,
onChange,
min = 1,
steps = [-10, -1, 1, 10],
}: {
label: string;
value: number;
onChange: (v: number) => void;
min?: number;
steps?: number[];
}) {
const t = useT();
const [editing, setEditing] = useState(false);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const clamp = (n: number) => Math.max(1, n);
const clamp = (n: number) => Math.max(min, n);
const commit = () => {
onChange(clamp(parseInt(input) || value));
setEditing(false);
@@ -73,23 +78,14 @@ function ThreadStepper({
className="text-sm text-gray-400 flex-1"
title="Двойной клик на числе для ручного ввода"
>
{t("tools.maxThreads")}
{label}
</span>
<div className="flex items-center gap-1">
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value - 10))}
>
-10
</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value - 1))}
>
-1
</button>
{steps.filter((s) => s < 0).map((s) => (
<button key={s} type="button" className={btnCls} onClick={() => onChange(clamp(value + s))}>
{s}
</button>
))}
{editing ? (
<input
ref={inputRef}
@@ -115,25 +111,33 @@ function ThreadStepper({
{value}
</span>
)}
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value + 1))}
>
+1
</button>
<button
type="button"
className={btnCls}
onClick={() => onChange(clamp(value + 10))}
>
+10
</button>
{steps.filter((s) => s > 0).map((s) => (
<button key={s} type="button" className={btnCls} onClick={() => onChange(clamp(value + s))}>
+{s}
</button>
))}
</div>
</div>
);
}
function ThreadStepper({
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
const t = useT();
return (
<NumericStepper
label={t("tools.maxThreads")}
value={value}
onChange={onChange}
/>
);
}
export function ValidationSettings() {
const t = useT();
const [settings, setSettings] = useState<VS>({
@@ -150,6 +154,10 @@ export function ValidationSettings() {
auto_validate_on_import_logpass: true,
auto_validate_on_import_token: true,
});
const [services, setServices] = useState<SS>({
auto_accept_interval: 15,
auto_confirm_interval: 30,
});
const [importTab, setImportTab] = useState<"mafile" | "logpass" | "token">(
"mafile",
);
@@ -159,16 +167,17 @@ export function ValidationSettings() {
const addToast = useUiStore((s) => s.addToast);
useEffect(() => {
api
.getValidationSettings()
.then(setSettings)
.catch(() => {});
api.getValidationSettings().then(setSettings).catch(() => {});
api.getServicesSettings().then(setServices).catch(() => {});
}, []);
const save = async () => {
try {
await api.updateValidationSettings(settings);
await loadImportSettings(); // sync stale uiStore values
await Promise.all([
api.updateValidationSettings(settings),
api.updateServicesSettings(services),
]);
await loadImportSettings();
addToast("success", t("toast.settingsSaved"));
} catch (e: unknown) {
addToast(
@@ -274,7 +283,7 @@ export function ValidationSettings() {
)}
</div>
<div className="px-5 pt-4 pb-3">
<div className="px-5 pt-4 pb-3 border-b border-dark-600">
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-0.5">
{t("settings.display")}
</p>
@@ -285,6 +294,26 @@ export function ValidationSettings() {
/>
</div>
<div className="px-5 pt-4 pb-3">
<p className="text-[10px] font-semibold text-gray-500 uppercase tracking-widest mb-0.5">
{t("tools.servicesSettings")}
</p>
<NumericStepper
label={t("tools.autoAcceptInterval")}
value={services.auto_accept_interval}
onChange={(v) => setServices((s) => ({ ...s, auto_accept_interval: v }))}
min={5}
steps={[-15, -10, -5, 5, 10, 15]}
/>
<NumericStepper
label={t("tools.autoConfirmInterval")}
value={services.auto_confirm_interval}
onChange={(v) => setServices((s) => ({ ...s, auto_confirm_interval: v }))}
min={5}
steps={[-15, -10, -5, 5, 10, 15]}
/>
</div>
<div className="px-5 py-3 border-t border-dark-600 flex justify-end">
<button onClick={save} className="btn-primary">
{t("btn.save")}
+20
View File
@@ -60,6 +60,8 @@ const translations: Translations = {
"col.phone": "Телефон",
"col.status": "Статус",
"col.ban": "Бан",
"col.vac": "VAC",
"col.limit": "Лимит",
"col.twofa": "2FA",
"col.mafile": "Mafile",
"col.proxy": "Прокси",
@@ -70,6 +72,8 @@ const translations: Translations = {
"col.behavior": "Behavior",
"col.license": "Лицензии",
"col.token": "Токен",
"col.balance": "Баланс",
"col.country": "Страна",
// -- Actions --
"action.selectAction": "— Действие",
"action.validate": "Валидация",
@@ -83,6 +87,9 @@ const translations: Translations = {
"action.enableAutoAccept": "Вкл. автопринятие",
"action.disableAutoAccept": "Выкл. авто-принятие входа",
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
"action.enableAutoConfirm": "Вкл. авто-подтверждения",
"action.disableAutoConfirm": "Выкл. авто-подтверждения",
"tip.autoConfirmActive": "Авто-подтверждения включены",
"action.generate2fa": "Сгенерировать 2FA код",
"action.confirmations": "Подтверждения",
// -- Action prompts --
@@ -284,6 +291,9 @@ const translations: Translations = {
"tools.importTabToken": "Token",
"tools.clickCopy": "Клик для копирования",
"tools.genErrorStr": "Ошибка",
"tools.servicesSettings": "Фоновые сервисы",
"tools.autoAcceptInterval": "Интервал auto-accept (сек):",
"tools.autoConfirmInterval": "Интервал auto-confirm (сек):",
// -- Logs panel --
"logs.title": "Лог",
"logs.clear": "Очистить",
@@ -372,6 +382,8 @@ const translations: Translations = {
"col.phone": "Phone",
"col.status": "Status",
"col.ban": "Ban",
"col.vac": "VAC",
"col.limit": "Limit",
"col.twofa": "2FA",
"col.mafile": "Mafile",
"col.proxy": "Proxy",
@@ -382,6 +394,8 @@ const translations: Translations = {
"col.behavior": "Behavior",
"col.license": "Licenses",
"col.token": "Token",
"col.balance": "Balance",
"col.country": "Country",
// -- Actions --
"action.selectAction": "— Action",
"action.validate": "Validate",
@@ -395,6 +409,9 @@ const translations: Translations = {
"action.enableAutoAccept": "Enable auto-accept",
"action.disableAutoAccept": "Disable login auto-accept",
"action.enableAutoAcceptLogin": "Enable login auto-accept",
"action.enableAutoConfirm": "Enable auto-confirm",
"action.disableAutoConfirm": "Disable auto-confirm",
"tip.autoConfirmActive": "Auto-confirm active",
"action.generate2fa": "Generate 2FA code",
"action.confirmations": "Confirmations",
// -- Action prompts --
@@ -594,6 +611,9 @@ const translations: Translations = {
"tools.importTabToken": "Token",
"tools.clickCopy": "Click to copy",
"tools.genErrorStr": "Error",
"tools.servicesSettings": "Background Services",
"tools.autoAcceptInterval": "Auto-accept interval (sec):",
"tools.autoConfirmInterval": "Auto-confirm interval (sec):",
// -- Logs panel --
"logs.title": "Log",
"logs.clear": "Clear",
+1
View File
@@ -3,6 +3,7 @@ import type { ReactNode, ErrorInfo } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
import "flag-icons/css/flag-icons.min.css";
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null };
+13
View File
@@ -24,12 +24,16 @@ const DEFAULT_COLUMNS: ColumnSettings = {
phone: true,
status: true,
ban: true,
vac: true,
limit: true,
twofa: true,
mafile: true,
proxy: true,
notes: true,
actions: true,
last_online: true,
balance: true,
country: true,
};
const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
@@ -41,6 +45,8 @@ const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
login_pass: true,
status: true,
ban: true,
vac: true,
limit: true,
prime: true,
trophy: true,
behavior: true,
@@ -49,6 +55,8 @@ const DEFAULT_LOGPASS_COLUMNS: LogpassColumnSettings = {
notes: true,
actions: true,
last_online: true,
balance: true,
country: true,
};
const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
@@ -59,9 +67,14 @@ const DEFAULT_TOKEN_COLUMNS: TokenColumnSettings = {
login: true,
token: true,
status: true,
ban: true,
vac: true,
limit: true,
proxy: true,
notes: true,
actions: true,
balance: true,
country: true,
};
interface UiState {