This commit is contained in:
Manchik
2026-04-17 22:22:40 +03:00
commit c82866b58a
135 changed files with 18493 additions and 0 deletions
+36
View File
@@ -0,0 +1,36 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.egg-info/
*.egg
dist/
build/
venv/
.venv/
docs/
# Frontend
frontend/node_modules/
# app/static/assets/
# app/static/index.html
# User data — DB, mafiles, logs, settings
data/
# IDE / tools
.github/
.vscode/
.history/
.serena/
.idea/
*.swp
*.swo
*~
.DS_Store
Thumbs.db
# Misc
example/
*.log
+97
View File
@@ -0,0 +1,97 @@
![SteamPanel](assets/steampanel_ru.png)
# SteamPanel
> [English version](assets/README_EN.md) | **Автор:** [@lolzdm](https://t.me/lolzdm)
Self-hosted веб-панель для массового управления Steam-аккаунтами. Backend на FastAPI + SQLite, фронт на React. Все операции выполняются пачками, прокси поддерживаются, прогресс задач в реальном времени через SSE.
Поддерживает **3 типа аккаунтов**:
- **Mafile** — полные аккаунты с секретами (shared_secret, identity_secret)
- **Log:Pass** — логин + пароль
- **Token** — access/refresh токены *(в разработке, не работает)*
---
## Функционал
**Аккаунты**
- Импорт из `.txt`: `login:pass`, `login:pass:email:email_pass`, с mafile — любой формат
- Bulk-операции на выбранных или всех аккаунтах
- Поиск по логину, Steam ID, заметкам, уровню (`lvl>10`)
- Настраиваемые колонки для каждой вкладки
**Действия со Steam**
- Смена пароля (ручной / рандомный)
- Смена email
- Смена / удаление телефона
- Удаление Steam Guard
- Авто-подтверждение входов (accept logins)
- Открытие Steam в браузере через [NoDriver](https://github.com/ultrafunkamsterdam/nodriver) (Chrome автоматизация)
**Валидация**
- Проверка логина, банов, парсинг профиля (никнейм, аватар, уровень, последний онлайн)
- Настройка кол-ва потоков
**Прокси**
- HTTP / SOCKS5
- Форматы: `login:pass@ip:port`, `ip:port:login:pass` и др. + кастомный конструктор
- Проверка (alive/dead), round-robin назначение, очистка
**Mafile**
- Mafile'ы привязываются к аккаунтам через раздел **Аккаунты**
- Экспорт в ZIP: выбор конкретных полей mafile и Session-блока
- Форматы экспорта: **flat** (все файлы в корне), **per-folder** (папка на аккаунт), **single JSON** (один файл)
- Шаблоны имён файлов с переменными `{username}`, `{steamid}`
- Генерация `.txt`: глобальный `accounts.txt`, отдельный `.txt` в каждой папке, кастомный формат строки
- Доступные переменные: `{login}`, `{password}`, `{email}`, `{email_password}`, `{steam_id}`, `{proxy}`, `{mafile}`
- Опция «не создавать папку для каждого аккаунта» при включённом глобальном `.txt`
**Инструменты**
- Генератор 2FA кодов по `shared_secret` — результат копируется по клику
- Настройки валидации: включение/отключение парсинга профиля (никнейм/аватар), проверки банов, количество потоков (1–50)
- Режим скрытия паролей (spoiler-режим)
---
## Установка
### Способ 1 — `run.bat` (Windows, рекомендуется)
Необходимо скачать архив, распаковать его и запустить `run.bat`.
Скрипт выполнит следующие действия автоматически:
1. Проверит наличие Python в системе
2. Создаст виртуальное окружение `venv`
3. Установит все необходимые зависимости
4. Запустит main.py
**Требования:** Python 3.14 — [python.org](https://python.org)
---
### Способ 2 — вручную
```bash
git clone https://github.com/LOLZ-dev/SteamPanel.git
cd SteamPanel
python -m venv venv
venv\scripts\activate
pip install -r requirements.txt
python main.py
```
После запуска панель будет доступна по адресу `http://127.0.0.1:8000`.
> Chrome/Chromium требуется только для функции «Открыть в браузере».
---
## Лицензия
MIT — **by [@lolzdm](https://t.me/lolzdm)**
View File
View File
+348
View File
@@ -0,0 +1,348 @@
import json
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from loguru import logger
from app.database import get_db
from app.models import (
AccountCreate,
AccountOut,
AccountUpdate,
BulkImportResult,
MafileData,
)
from app.config import settings
router = APIRouter(prefix="/api/accounts", tags=["accounts"])
@router.get("", response_model=list[AccountOut])
async def list_accounts():
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts ORDER BY id DESC")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@router.get("/{account_id}", response_model=AccountOut)
async def get_account(account_id: int):
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.post("", response_model=AccountOut, status_code=201)
async def create_account(account: AccountCreate):
db = await get_db()
cursor = await db.execute(
"""INSERT INTO accounts (login, password, steam_id, email, email_password, phone, proxy, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)""",
(
account.login,
account.password,
account.steam_id,
account.email,
account.email_password,
account.phone,
account.proxy,
account.notes,
),
)
await db.commit()
new_cursor = await db.execute(
"SELECT * FROM accounts WHERE id = ?", (cursor.lastrowid,)
)
return dict(await new_cursor.fetchone())
@router.put("/{account_id}", response_model=AccountOut)
async def update_account(account_id: int, account: AccountUpdate):
db = await get_db()
fields = {k: v for k, v in account.model_dump().items() if v is not None}
# Allow clearing fields by sending empty string → store as NULL
for k, v in account.model_dump().items():
if v == "" and k not in fields:
fields[k] = None
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields["updated_at"] = "datetime('now')"
set_clause = ", ".join(
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
for k in fields
)
values = [v for k, v in fields.items() if k != "updated_at"]
values.append(account_id)
await db.execute(
f"UPDATE accounts SET {set_clause} WHERE id = ?",
values,
)
await db.commit()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.delete("/{account_id}", status_code=204)
async def delete_account(account_id: int):
db = await get_db()
cursor = await db.execute("SELECT mafile_path FROM accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
if row["mafile_path"]:
mafile = Path(row["mafile_path"])
if mafile.exists():
mafile.unlink()
await db.execute("DELETE FROM accounts WHERE id = ?", (account_id,))
await db.commit()
@router.post("/delete-bulk", status_code=200)
async def delete_accounts_bulk(request: dict):
"""Delete multiple accounts by IDs list, or all if ids is empty."""
db = await get_db()
ids = request.get("ids", [])
if ids:
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT id, mafile_path FROM accounts WHERE id IN ({placeholders})", ids
)
else:
cursor = await db.execute("SELECT id, mafile_path FROM accounts")
rows = await cursor.fetchall()
count = 0
for row in rows:
if row["mafile_path"]:
mafile = Path(row["mafile_path"])
if mafile.exists():
mafile.unlink()
count += 1
if ids:
placeholders = ",".join("?" for _ in ids)
await db.execute(f"DELETE FROM accounts WHERE id IN ({placeholders})", ids)
else:
await db.execute("DELETE FROM accounts")
await db.commit()
return {"deleted": count}
@router.post("/assign-proxies")
async def assign_proxies_round_robin():
"""Round-robin assign proxies to accounts that don't have one."""
db = await get_db()
proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
proxies = [dict(r) for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute("SELECT id FROM accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id")
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
proxy = proxies[i % len(proxies)]
await db.execute(
"UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxy["address"], acc_id),
)
await db.commit()
from app.core.proxy_manager import proxy_manager
await proxy_manager.load()
logger.info(f"Assigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)")
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/reassign-proxies")
async def reassign_proxies_round_robin():
"""Round-robin reassign proxies to ALL accounts, overwriting existing."""
db = await get_db()
proxy_cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
proxies = [dict(r) for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute("SELECT id FROM accounts ORDER BY id")
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
proxy = proxies[i % len(proxies)]
await db.execute(
"UPDATE accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxy["address"], acc_id),
)
await db.commit()
from app.core.proxy_manager import proxy_manager
await proxy_manager.load()
logger.info(f"Reassigned {len(proxies)} proxies to {len(account_ids)} accounts (round-robin)")
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/clear-proxies")
async def clear_all_proxies():
"""Remove proxy assignment from all accounts."""
db = await get_db()
cursor = await db.execute("UPDATE accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''")
await db.commit()
count = cursor.rowcount
from app.core.proxy_manager import proxy_manager
await proxy_manager.load()
logger.info(f"Cleared proxies from {count} accounts")
return {"cleared": count}
@router.post("/import", response_model=BulkImportResult)
async def import_accounts(file: UploadFile = File(...)):
"""Import accounts.
Supported formats (: or | separator):
login|password|{mafile_json}
login|password|email|email_password|{mafile_json}
login|password|email|email_password
"""
content = (await file.read()).decode("utf-8", errors="ignore")
result = BulkImportResult()
db = await get_db()
for raw_line in content.strip().splitlines():
line = raw_line.strip()
if not line:
continue
# Split off embedded mafile JSON (everything from first '{' onward)
json_start = line.find("{")
if json_start != -1:
mafile_str = line[json_start:]
prefix = line[:json_start].rstrip("|:")
else:
mafile_str = None
prefix = line
sep = "|" if "|" in prefix else ":"
parts = prefix.split(sep)
if mafile_str:
# Accepted: 2 fields (login|pass) or 4 fields (login|pass|email|email_pass)
if len(parts) == 2:
login, password = parts
email = email_password = None
elif len(parts) == 4:
login, password, email, email_password = parts
else:
result.errors.append(f"Invalid prefix (need 2 or 4 fields): {line[:50]}")
result.skipped += 1
continue
try:
mafile_data = json.loads(mafile_str)
except json.JSONDecodeError as exc:
result.errors.append(f"Bad mafile JSON on line starting '{prefix[:30]}': {exc}")
result.skipped += 1
continue
# Fall back to mail fields embedded in the mafile JSON itself
if not email:
email = mafile_data.get("mail") or mafile_data.get("email") or None
email_password = mafile_data.get("mail_password") or mafile_data.get("email_password") or None
else:
# No mafile — must be exactly 4 fields
if len(parts) != 4:
result.errors.append(f"Invalid line (need 4 fields): {line[:50]}")
result.skipped += 1
continue
login, password, email, email_password = parts
mafile_data = None
try:
# Check if account with this login already exists
existing = await db.execute(
"SELECT id FROM accounts WHERE login = ?", (login,)
)
if await existing.fetchone():
result.errors.append(f"{login}: уже существует")
result.skipped += 1
continue
await db.execute(
"""INSERT INTO accounts (login, password, email, email_password)
VALUES (?, ?, ?, ?)""",
(login, password, email or None, email_password or None),
)
result.imported += 1
except Exception as exc:
result.errors.append(f"{login}: {exc}")
result.skipped += 1
continue
if mafile_data:
account_name = mafile_data.get("account_name") or login
shared_secret = mafile_data.get("shared_secret") or ""
identity_secret = mafile_data.get("identity_secret") or ""
steam_id = (mafile_data.get("Session") or {}).get("SteamID") or None
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
dest = settings.mafiles_dir / f"{account_name}.mafile"
dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8")
await db.execute(
"""UPDATE accounts
SET mafile_path = ?, shared_secret = ?, identity_secret = ?, steam_id = ?
WHERE login = ? AND steam_id IS NULL""",
(str(dest), shared_secret, identity_secret, steam_id, login),
)
await db.commit()
logger.info(f"Imported {result.imported} accounts, skipped {result.skipped}")
return result
@router.post("/{account_id}/mafile")
async def upload_mafile(account_id: int, file: UploadFile = File(...)):
"""Bind a .mafile to an account."""
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
content = (await file.read()).decode("utf-8")
try:
mafile = MafileData.model_validate_json(content)
except Exception:
raise HTTPException(status_code=400, detail="Invalid mafile format")
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
steam_id = mafile.Session.SteamID or row["steam_id"] or account_id
mafile_path = settings.mafiles_dir / f"{steam_id}.mafile"
mafile_path.write_text(content, encoding="utf-8")
await db.execute(
"""UPDATE accounts SET mafile_path = ?, shared_secret = ?, identity_secret = ?,
steam_id = COALESCE(steam_id, ?), updated_at = datetime('now') WHERE id = ?""",
(
str(mafile_path),
mafile.shared_secret,
mafile.identity_secret,
str(mafile.Session.SteamID) if mafile.Session.SteamID else None,
account_id,
),
)
await db.commit()
return {"status": "ok", "mafile_path": str(mafile_path)}
+86
View File
@@ -0,0 +1,86 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from loguru import logger
from app.database import get_db
from app.models import ActionRequest
from app.core.task_manager import task_manager
router = APIRouter(prefix="/api/actions", tags=["actions"])
VALID_ACTIONS = {
"change_password",
"random_password",
"change_email",
"change_phone",
"remove_guard",
"generate_2fa",
"validate",
"add_friend",
"accept_logins",
"change_language",
}
@router.post("")
async def execute_action(request: ActionRequest):
if request.action not in VALID_ACTIONS:
raise HTTPException(
status_code=400,
detail=f"Invalid action. Valid: {', '.join(sorted(VALID_ACTIONS))}",
)
db = await get_db()
placeholders = ",".join("?" for _ in request.account_ids)
cursor = await db.execute(
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
request.account_ids,
)
accounts = [dict(r) for r in await cursor.fetchall()]
if not accounts:
raise HTTPException(status_code=404, detail="No accounts found")
task_id = await task_manager.submit(
task_type=request.action,
accounts=accounts,
params=request.params or {},
)
logger.info(
f"Action '{request.action}' submitted for {len(accounts)} accounts → task {task_id}"
)
return {"task_id": task_id, "accounts_count": len(accounts)}
class Generate2FARequest(BaseModel):
shared_secret: str
class Generate2FAByAccountRequest(BaseModel):
account_id: int
@router.post("/generate-2fa")
async def generate_2fa_code(request: Generate2FARequest):
"""Server-side 2FA code generation from shared_secret."""
from app.services.steam_guard import generate_2fa_code as gen_code
code = gen_code(request.shared_secret)
return {"code": code}
@router.post("/generate-2fa-by-account")
async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
"""Generate 2FA code for an account using its stored shared_secret."""
from app.services.steam_guard import generate_2fa_code as gen_code
db = await get_db()
cursor = await db.execute(
"SELECT shared_secret FROM accounts WHERE id = ?",
(request.account_id,),
)
row = await cursor.fetchone()
if not row or not row["shared_secret"]:
raise HTTPException(status_code=404, detail="Account not found or has no shared_secret")
return {"code": gen_code(row["shared_secret"])}
+49
View File
@@ -0,0 +1,49 @@
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.database import get_db
from app.core.auto_accept import auto_accept_manager
router = APIRouter(prefix="/api/auto-accept", tags=["auto-accept"])
class AutoAcceptRequest(BaseModel):
account_ids: list[int]
@router.post("/start")
async def start_auto_accept(body: AutoAcceptRequest):
db = await get_db()
started: list[int] = []
for aid in body.account_ids:
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (aid,))
row = await cursor.fetchone()
if not row:
raise HTTPException(404, f"Account {aid} not found")
account = dict(row)
if not account.get("shared_secret"):
raise HTTPException(400, f"Account {account['login']} has no shared_secret (mafile)")
await auto_accept_manager.start(account)
await db.execute("UPDATE accounts SET auto_accept = 1 WHERE id = ?", (aid,))
started.append(aid)
await db.commit()
return {"started": started}
@router.post("/stop")
async def stop_auto_accept(body: AutoAcceptRequest):
db = await get_db()
stopped: list[int] = []
for aid in body.account_ids:
auto_accept_manager.stop(aid)
await db.execute("UPDATE accounts SET auto_accept = 0 WHERE id = ?", (aid,))
stopped.append(aid)
await db.commit()
return {"stopped": stopped}
@router.get("/status")
async def auto_accept_status():
running = auto_accept_manager.running_ids()
errors = auto_accept_manager.pop_errors()
return {"running": list(running), "errors": errors}
+65
View File
@@ -0,0 +1,65 @@
import asyncio
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.database import get_db
from app.config import read_validation_settings
from app.core.task_manager import task_manager
router = APIRouter(prefix="/api", tags=["browser"])
@router.post("/accounts/{account_id}/browser")
async def open_browser(account_id: int):
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(404, "Account not found")
account = dict(row)
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
from app.config import read_validation_settings
# If account isn't validated or has no cookies — auto-revalidate if enabled
if account["status"] != "valid" or not account.get("session_cookies"):
val_settings = read_validation_settings()
if val_settings.get("auto_revalidate_browser"):
task_id = await task_manager.submit(
task_type="validate",
accounts=[account],
params={},
)
logger.info(f"Account {account['login']} not valid/no cookies, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
if account["status"] != "valid":
raise HTTPException(400, "Account is not validated")
raise HTTPException(400, "No session cookies. Validate the account first.")
proxy = await _resolve_proxy(account)
alive = await check_cookies_alive(account["session_cookies"], proxy)
if not alive:
val_settings = read_validation_settings()
if val_settings.get("auto_revalidate_browser"):
task_id = await task_manager.submit(
task_type="validate",
accounts=[account],
params={},
)
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
raise HTTPException(400, "Session cookies expired. Re-validate the account.")
from app.services.browser_login import open_browser_with_cookies
async def _run():
try:
await open_browser_with_cookies(account)
except Exception as exc:
logger.error(f"Browser open failed for {account['login']}: {exc}")
asyncio.create_task(_run())
return {"status": "ok", "message": "Browser opening..."}
+356
View File
@@ -0,0 +1,356 @@
"""CRUD and validate endpoints for log:pass accounts."""
import csv
import ctypes
import io
import sys
csv.field_size_limit(min(sys.maxsize, ctypes.c_ulong(-1).value // 2))
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.database import get_db
from app.models import (
LogpassAccountCreate,
LogpassAccountOut,
LogpassAccountUpdate,
)
from app.core.task_manager import task_manager
router = APIRouter(prefix="/api/logpass", tags=["logpass"])
@router.get("", response_model=list[LogpassAccountOut])
async def list_logpass():
db = await get_db()
cursor = await db.execute("SELECT * FROM logpass_accounts ORDER BY id DESC")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@router.get("/{account_id}", response_model=LogpassAccountOut)
async def get_logpass(account_id: int):
db = await get_db()
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.post("", response_model=LogpassAccountOut, status_code=201)
async def create_logpass(account: LogpassAccountCreate):
db = await get_db()
cursor = await db.execute(
"INSERT INTO logpass_accounts (login, password, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
(account.login, account.password, account.steam_id, account.proxy, account.notes),
)
await db.commit()
new_cursor = await db.execute(
"SELECT * FROM logpass_accounts WHERE id = ?", (cursor.lastrowid,)
)
return dict(await new_cursor.fetchone())
@router.put("/{account_id}", response_model=LogpassAccountOut)
async def update_logpass(account_id: int, account: LogpassAccountUpdate):
db = await get_db()
fields = {k: v for k, v in account.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields["updated_at"] = "datetime('now')"
set_clause = ", ".join(
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
for k in fields
)
values = [v for k, v in fields.items() if k != "updated_at"]
values.append(account_id)
await db.execute(
f"UPDATE logpass_accounts SET {set_clause} WHERE id = ?", values
)
await db.commit()
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.delete("/{account_id}", status_code=204)
async def delete_logpass(account_id: int):
db = await get_db()
await db.execute("DELETE FROM logpass_accounts WHERE id = ?", (account_id,))
await db.commit()
@router.post("/delete-bulk")
async def delete_logpass_bulk(data: dict):
ids: list[int] = data.get("ids", [])
if not ids:
return {"deleted": 0}
db = await get_db()
placeholders = ",".join("?" for _ in ids)
await db.execute(f"DELETE FROM logpass_accounts WHERE id IN ({placeholders})", ids)
await db.commit()
return {"deleted": len(ids)}
@router.post("/import")
async def import_logpass(data: dict):
"""Bulk import from plain text (login:pass / login|pass) or CSV with headers."""
lines: list[str] = data.get("lines", [])
if not lines:
return {"imported": 0, "skipped": 0, "errors": []}
imported = 0
skipped = 0
errors: list[str] = []
db = await get_db()
async def upsert(login: str, fields: dict):
"""Insert or update by login, works with or without UNIQUE index."""
cursor = await db.execute(
"SELECT id FROM logpass_accounts WHERE login = ?", (login,)
)
existing = await cursor.fetchone()
if existing:
sets = ", ".join(f"{k} = ?" for k in fields)
vals = list(fields.values()) + [existing["id"]]
await db.execute(
f"UPDATE logpass_accounts SET {sets}, updated_at = datetime('now') WHERE id = ?",
vals,
)
else:
fields["login"] = login
cols = ", ".join(fields.keys())
placeholders = ", ".join("?" for _ in fields)
await db.execute(
f"INSERT INTO logpass_accounts ({cols}) VALUES ({placeholders})",
list(fields.values()),
)
# Detect CSV: first line looks like a header row
first = lines[0].strip().lower()
is_csv = first.startswith("login,") or first.startswith("login;")
try:
if is_csv:
text = "\n".join(lines)
reader = csv.DictReader(io.StringIO(text))
for row in reader:
login = (row.get("login") or "").strip()
password = (row.get("password") or "").strip()
if not login or not password:
skipped += 1
continue
fields = {"password": password}
_NA = {"n/a", "na", "none", "null", ""}
for csv_col, db_col in [
("steam_id", "steam_id"), ("ban", "ban_status"),
("prime", "prime"), ("trophy", "trophy"),
("behavior", "behavior"), ("license", "license"),
]:
val = (row.get(csv_col) or "").strip()
if val:
if db_col in ("prime", "trophy", "behavior") and val.lower() in _NA:
val = "\u2014"
fields[db_col] = val
try:
await upsert(login, fields)
imported += 1
except Exception as exc:
logger.error(f"Logpass CSV import error for '{login}': {exc}")
errors.append(f"{login}: {exc}")
else:
for raw in lines:
line = raw.strip()
if not line:
skipped += 1
continue
# Skip JSON/mafile lines
if line.startswith("{") or line.startswith("["):
skipped += 1
continue
if "|" in line:
parts = line.split("|", 1)
elif ":" in line:
parts = line.split(":", 1)
else:
skipped += 1
continue
login, password = parts[0].strip(), parts[1].strip()
if not login or not password:
skipped += 1
continue
# Reject if password looks like JSON (mafile embedded) or is too long
if password.startswith("{") or password.startswith("[") or len(password) > 128:
skipped += 1
logger.warning(f"Logpass import: skipped '{login}' — password looks like mafile or is too long ({len(password)} chars)")
continue
try:
await upsert(login, {"password": password})
imported += 1
except Exception as exc:
logger.error(f"Logpass import error for '{login}': {exc}")
errors.append(f"{login}: {exc}")
await db.commit()
except Exception as exc:
logger.exception(f"Logpass import failed: {exc}")
raise HTTPException(status_code=500, detail=str(exc))
return {"imported": imported, "skipped": skipped, "errors": errors}
@router.post("/validate")
async def validate_logpass(data: dict):
"""Submit validation task for selected log:pass accounts."""
ids: list[int] = data.get("account_ids", [])
if not ids:
raise HTTPException(status_code=400, detail="No account IDs provided")
db = await get_db()
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
)
accounts = [dict(r) for r in await cursor.fetchall()]
if not accounts:
raise HTTPException(status_code=404, detail="No accounts found")
task_id = await task_manager.submit(
task_type="logpass_validate",
accounts=accounts,
params={},
)
logger.info(f"logpass_validate submitted for {len(accounts)} accounts → task {task_id}")
return {"task_id": task_id, "accounts_count": len(accounts)}
@router.post("/full-parse")
async def full_parse_logpass(data: dict):
"""Submit full parse task (prime, trophy, behavior, licenses) for selected accounts."""
ids: list[int] = data.get("account_ids", [])
if not ids:
raise HTTPException(status_code=400, detail="No account IDs provided")
db = await get_db()
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT * FROM logpass_accounts WHERE id IN ({placeholders})", ids
)
accounts = [dict(r) for r in await cursor.fetchall()]
if not accounts:
raise HTTPException(status_code=404, detail="No accounts found")
task_id = await task_manager.submit(
task_type="logpass_full_parse",
accounts=accounts,
params={},
)
logger.info(f"logpass_full_parse submitted for {len(accounts)} accounts → task {task_id}")
return {"task_id": task_id, "accounts_count": len(accounts)}
@router.post("/assign-proxies")
async def logpass_assign_proxies():
"""Round-robin assign proxies to logpass accounts that don't have one."""
db = await get_db()
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute(
"SELECT id FROM logpass_accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id"
)
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
await db.execute(
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxies[i % len(proxies)], acc_id),
)
await db.commit()
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/reassign-proxies")
async def logpass_reassign_proxies():
"""Round-robin reassign proxies to ALL logpass accounts."""
db = await get_db()
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute("SELECT id FROM logpass_accounts ORDER BY id")
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
await db.execute(
"UPDATE logpass_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxies[i % len(proxies)], acc_id),
)
await db.commit()
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/clear-proxies")
async def logpass_clear_proxies():
"""Remove proxy from all logpass accounts."""
db = await get_db()
cursor = await db.execute(
"UPDATE logpass_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
)
await db.commit()
return {"cleared": cursor.rowcount}
@router.post("/{account_id}/browser")
async def open_logpass_browser(account_id: int):
"""Open Chrome browser with saved session cookies for a logpass account."""
import asyncio
db = await get_db()
cursor = await db.execute("SELECT * FROM logpass_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
account = dict(row)
if not account.get("session_cookies"):
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
from app.config import read_validation_settings
proxy = await _resolve_proxy(account)
alive = await check_cookies_alive(account["session_cookies"], proxy)
if not alive:
val_settings = read_validation_settings()
if val_settings.get("auto_revalidate_browser"):
task_id = await task_manager.submit(
task_type="logpass_validate",
accounts=[account],
params={},
)
logger.info(f"Cookies dead for {account['login']}, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
from app.services.browser_login import open_browser_with_cookies
async def _run():
try:
await open_browser_with_cookies(account)
except Exception as exc:
logger.error(f"Browser open failed for {account['login']}: {exc}")
asyncio.create_task(_run())
return {"status": "ok", "message": "Browser opening..."}
+67
View File
@@ -0,0 +1,67 @@
import asyncio
import json
from collections import deque
from typing import Any
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from loguru import logger
router = APIRouter(prefix="/api/logs", tags=["logs"])
_LOG_BUFFER: deque[dict[str, Any]] = deque(maxlen=500)
_SUBSCRIBERS: list[asyncio.Queue] = []
LEVEL_ICONS = {
"TRACE": "~",
"DEBUG": ".",
"INFO": "i",
"SUCCESS": "+",
"WARNING": "!",
"ERROR": "e",
"CRITICAL": "e",
}
def _log_sink(message: Any) -> None:
record = message.record
entry = {
"ts": record["time"].strftime("%H:%M:%S"),
"level": record["level"].name.lower(),
"icon": LEVEL_ICONS.get(record["level"].name, "i"),
"msg": record["message"],
}
_LOG_BUFFER.append(entry)
for q in _SUBSCRIBERS[:]:
try:
q.put_nowait(entry)
except asyncio.QueueFull:
pass
def install_log_sink() -> None:
logger.add(_log_sink, format="{message}", level="DEBUG", colorize=False)
@router.get("")
async def get_logs():
return list(_LOG_BUFFER)
@router.get("/stream")
async def stream_logs():
queue: asyncio.Queue = asyncio.Queue(maxsize=200)
_SUBSCRIBERS.append(queue)
async def event_generator():
try:
while True:
entry = await queue.get()
yield f"data: {json.dumps(entry, ensure_ascii=False)}\n\n"
except asyncio.CancelledError:
pass
finally:
if queue in _SUBSCRIBERS:
_SUBSCRIBERS.remove(queue)
return StreamingResponse(event_generator(), media_type="text/event-stream")
+275
View File
@@ -0,0 +1,275 @@
import json
import io
import zipfile
from pathlib import Path
from fastapi import APIRouter, HTTPException, UploadFile, File
from fastapi.responses import StreamingResponse
from loguru import logger
from pydantic import BaseModel
from app.config import settings
from app.database import get_db
from app.models import MafileData
router = APIRouter(prefix="/api/mafiles", tags=["mafile_tools"])
@router.get("")
async def list_mafiles():
"""List all stored mafiles."""
mafiles_dir = settings.mafiles_dir
if not mafiles_dir.exists():
return []
result = []
for f in sorted(mafiles_dir.glob("*.mafile")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
result.append({
"filename": f.name,
"account_name": data.get("account_name", ""),
"steam_id": data.get("Session", {}).get("SteamID", ""),
"has_shared_secret": bool(data.get("shared_secret")),
"has_identity_secret": bool(data.get("identity_secret")),
"fully_enrolled": data.get("fully_enrolled", False),
})
except (json.JSONDecodeError, OSError):
result.append({"filename": f.name, "error": "Failed to parse"})
return result
@router.post("/upload")
async def upload_mafiles(files: list[UploadFile] = File(...)):
"""Upload multiple .mafile files."""
settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
uploaded = 0
errors: list[str] = []
db = await get_db()
for file in files:
content = (await file.read()).decode("utf-8", errors="ignore")
try:
mafile = MafileData.model_validate_json(content)
steam_id = mafile.Session.SteamID or mafile.account_name or file.filename
dest = settings.mafiles_dir / f"{steam_id}.mafile"
dest.write_text(content, encoding="utf-8")
uploaded += 1
if mafile.account_name:
await db.execute(
"""UPDATE accounts
SET mafile_path = ?, shared_secret = ?, identity_secret = ?
WHERE login = ?""",
(
str(dest),
mafile.shared_secret or "",
mafile.identity_secret or "",
mafile.account_name,
),
)
await db.commit()
except Exception as exc:
errors.append(f"{file.filename}: {exc}")
logger.info(f"Uploaded {uploaded} mafiles, {len(errors)} errors")
return {"uploaded": uploaded, "errors": errors}
@router.get("/{filename}")
async def get_mafile(filename: str):
"""Get parsed mafile data by filename."""
safe_name = Path(filename).name
mafile_path = settings.mafiles_dir / safe_name
if not mafile_path.exists() or not mafile_path.suffix == ".mafile":
raise HTTPException(status_code=404, detail="Mafile not found")
content = mafile_path.read_text(encoding="utf-8")
try:
return json.loads(content)
except json.JSONDecodeError:
raise HTTPException(status_code=500, detail="Failed to parse mafile")
@router.delete("/{filename}", status_code=204)
async def delete_mafile(filename: str):
safe_name = Path(filename).name
mafile_path = settings.mafiles_dir / safe_name
if not mafile_path.exists():
raise HTTPException(status_code=404, detail="Mafile not found")
mafile_path.unlink()
@router.get("/export/all")
async def export_all_secrets():
"""Export account_name:shared_secret:identity_secret for all mafiles."""
mafiles_dir = settings.mafiles_dir
if not mafiles_dir.exists():
return []
result = []
for f in sorted(mafiles_dir.glob("*.mafile")):
try:
data = json.loads(f.read_text(encoding="utf-8"))
result.append({
"account_name": data.get("account_name", ""),
"shared_secret": data.get("shared_secret", ""),
"identity_secret": data.get("identity_secret", ""),
"steam_id": data.get("Session", {}).get("SteamID", ""),
})
except (json.JSONDecodeError, OSError):
pass
return result
class ExportRequest(BaseModel):
fields: list[str] = []
session_fields: list[str] = []
format: str = "flat_mafiles" # flat_mafiles | per_account_folder | single_file
account_ids: list[int] = []
# Naming variables: {username} and {steamid} are replaced at export time
folder_name_template: str = "{username}"
mafile_name_template: str = "{steamid}.mafile"
txt_name_template: str = "{username}.txt"
include_txt_per_folder: bool = False
include_global_txt: bool = False
skip_folders: bool = False
# Format for .txt lines: login:password:email:email_password
txt_format: str = "{login}:{password}:{email}:{email_password}"
def _filter_mafile(data: dict, fields: list[str], session_fields: list[str]) -> dict:
"""Keep only selected top-level + Session fields from mafile JSON."""
if not fields and not session_fields:
return data
filtered = {}
for key in fields:
if key in data and key != "Session":
filtered[key] = data[key]
if session_fields and "Session" in data:
filtered["Session"] = {
k: v for k, v in data["Session"].items() if k in session_fields
}
return filtered
@router.post("/export/zip")
async def export_mafiles_zip(body: ExportRequest):
"""Build a .zip with filtered mafile data, optionally limited to account IDs."""
if not body.account_ids:
raise HTTPException(status_code=400, detail="No account IDs provided for export")
mafiles_dir = settings.mafiles_dir
if not mafiles_dir.exists():
raise HTTPException(status_code=404, detail="No mafiles directory")
db = await get_db()
# Load account info for naming and .txt generation
account_map: dict[str, dict] = {} # login -> account row
if body.account_ids:
placeholders = ",".join("?" * len(body.account_ids))
cursor = await db.execute(
f"SELECT * FROM accounts WHERE id IN ({placeholders})",
body.account_ids,
)
else:
cursor = await db.execute("SELECT * FROM accounts")
for row in await cursor.fetchall():
account_map[row["login"]] = dict(row)
account_logins = set(account_map.keys()) if body.account_ids else set()
def _resolve_template(template: str, username: str, steam_id: str) -> str:
return template.replace("{username}", username).replace("{steamid}", steam_id)
def _resolve_txt_line(template: str, acc: dict, mafile_data: dict | None = None) -> str:
has_mafile_var = "{mafile}" in template
if has_mafile_var:
before, _, after = template.partition("{mafile}")
else:
before = template
line = (
before
.replace("{login}", acc.get("login") or "")
.replace("{password}", acc.get("password") or "")
.replace("{email}", acc.get("email") or "")
.replace("{email_password}", acc.get("email_password") or "")
.replace("{steam_id}", acc.get("steam_id") or "")
.replace("{proxy}", acc.get("proxy") or "")
)
line = line.rstrip(":")
if has_mafile_var and mafile_data is not None:
line += ":" + json.dumps(mafile_data, ensure_ascii=False, separators=(",", ":"))
return line
buf = io.BytesIO()
all_data: list[str] = []
global_txt_lines: list[str] = []
folder_txt_map: dict[str, list[str]] = {} # folder_name -> lines
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
for f in sorted(mafiles_dir.glob("*.mafile")):
try:
raw = json.loads(f.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
continue
acct_name = raw.get("account_name", "")
steam_id = str(raw.get("Session", {}).get("SteamID", ""))
if account_logins:
if acct_name not in account_logins and steam_id not in {str(a) for a in body.account_ids}:
continue
filtered = _filter_mafile(raw, body.fields, body.session_fields)
# Find matching account record for txt
acc = account_map.get(acct_name, {})
if body.format == "single_file":
if acc:
all_data.append(_resolve_txt_line(body.txt_format, acc, filtered))
else:
all_data.append(json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
elif body.format == "per_account_folder":
folder_name = _resolve_template(body.folder_name_template, acct_name, steam_id)
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
if body.skip_folders:
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
else:
zf.writestr(f"{folder_name}/{mafile_name}", json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
if body.include_txt_per_folder and acc:
txt_name = _resolve_template(body.txt_name_template, acct_name, steam_id)
line = _resolve_txt_line(body.txt_format, acc, filtered)
if folder_name not in folder_txt_map:
folder_txt_map[folder_name] = []
folder_txt_map[folder_name].append(line)
zf.writestr(f"{folder_name}/{txt_name}", line + "\n")
else:
mafile_name = _resolve_template(body.mafile_name_template, acct_name, steam_id)
zf.writestr(mafile_name, json.dumps(filtered, ensure_ascii=False, separators=(",", ":")))
# Collect global txt line
if body.include_global_txt and acc:
global_txt_lines.append(_resolve_txt_line(body.txt_format, acc, filtered))
if body.format == "single_file" and all_data:
zf.writestr("accounts.txt", "\n".join(all_data) + "\n")
if body.include_global_txt and global_txt_lines:
zf.writestr("accounts.txt", "\n".join(global_txt_lines) + "\n")
buf.seek(0)
return StreamingResponse(
buf,
media_type="application/zip",
headers={"Content-Disposition": "attachment; filename=mafiles_export.zip"},
)
+123
View File
@@ -0,0 +1,123 @@
import asyncio
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.database import get_db
from app.models import ProxyCreate, ProxyOut
router = APIRouter(prefix="/api/proxies", tags=["proxies"])
@router.get("", response_model=list[ProxyOut])
async def list_proxies():
db = await get_db()
cursor = await db.execute("SELECT * FROM proxies ORDER BY id")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@router.post("", response_model=ProxyOut, status_code=201)
async def add_proxy(proxy: ProxyCreate):
db = await get_db()
try:
cursor = await db.execute(
"INSERT INTO proxies (address, protocol) VALUES (?, ?)",
(proxy.address, proxy.protocol),
)
await db.commit()
except Exception:
raise HTTPException(status_code=409, detail="Proxy already exists")
new_cursor = await db.execute(
"SELECT * FROM proxies WHERE id = ?", (cursor.lastrowid,)
)
return dict(await new_cursor.fetchone())
@router.delete("/all", status_code=200)
async def delete_all_proxies():
db = await get_db()
cursor = await db.execute("SELECT COUNT(*) as cnt FROM proxies")
row = await cursor.fetchone()
count = row["cnt"]
await db.execute("DELETE FROM proxies")
await db.commit()
from app.core.proxy_manager import proxy_manager
await proxy_manager.load()
logger.info(f"Deleted all {count} proxies")
return {"deleted": count}
@router.delete("/{proxy_id}", status_code=204)
async def delete_proxy(proxy_id: int):
db = await get_db()
result = await db.execute("DELETE FROM proxies WHERE id = ?", (proxy_id,))
await db.commit()
if result.rowcount == 0:
raise HTTPException(status_code=404, detail="Proxy not found")
@router.post("/bulk")
async def bulk_add_proxies(proxies: list[ProxyCreate]):
db = await get_db()
added = 0
for p in proxies:
try:
await db.execute(
"INSERT OR IGNORE INTO proxies (address, protocol) VALUES (?, ?)",
(p.address, p.protocol),
)
added += 1
except Exception:
pass
await db.commit()
logger.info(f"Bulk added {added} proxies")
return {"added": added}
async def _check_one_proxy(proxy_id: int, address: str, protocol: str) -> dict:
"""Check proxy by making a real HTTP request through it."""
import aiohttp
from aiohttp_socks import ProxyConnector
from app.core.proxy_manager import build_proxy_url
try:
proxy_url = build_proxy_url(address, protocol)
connector = ProxyConnector.from_url(proxy_url, ssl=False)
async with aiohttp.ClientSession(connector=connector) as session:
async with session.get(
"https://steamcommunity.com/robots.txt",
timeout=aiohttp.ClientTimeout(total=3),
) as resp:
alive = resp.status == 200
except Exception:
alive = False
db = await get_db()
await db.execute(
"UPDATE proxies SET is_alive = ?, last_checked = datetime('now') WHERE id = ?",
(int(alive), proxy_id),
)
await db.commit()
return {"id": proxy_id, "alive": alive}
@router.post("/check")
async def check_proxies():
db = await get_db()
cursor = await db.execute("SELECT id, address, protocol FROM proxies ORDER BY id")
rows = await cursor.fetchall()
if not rows:
return {"total": 0, "alive": 0, "dead": 0}
sem = asyncio.Semaphore(300)
async def limited(row):
async with sem:
return await _check_one_proxy(row["id"], row["address"], row["protocol"])
results = await asyncio.gather(*(limited(r) for r in rows))
alive_count = sum(1 for r in results if r["alive"])
return {"total": len(results), "alive": alive_count, "dead": len(results) - alive_count}
+210
View File
@@ -0,0 +1,210 @@
import json
from pathlib import Path
from fastapi import APIRouter
from pydantic import BaseModel
from app.config import settings
router = APIRouter(prefix="/api/settings", tags=["settings"])
SETTINGS_FILE = settings.data_dir / "settings.json"
DEFAULTS = {
"validation": {
"fetch_profile": True,
"check_ban": True,
"max_threads": 5,
"auto_revalidate_browser": True,
},
"display": {
"hide_passwords": False,
},
"columns": {
"browser": True,
"profile": True,
"steam_id": True,
"login": True,
"password": True,
"login_pass": True,
"email": True,
"phone": True,
"status": True,
"ban": True,
"twofa": True,
"mafile": True,
"proxy": True,
"actions": True,
},
"logpass_columns": {
"browser": True,
"profile": True,
"steam_id": True,
"login": True,
"password": True,
"login_pass": True,
"status": True,
"ban": True,
"prime": True,
"trophy": True,
"behavior": True,
"license": True,
"proxy": True,
"notes": True,
"actions": True,
},
"token_columns": {
"browser": True,
"profile": True,
"last_online": True,
"steam_id": True,
"login": True,
"token": True,
"status": True,
"proxy": True,
"notes": True,
"actions": True,
},
}
def _read() -> dict:
if SETTINGS_FILE.exists():
return json.loads(SETTINGS_FILE.read_text("utf-8"))
return DEFAULTS.copy()
def _write(data: dict) -> None:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
SETTINGS_FILE.write_text(json.dumps(data, indent=2, ensure_ascii=False), "utf-8")
class ValidationSettings(BaseModel):
fetch_profile: bool = True
check_ban: bool = True
max_threads: int = 5
auto_revalidate_browser: bool = True
class DisplaySettings(BaseModel):
hide_passwords: bool = False
class ColumnSettings(BaseModel):
browser: bool = True
profile: bool = True
steam_id: bool = True
login: bool = True
password: bool = True
login_pass: bool = True
email: bool = True
phone: bool = True
status: bool = True
ban: bool = True
twofa: bool = True
mafile: bool = True
proxy: bool = True
actions: bool = True
last_online: bool = True
@router.get("/validation", response_model=ValidationSettings)
async def get_validation_settings():
data = _read()
return data.get("validation", DEFAULTS["validation"])
@router.put("/validation", response_model=ValidationSettings)
async def update_validation_settings(body: ValidationSettings):
data = _read()
data["validation"] = body.model_dump()
_write(data)
return data["validation"]
@router.get("/display", response_model=DisplaySettings)
async def get_display_settings():
data = _read()
return data.get("display", DEFAULTS["display"])
@router.put("/display", response_model=DisplaySettings)
async def update_display_settings(body: DisplaySettings):
data = _read()
data["display"] = body.model_dump()
_write(data)
return data["display"]
@router.get("/columns", response_model=ColumnSettings)
async def get_column_settings():
data = _read()
return data.get("columns", DEFAULTS["columns"])
@router.put("/columns", response_model=ColumnSettings)
async def update_column_settings(body: ColumnSettings):
data = _read()
data["columns"] = body.model_dump()
_write(data)
return data["columns"]
class LogpassColumnSettings(BaseModel):
browser: bool = True
profile: bool = True
steam_id: bool = True
login: bool = True
password: bool = True
login_pass: bool = True
status: bool = True
ban: bool = True
prime: bool = True
trophy: bool = True
behavior: bool = True
license: bool = True
proxy: bool = True
notes: bool = True
actions: bool = True
last_online: bool = True
@router.get("/logpass-columns", response_model=LogpassColumnSettings)
async def get_logpass_column_settings():
data = _read()
return data.get("logpass_columns", DEFAULTS["logpass_columns"])
@router.put("/logpass-columns", response_model=LogpassColumnSettings)
async def update_logpass_column_settings(body: LogpassColumnSettings):
data = _read()
data["logpass_columns"] = body.model_dump()
_write(data)
return data["logpass_columns"]
class TokenColumnSettings(BaseModel):
browser: bool = True
profile: bool = True
last_online: bool = True
steam_id: bool = True
login: bool = True
token: bool = True
status: bool = True
proxy: bool = True
notes: bool = True
actions: bool = True
@router.get("/token-columns", response_model=TokenColumnSettings)
async def get_token_column_settings():
data = _read()
return data.get("token_columns", DEFAULTS["token_columns"])
@router.put("/token-columns", response_model=TokenColumnSettings)
async def update_token_column_settings(body: TokenColumnSettings):
data = _read()
data["token_columns"] = body.model_dump()
_write(data)
return data["token_columns"]
+101
View File
@@ -0,0 +1,101 @@
import asyncio
import json
from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from loguru import logger
from app.database import get_db
from app.models import TaskOut
from app.core.task_manager import task_manager
router = APIRouter(prefix="/api/tasks", tags=["tasks"])
@router.get("", response_model=list[TaskOut])
async def list_tasks():
db = await get_db()
cursor = await db.execute("SELECT * FROM tasks ORDER BY created_at DESC LIMIT 50")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@router.get("/{task_id}", response_model=TaskOut)
async def get_task(task_id: str):
db = await get_db()
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Task not found")
return dict(row)
@router.get("/{task_id}/stream")
async def stream_task(task_id: str):
"""SSE endpoint for real-time task progress."""
async def event_generator():
last_data = None
while True:
db = await get_db()
cursor = await db.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
row = await cursor.fetchone()
if not row:
yield f"data: {json.dumps({'error': 'Task not found'})}\n\n"
break
task = dict(row)
# Parse JSON string fields from DB into dicts for proper serialization
for json_field in ("account_ids", "account_results"):
if isinstance(task.get(json_field), str):
try:
task[json_field] = json.loads(task[json_field])
except (json.JSONDecodeError, TypeError):
pass
prompt_info = task_manager.get_pending_prompt(task_id)
if prompt_info:
task["prompt"] = prompt_info["message"]
task["prompt_login"] = prompt_info["login"]
step_info = task_manager.get_step_info(task_id)
if step_info:
task["step"] = step_info["step"]
task["total_steps"] = step_info["total_steps"]
task["step_label"] = step_info["label"]
active = task_manager.get_active_count(task_id)
if active > 0:
task["active_count"] = active
acc_results = task_manager.get_account_results(task_id)
if acc_results:
task["account_results"] = {str(k): v for k, v in acc_results.items()}
acc_steps = task_manager.get_account_steps(task_id)
if acc_steps:
task["account_steps"] = {str(k): v for k, v in acc_steps.items()}
data_str = json.dumps(task)
if data_str != last_data or task["status"] in ("completed", "failed", "cancelled"):
last_data = data_str
yield f"data: {data_str}\n\n"
if task["status"] in ("completed", "failed", "cancelled"):
break
await asyncio.sleep(0.5)
return StreamingResponse(event_generator(), media_type="text/event-stream")
@router.post("/{task_id}/respond")
async def respond_to_prompt(task_id: str, body: dict):
"""Provide user input for a pending task prompt."""
value = body.get("value", "")
login = body.get("login", "")
if not task_manager.respond(task_id, value, login):
raise HTTPException(status_code=404, detail="No pending prompt for this task")
return {"status": "ok"}
@router.delete("/{task_id}", status_code=204)
async def cancel_task(task_id: str):
cancelled = task_manager.cancel(task_id)
if not cancelled:
raise HTTPException(status_code=404, detail="Task not found or already finished")
+245
View File
@@ -0,0 +1,245 @@
"""CRUD and validate endpoints for token accounts."""
import asyncio
from fastapi import APIRouter, HTTPException
from loguru import logger
from app.database import get_db
from app.core.task_manager import task_manager
from app.models import TokenAccountCreate, TokenAccountOut, TokenAccountUpdate
router = APIRouter(prefix="/api/token-accounts", tags=["token-accounts"])
@router.get("", response_model=list[TokenAccountOut])
async def list_tokens():
db = await get_db()
cursor = await db.execute("SELECT * FROM token_accounts ORDER BY id DESC")
rows = await cursor.fetchall()
return [dict(r) for r in rows]
@router.get("/{account_id}", response_model=TokenAccountOut)
async def get_token(account_id: int):
db = await get_db()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.post("", response_model=TokenAccountOut, status_code=201)
async def create_token(account: TokenAccountCreate):
db = await get_db()
cursor = await db.execute(
"INSERT INTO token_accounts (login, token, steam_id, proxy, notes) VALUES (?, ?, ?, ?, ?)",
(account.login, account.token, account.steam_id, account.proxy, account.notes),
)
await db.commit()
new_cursor = await db.execute(
"SELECT * FROM token_accounts WHERE id = ?", (cursor.lastrowid,)
)
return dict(await new_cursor.fetchone())
@router.put("/{account_id}", response_model=TokenAccountOut)
async def update_token(account_id: int, account: TokenAccountUpdate):
db = await get_db()
fields = {k: v for k, v in account.model_dump().items() if v is not None}
if not fields:
raise HTTPException(status_code=400, detail="No fields to update")
fields["updated_at"] = "datetime('now')"
set_clause = ", ".join(
f"{k} = datetime('now')" if k == "updated_at" else f"{k} = ?"
for k in fields
)
values = [v for k, v in fields.items() if k != "updated_at"]
values.append(account_id)
await db.execute(f"UPDATE token_accounts SET {set_clause} WHERE id = ?", values)
await db.commit()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
return dict(row)
@router.delete("/{account_id}", status_code=204)
async def delete_token(account_id: int):
db = await get_db()
await db.execute("DELETE FROM token_accounts WHERE id = ?", (account_id,))
await db.commit()
@router.post("/delete-bulk")
async def delete_token_bulk(data: dict):
ids: list[int] = data.get("ids", [])
if not ids:
return {"deleted": 0}
db = await get_db()
placeholders = ",".join("?" for _ in ids)
await db.execute(f"DELETE FROM token_accounts WHERE id IN ({placeholders})", ids)
await db.commit()
return {"deleted": len(ids)}
@router.post("/import")
async def import_tokens(data: dict):
"""Bulk import: one token per line, optionally 'login:token'."""
lines: list[str] = data.get("lines", [])
imported = 0
skipped = 0
errors: list[str] = []
db = await get_db()
for raw in lines:
line = raw.strip()
if not line:
skipped += 1
continue
login = None
token = line
if ":" in line:
parts = line.split(":", 1)
login, token = parts[0].strip(), parts[1].strip()
if not token:
skipped += 1
continue
try:
await db.execute(
"INSERT OR IGNORE INTO token_accounts (login, token) VALUES (?, ?)",
(login, token),
)
imported += 1
except Exception as exc:
errors.append(f"{token[:20]}: {exc}")
await db.commit()
return {"imported": imported, "skipped": skipped, "errors": errors}
@router.post("/validate")
async def validate_tokens(data: dict):
"""Submit validation task for selected token accounts."""
ids: list[int] = data.get("account_ids", [])
if not ids:
raise HTTPException(status_code=400, detail="No account IDs provided")
db = await get_db()
placeholders = ",".join("?" for _ in ids)
cursor = await db.execute(
f"SELECT * FROM token_accounts WHERE id IN ({placeholders})", ids
)
accounts = [dict(r) for r in await cursor.fetchall()]
if not accounts:
raise HTTPException(status_code=404, detail="No accounts found")
task_id = await task_manager.submit(
task_type="token_validate",
accounts=accounts,
params={},
)
logger.info(f"token_validate submitted for {len(accounts)} accounts → task {task_id}")
return {"task_id": task_id, "accounts_count": len(accounts)}
@router.post("/{account_id}/browser")
async def open_token_browser(account_id: int):
"""Open Chrome browser with saved session cookies for a token account."""
db = await get_db()
cursor = await db.execute("SELECT * FROM token_accounts WHERE id = ?", (account_id,))
row = await cursor.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Account not found")
account = dict(row)
if not account.get("session_cookies"):
raise HTTPException(status_code=400, detail="No session cookies. Validate the account first.")
from app.services.steam_auth import check_cookies_alive, _resolve_proxy
from app.config import read_validation_settings
proxy = await _resolve_proxy(account)
alive = await check_cookies_alive(account["session_cookies"], proxy)
if not alive:
val_settings = read_validation_settings()
if val_settings.get("auto_revalidate_browser"):
task_id = await task_manager.submit(
task_type="token_validate",
accounts=[account],
params={},
)
logger.info(f"Cookies dead for token account {account_id}, auto-revalidating → task {task_id}")
return {"status": "revalidating", "message": "Cookies expired. Re-validating...", "task_id": task_id}
raise HTTPException(status_code=400, detail="Session cookies expired. Re-validate the account.")
from app.services.browser_login import open_browser_with_cookies
async def _run():
try:
await open_browser_with_cookies(account)
except Exception as exc:
logger.error(f"Browser open failed for {account.get('login', account_id)}: {exc}")
asyncio.create_task(_run())
return {"status": "ok", "message": "Browser opening..."}
@router.post("/assign-proxies")
async def token_assign_proxies():
"""Round-robin assign proxies to token accounts that don't have one."""
db = await get_db()
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute(
"SELECT id FROM token_accounts WHERE proxy IS NULL OR proxy = '' ORDER BY id"
)
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
await db.execute(
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxies[i % len(proxies)], acc_id),
)
await db.commit()
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/reassign-proxies")
async def token_reassign_proxies():
"""Round-robin reassign proxies to ALL token accounts."""
db = await get_db()
proxy_cursor = await db.execute("SELECT address FROM proxies ORDER BY id")
proxies = [r["address"] for r in await proxy_cursor.fetchall()]
if not proxies:
raise HTTPException(status_code=400, detail="No proxies available")
acc_cursor = await db.execute("SELECT id FROM token_accounts ORDER BY id")
account_ids = [r["id"] for r in await acc_cursor.fetchall()]
if not account_ids:
return {"assigned": 0, "proxies_used": 0}
for i, acc_id in enumerate(account_ids):
await db.execute(
"UPDATE token_accounts SET proxy = ?, updated_at = datetime('now') WHERE id = ?",
(proxies[i % len(proxies)], acc_id),
)
await db.commit()
return {"assigned": len(account_ids), "proxies_used": len(proxies)}
@router.post("/clear-proxies")
async def token_clear_proxies():
"""Remove proxy from all token accounts."""
db = await get_db()
cursor = await db.execute(
"UPDATE token_accounts SET proxy = NULL, updated_at = datetime('now') WHERE proxy IS NOT NULL AND proxy != ''"
)
await db.commit()
return {"cleared": cursor.rowcount}
+50
View File
@@ -0,0 +1,50 @@
from pathlib import Path
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
app_name: str = "SteamPanel"
debug: bool = False
host: str = "127.0.0.1"
port: int = 8000
base_dir: Path = Path(__file__).resolve().parent.parent
data_dir: Path = base_dir / "data"
db_path: Path = data_dir / "accounts.db"
mafiles_dir: Path = data_dir / "mafiles"
logs_dir: Path = data_dir / "logs"
proxies_path: Path = data_dir / "proxies.json"
max_concurrent_tasks: int = 5
request_timeout: int = 30
proxy_rotation_interval: int = 60
model_config = {"env_prefix": "STEAM_PANEL_"}
settings = Settings()
# Defaults for data/settings.json → "validation" section
_VALIDATION_DEFAULTS: dict = {
"fetch_profile": True,
"check_ban": True,
"max_threads": 10,
"account_timeout": 90,
"auto_revalidate_browser": True,
}
def read_validation_settings() -> dict:
"""Read the 'validation' section from data/settings.json (sync, for small file)."""
import json as _json
settings_path = settings.data_dir / "settings.json"
result = _VALIDATION_DEFAULTS.copy()
if settings_path.exists():
try:
data = _json.loads(settings_path.read_text("utf-8"))
result.update(data.get("validation", {}))
except Exception:
pass
return result
View File
+120
View File
@@ -0,0 +1,120 @@
"""AutoAcceptManager — manages per-account background loops for auto-accepting Steam logins."""
import asyncio
import aiohttp
from loguru import logger
from app.services.steam_auto_accept import mobile_login, get_pending_sessions, confirm_session
from app.database import get_db
CHECK_INTERVAL = 15 # seconds
class AutoAcceptManager:
def __init__(self) -> None:
self._tasks: dict[int, asyncio.Task] = {}
self._tokens: dict[int, str] = {}
self._errors: dict[int, str] = {}
async def start(self, account: dict) -> None:
account_id = account["id"]
if account_id in self._tasks and not self._tasks[account_id].done():
return
self._tasks[account_id] = asyncio.create_task(self._loop(account))
logger.info(f"[auto-accept] Started monitoring for {account['login']} (id={account_id})")
def stop(self, account_id: int) -> None:
task = self._tasks.pop(account_id, None)
self._tokens.pop(account_id, None)
self._errors.pop(account_id, None)
if task and not task.done():
task.cancel()
logger.info(f"[auto-accept] Stopped monitoring for id={account_id}")
def is_running(self, account_id: int) -> bool:
task = self._tasks.get(account_id)
return task is not None and not task.done()
def running_ids(self) -> set[int]:
# Cleanup dead tasks
dead = [aid for aid, t in self._tasks.items() if t.done()]
for aid in dead:
self._tasks.pop(aid, None)
self._tokens.pop(aid, None)
return {aid for aid, t in self._tasks.items() if not t.done()}
def get_errors(self) -> dict[int, str]:
"""Return error messages for accounts that stopped unexpectedly."""
return dict(self._errors)
def pop_errors(self) -> dict[int, str]:
"""Return and clear error messages."""
errors = dict(self._errors)
self._errors.clear()
return errors
async def _loop(self, account: dict) -> None:
account_id = account["id"]
login = account["login"]
async with aiohttp.ClientSession() as session:
# Initial mobile login
token = await mobile_login(session, account)
if not token:
logger.error(f"[auto-accept] Initial login failed for {login}, stopping")
self._errors[account_id] = "Initial login failed"
await self._disable_in_db(account_id)
return
self._tokens[account_id] = token
while True:
try:
await asyncio.sleep(CHECK_INTERVAL)
client_ids = await get_pending_sessions(session, self._tokens[account_id])
# Token expired — re-login
if client_ids is None:
logger.warning(f"[auto-accept] Token expired for {login}, re-logging...")
new_token = await mobile_login(session, account)
if new_token:
self._tokens[account_id] = new_token
client_ids = await get_pending_sessions(session, new_token)
else:
logger.error(f"[auto-accept] Re-login failed for {login}, stopping")
self._errors[account_id] = "Re-login failed (token expired)"
await self._disable_in_db(account_id)
return
if not client_ids:
continue
for cid in client_ids:
await confirm_session(session, self._tokens[account_id], account, cid)
await asyncio.sleep(1)
except asyncio.CancelledError:
logger.info(f"[auto-accept] Loop cancelled for {login}")
return
except Exception as exc:
logger.error(f"[auto-accept] Loop error for {login}: {exc}")
await asyncio.sleep(CHECK_INTERVAL)
async def stop_all(self) -> None:
for aid in list(self._tasks):
self.stop(aid)
async def _disable_in_db(self, account_id: int) -> None:
"""Reset auto_accept flag in DB when task fails."""
try:
db = await get_db()
await db.execute("UPDATE accounts SET auto_accept = 0 WHERE id = ?", (account_id,))
await db.commit()
logger.info(f"[auto-accept] Disabled auto_accept in DB for id={account_id}")
except Exception as exc:
logger.error(f"[auto-accept] Failed to update DB for id={account_id}: {exc}")
auto_accept_manager = AutoAcceptManager()
+20
View File
@@ -0,0 +1,20 @@
import base64
import rsa
def get_rsa_key(response_data: dict) -> tuple[str, str, int]:
"""Extract RSA key components from Steam's getrsakey response."""
mod = response_data["publickey_mod"]
exp = response_data["publickey_exp"]
timestamp = int(response_data["timestamp"])
return mod, exp, timestamp
def encrypt_password(password: str, mod_hex: str, exp_hex: str) -> str:
"""RSA-encrypt a password using Steam's public key, return base64."""
mod = int(mod_hex, 16)
exp = int(exp_hex, 16)
public_key = rsa.PublicKey(mod, exp)
encrypted = rsa.encrypt(password.encode("ascii"), public_key)
return base64.b64encode(encrypted).decode("ascii")
+35
View File
@@ -0,0 +1,35 @@
class SteamPanelError(Exception):
def __init__(self, message: str, code: str = "unknown_error") -> None:
self.message = message
self.code = code
super().__init__(message)
class SteamAuthError(SteamPanelError):
def __init__(self, message: str = "Authentication failed") -> None:
super().__init__(message, code="auth_error")
class SteamGuardError(SteamPanelError):
def __init__(self, message: str = "Steam Guard operation failed") -> None:
super().__init__(message, code="guard_error")
class ProxyError(SteamPanelError):
def __init__(self, message: str = "Proxy error") -> None:
super().__init__(message, code="proxy_error")
class MafileError(SteamPanelError):
def __init__(self, message: str = "Invalid mafile") -> None:
super().__init__(message, code="mafile_error")
class TaskError(SteamPanelError):
def __init__(self, message: str = "Task execution failed") -> None:
super().__init__(message, code="task_error")
class EmailError(SteamPanelError):
def __init__(self, message: str = "Email operation failed") -> None:
super().__init__(message, code="email_error")
+138
View File
@@ -0,0 +1,138 @@
import logging
import re
import sys
from loguru import logger
from app.config import settings
LEVEL_ICONS = {
"TRACE": "[~]",
"DEBUG": "[.]",
"INFO": "[i]",
"SUCCESS": "[+]",
"WARNING": "[!]",
"ERROR": "[e]",
"CRITICAL":"[e]",
}
LEVEL_COLORS = {
"TRACE": "\033[35m",
"DEBUG": "\033[37m",
"INFO": "\033[34m",
"SUCCESS": "\033[32m",
"WARNING": "\033[33m",
"ERROR": "\033[31m",
"CRITICAL":"\033[31m",
}
RESET = "\033[0m"
def _loguru_fmt(record: dict) -> str:
level = record["level"].name
icon = LEVEL_ICONS.get(level, "[i]")
color = LEVEL_COLORS.get(level, "")
ts = record["time"].strftime("%H:%M:%S")
msg = record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
return f"{RESET}[{ts}] {color}{icon}{RESET} {msg}\n"
class _UvicornInterceptHandler(logging.Handler):
"""Route all standard logging records (uvicorn, fastapi) into loguru."""
def emit(self, record: logging.LogRecord) -> None:
try:
level = logger.level(record.levelname).name
except ValueError:
level = record.levelno
frame, depth = sys._getframe(6), 6
while frame and frame.f_code.co_filename == logging.__file__:
frame = frame.f_back
depth += 1
logger.opt(depth=depth, exception=record.exc_info).log(level, record.getMessage())
_logging_configured = False
def setup_logging() -> None:
global _logging_configured
if _logging_configured:
return
_logging_configured = True
logger.remove()
logger.add(
sys.stderr,
format=_loguru_fmt,
level="DEBUG" if settings.debug else "INFO",
colorize=False,
)
settings.logs_dir.mkdir(parents=True, exist_ok=True)
def _file_fmt(record: dict) -> str:
level = record["level"].name
icon = LEVEL_ICONS.get(level, "[i]")
ts = record["time"].strftime("%Y-%m-%d %H:%M:%S")
msg = record["message"].replace("<", "\\<").replace("{", "{{").replace("}", "}}")
return f"[{ts}] {icon} {msg}\n"
logger.add(
settings.logs_dir / "steampanel_{time:YYYY-MM-DD}.log",
format=_file_fmt,
level="DEBUG",
rotation="1 day",
retention="7 days",
compression="zip",
)
_install_intercept()
class _AioSQLiteFilter(logging.Filter):
"""Reformats raw aiosqlite debug messages into readable DB operation lines."""
_OP_RE = re.compile(r"method (\w+) of sqlite3\.(\w+)")
_SQL_RE = re.compile(r',\s*"(.*?)"(?:,\s*(\(.*?\)))?\s*\)$', re.DOTALL)
def filter(self, record: logging.LogRecord) -> bool:
msg = record.getMessage()
# Drop the redundant "operation X completed" lines
if " completed" in msg:
return False
if msg.startswith("executing"):
op_m = self._OP_RE.search(msg)
method = op_m.group(1).upper() if op_m else "OP"
sql_m = self._SQL_RE.search(msg)
if sql_m:
sql = sql_m.group(1).strip()
params = sql_m.group(2) or ""
record.msg = f"[DB] {sql}" + (f" {params}" if params else "")
else:
record.msg = f"[DB] {method}"
record.args = ()
return True
def _install_intercept() -> None:
intercept = _UvicornInterceptHandler()
for name in ("", "uvicorn", "uvicorn.error", "uvicorn.access", "fastapi"):
log = logging.getLogger(name)
log.handlers = [intercept]
log.setLevel(logging.DEBUG)
log.propagate = False
# Format aiosqlite SQL logs instead of silencing them
aiosqlite_log = logging.getLogger("aiosqlite")
aiosqlite_log.setLevel(logging.DEBUG)
aiosqlite_log.addFilter(_AioSQLiteFilter())
logging.getLogger("asyncio").setLevel(logging.WARNING)
+78
View File
@@ -0,0 +1,78 @@
import random
import aiohttp
from aiohttp_socks import ProxyConnector
from loguru import logger
from app.database import get_db
def build_proxy_url(address: str, protocol: str) -> str:
"""Convert stored address to standard proxy URL (proto://login:pass@ip:port).
The frontend always normalizes addresses to login:pass@ip:port (or plain ip:port),
so we just prepend the scheme.
"""
if "://" in address:
return address
scheme = protocol if protocol in ("socks5", "socks4") else "http"
return f"{scheme}://{address}"
class ProxyManager:
def __init__(self) -> None:
self._proxies: list[dict] = []
self._index: int = 0
async def load(self) -> None:
db = await get_db()
cursor = await db.execute(
"SELECT * FROM proxies WHERE is_alive = 1 ORDER BY fail_count ASC"
)
self._proxies = [dict(r) for r in await cursor.fetchall()]
logger.info(f"Loaded {len(self._proxies)} alive proxies")
def get_next(self) -> dict | None:
if not self._proxies:
return None
proxy = self._proxies[self._index % len(self._proxies)]
self._index += 1
return proxy
def get_random(self) -> dict | None:
if not self._proxies:
return None
return random.choice(self._proxies)
def get_connector(self, proxy: dict | None = None) -> aiohttp.TCPConnector | ProxyConnector:
if proxy is None:
return aiohttp.TCPConnector()
url = build_proxy_url(proxy["address"], proxy.get("protocol", "http"))
return ProxyConnector.from_url(url, ssl=False)
async def mark_failed(self, proxy_id: int) -> None:
db = await get_db()
await db.execute(
"UPDATE proxies SET fail_count = fail_count + 1, last_checked = datetime('now') WHERE id = ?",
(proxy_id,),
)
await db.execute(
"UPDATE proxies SET is_alive = 0 WHERE id = ? AND fail_count >= 5",
(proxy_id,),
)
await db.commit()
async def mark_alive(self, proxy_id: int) -> None:
db = await get_db()
await db.execute(
"UPDATE proxies SET fail_count = 0, is_alive = 1, last_checked = datetime('now') WHERE id = ?",
(proxy_id,),
)
await db.commit()
@property
def count(self) -> int:
return len(self._proxies)
proxy_manager = ProxyManager()
+249
View File
@@ -0,0 +1,249 @@
import asyncio
import json
import uuid
from pathlib import Path
from typing import Any, Protocol
from loguru import logger
from app.config import settings
DEFAULT_MAX_PARALLEL = 10
DEFAULT_ACCOUNT_TIMEOUT = 90 # fallback for actions not in the map below
# Per-action timeouts (seconds). Fast read-only actions get 60s;
# write actions that involve SMS/email prompts get 120s.
TASK_TIMEOUTS: dict[str, int] = {
"validate": 60,
"remove_guard": 60,
"change_password": 60,
"random_password": 60,
"change_phone": 120,
"change_email": 120,
}
def _read_max_threads() -> int:
from app.config import read_validation_settings
return int(read_validation_settings().get("max_threads", DEFAULT_MAX_PARALLEL))
def _read_account_timeout() -> int:
from app.config import read_validation_settings
return int(read_validation_settings().get("account_timeout", DEFAULT_ACCOUNT_TIMEOUT))
class TaskHandler(Protocol):
async def __call__(self, account: dict, params: dict, *, task_id: str) -> None: ...
class TaskManager:
def __init__(self) -> None:
self._running: dict[str, asyncio.Task] = {}
self._handlers: dict[str, TaskHandler] = {}
self._prompts: dict[str, dict[str, Any]] = {}
self._steps: dict[str, dict[str, Any]] = {}
self._active_counts: dict[str, int] = {}
self._account_results: dict[str, dict[int, dict[str, str]]] = {}
self._account_steps: dict[str, dict[int, dict[str, int]]] = {}
def register_handler(self, action: str, handler: TaskHandler) -> None:
self._handlers[action] = handler
async def submit(
self,
task_type: str,
accounts: list[dict],
params: dict,
) -> str:
task_id = uuid.uuid4().hex[:12]
account_ids = [acc.get("id", 0) for acc in accounts]
from app.database import get_db
db = await get_db()
await db.execute(
"INSERT INTO tasks (id, type, status, total, account_ids) VALUES (?, ?, 'running', ?, ?)",
(task_id, task_type, len(accounts), json.dumps(account_ids)),
)
await db.commit()
async_task = asyncio.create_task(
self._execute(task_id, task_type, accounts, params)
)
self._running[task_id] = async_task
return task_id
async def _execute(
self,
task_id: str,
task_type: str,
accounts: list[dict],
params: dict,
) -> None:
from app.database import get_db
handler = self._handlers.get(task_type)
if not handler:
db = await get_db()
await db.execute(
"UPDATE tasks SET status = 'failed', error = ? WHERE id = ?",
(f"No handler for action: {task_type}", task_id),
)
await db.commit()
return
completed = 0
errors: list[str] = []
lock = asyncio.Lock()
max_parallel = _read_max_threads()
account_timeout = TASK_TIMEOUTS.get(task_type, _read_account_timeout())
sem = asyncio.Semaphore(max_parallel)
self._active_counts[task_id] = 0
self._account_results[task_id] = {}
total = len(accounts)
async def process_one(account: dict, idx: int) -> None:
nonlocal completed
login = account.get("login", "?")
acc_id = account.get("id", 0)
logger.debug(f"[{task_type}] ({idx}/{total}) processing {login}...")
async with sem:
async with lock:
self._active_counts[task_id] = self._active_counts.get(task_id, 0) + 1
try:
await asyncio.wait_for(
handler(account, params, task_id=task_id),
timeout=account_timeout,
)
logger.success(f"[{task_type}] ({idx}/{total}) {login} — done")
async with lock:
self._account_results[task_id][acc_id] = {"status": "ok"}
except asyncio.TimeoutError:
async with lock:
errors.append(f"{login}: timed out after {account_timeout}s")
self._account_results[task_id][acc_id] = {"status": "error", "error": f"Timed out after {account_timeout}s"}
logger.error(f"[{task_type}] ({idx}/{total}) {login} — timed out after {account_timeout}s")
except Exception as exc:
err_str = str(exc)
async with lock:
errors.append(f"{login}: {exc}")
self._account_results[task_id][acc_id] = {"status": "error", "error": err_str}
logger.error(f"[{task_type}] ({idx}/{total}) {login} — error: {exc}")
finally:
async with lock:
self._active_counts[task_id] = max(0, self._active_counts.get(task_id, 0) - 1)
async with lock:
completed += 1
db = await get_db()
await db.execute(
"UPDATE tasks SET progress = ?, updated_at = datetime('now') WHERE id = ?",
(completed, task_id),
)
await db.commit()
try:
tasks = [process_one(acc, i) for i, acc in enumerate(accounts, 1)]
await asyncio.gather(*tasks)
db = await get_db()
status = "completed"
result = f"Done: {completed - len(errors)} success, {len(errors)} errors"
error_text = "; ".join(errors[:10]) if errors else None
results_json = json.dumps(
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
)
await db.execute(
"UPDATE tasks SET status = ?, result = ?, error = ?, account_results = ?, updated_at = datetime('now') WHERE id = ?",
(status, result, error_text, results_json, task_id),
)
await db.commit()
except asyncio.CancelledError:
db = await get_db()
results_json = json.dumps(
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
)
await db.execute(
"UPDATE tasks SET status = 'cancelled', account_results = ?, updated_at = datetime('now') WHERE id = ?",
(results_json, task_id),
)
await db.commit()
except Exception as exc:
logger.error(f"Task {task_id} fatal error: {exc}")
db = await get_db()
results_json = json.dumps(
{str(k): v for k, v in self._account_results.get(task_id, {}).items()}
)
await db.execute(
"UPDATE tasks SET status = 'failed', error = ?, account_results = ?, updated_at = datetime('now') WHERE id = ?",
(str(exc)[:500], results_json, task_id),
)
await db.commit()
finally:
self._running.pop(task_id, None)
self._steps.pop(task_id, None)
self._active_counts.pop(task_id, None)
self._account_results.pop(task_id, None)
self._account_steps.pop(task_id, None)
def get_active_count(self, task_id: str) -> int:
return self._active_counts.get(task_id, 0)
def get_account_results(self, task_id: str) -> dict[int, dict[str, str]]:
return self._account_results.get(task_id, {})
def clear_account_results(self, task_id: str) -> None:
self._account_results.pop(task_id, None)
async def set_step(self, task_id: str, step: int, total_steps: int, label: str = "", acc_id: int = 0) -> None:
self._steps[task_id] = {"step": step, "total_steps": total_steps, "label": label}
if acc_id:
self._account_steps.setdefault(task_id, {})[acc_id] = {"step": step, "total": total_steps}
def get_step_info(self, task_id: str) -> dict[str, Any] | None:
return self._steps.get(task_id)
def get_account_steps(self, task_id: str) -> dict[int, dict[str, int]]:
return self._account_steps.get(task_id, {})
async def prompt_user(self, task_id: str, message: str, login: str = "") -> str:
"""Pause task execution and ask the user for input via SSE prompt."""
key = f"{task_id}:{login}" if login else task_id
event = asyncio.Event()
self._prompts[key] = {"message": message, "login": login, "event": event, "response": ""}
await event.wait()
response = self._prompts.pop(key, {}).get("response", "")
return response
def respond(self, task_id: str, value: str, login: str = "") -> bool:
key = f"{task_id}:{login}" if login else task_id
prompt = self._prompts.get(key)
if not prompt:
return False
prompt["response"] = value
prompt["event"].set()
return True
def get_pending_prompt(self, task_id: str) -> dict[str, str] | None:
"""Return first pending prompt for this task (with login info)."""
prefix = f"{task_id}:"
for key, prompt in self._prompts.items():
if key == task_id or key.startswith(prefix):
return {"message": prompt["message"], "login": prompt.get("login", "")}
return None
def cancel(self, task_id: str) -> bool:
task = self._running.get(task_id)
if task and not task.done():
task.cancel()
return True
return False
def active_count(self) -> int:
return len(self._running)
task_manager = TaskManager()
+200
View File
@@ -0,0 +1,200 @@
import aiosqlite
from loguru import logger
from app.config import settings
import asyncio
_db: aiosqlite.Connection | None = None
_db_lock = asyncio.Lock()
SQL_CREATE_ACCOUNTS = """
CREATE TABLE IF NOT EXISTS accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL,
password TEXT NOT NULL,
steam_id TEXT UNIQUE,
email TEXT,
email_password TEXT,
phone TEXT,
mafile_path TEXT,
shared_secret TEXT,
identity_secret TEXT,
proxy TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
notes TEXT,
nickname TEXT,
avatar_url TEXT,
steam_level INTEGER,
auto_accept INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
SQL_CREATE_PROXIES = """
CREATE TABLE IF NOT EXISTS proxies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
address TEXT NOT NULL UNIQUE,
protocol TEXT NOT NULL DEFAULT 'http',
is_alive INTEGER NOT NULL DEFAULT 1,
last_checked TEXT,
fail_count INTEGER NOT NULL DEFAULT 0
);
"""
SQL_CREATE_TASKS = """
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
type TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
progress INTEGER NOT NULL DEFAULT 0,
total INTEGER NOT NULL DEFAULT 0,
result TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
SQL_CREATE_LOGPASS_ACCOUNTS = """
CREATE TABLE IF NOT EXISTS logpass_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT NOT NULL UNIQUE,
password TEXT NOT NULL,
steam_id TEXT,
proxy TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
ban_status TEXT,
nickname TEXT,
steam_level INTEGER,
prime TEXT,
trophy TEXT,
behavior TEXT,
license TEXT,
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
SQL_CREATE_TOKEN_ACCOUNTS = """
CREATE TABLE IF NOT EXISTS token_accounts (
id INTEGER PRIMARY KEY AUTOINCREMENT,
login TEXT,
token TEXT NOT NULL,
steam_id TEXT,
status TEXT NOT NULL DEFAULT 'unknown',
notes TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
"""
async def get_db() -> aiosqlite.Connection:
global _db
async with _db_lock:
if _db is None:
settings.data_dir.mkdir(parents=True, exist_ok=True)
_db = await aiosqlite.connect(str(settings.db_path))
_db.row_factory = aiosqlite.Row
await _db.execute("PRAGMA journal_mode=WAL")
await _db.execute("PRAGMA foreign_keys=ON")
await _init_tables(_db)
logger.info(f"Database connected: {settings.db_path}")
return _db
async def _init_tables(db: aiosqlite.Connection) -> None:
await db.execute(SQL_CREATE_ACCOUNTS)
await db.execute(SQL_CREATE_PROXIES)
await db.execute(SQL_CREATE_TASKS)
await db.execute(SQL_CREATE_LOGPASS_ACCOUNTS)
await db.execute(SQL_CREATE_TOKEN_ACCOUNTS)
await _migrate(db)
await db.commit()
async def _migrate(db: aiosqlite.Connection) -> None:
"""Add columns that may not exist in older DBs."""
cursor = await db.execute("PRAGMA table_info(accounts)")
cols = {row[1] for row in await cursor.fetchall()}
migrations = [
("nickname", "ALTER TABLE accounts ADD COLUMN nickname TEXT"),
("avatar_url", "ALTER TABLE accounts ADD COLUMN avatar_url TEXT"),
("steam_level", "ALTER TABLE accounts ADD COLUMN steam_level INTEGER"),
("auto_accept", "ALTER TABLE accounts ADD COLUMN auto_accept INTEGER NOT NULL DEFAULT 0"),
("ban_status", "ALTER TABLE accounts ADD COLUMN ban_status TEXT"),
("session_cookies", "ALTER TABLE accounts ADD COLUMN session_cookies TEXT"),
("last_online", "ALTER TABLE accounts ADD COLUMN last_online TEXT"),
]
for col, sql in migrations:
if col not in cols:
await db.execute(sql)
logger.info(f"Migration: added column '{col}' to accounts")
cursor = await db.execute("PRAGMA table_info(tasks)")
task_cols = {row[1] for row in await cursor.fetchall()}
task_migrations = [
("account_ids", "ALTER TABLE tasks ADD COLUMN account_ids TEXT"),
("account_results", "ALTER TABLE tasks ADD COLUMN account_results TEXT"),
]
for col, sql in task_migrations:
if col not in task_cols:
await db.execute(sql)
logger.info(f"Migration: added column '{col}' to tasks")
# Logpass accounts migrations
cursor = await db.execute("PRAGMA table_info(logpass_accounts)")
lp_cols = {row[1] for row in await cursor.fetchall()}
lp_migrations = [
("prime", "ALTER TABLE logpass_accounts ADD COLUMN prime TEXT"),
("trophy", "ALTER TABLE logpass_accounts ADD COLUMN trophy TEXT"),
("behavior", "ALTER TABLE logpass_accounts ADD COLUMN behavior TEXT"),
("license", "ALTER TABLE logpass_accounts ADD COLUMN license TEXT"),
("session_cookies", "ALTER TABLE logpass_accounts ADD COLUMN session_cookies TEXT"),
("steam_level", "ALTER TABLE logpass_accounts ADD COLUMN steam_level INTEGER"),
("avatar_url", "ALTER TABLE logpass_accounts ADD COLUMN avatar_url TEXT"),
("last_online", "ALTER TABLE logpass_accounts ADD COLUMN last_online TEXT"),
]
for col, sql in lp_migrations:
if col not in lp_cols:
await db.execute(sql)
logger.info(f"Migration: added column '{col}' to logpass_accounts")
# Token accounts migrations
cursor = await db.execute("PRAGMA table_info(token_accounts)")
tk_cols = {row[1] for row in await cursor.fetchall()}
tk_migrations = [
("proxy", "ALTER TABLE token_accounts ADD COLUMN proxy TEXT"),
("session_cookies", "ALTER TABLE token_accounts ADD COLUMN session_cookies TEXT"),
("ban_status", "ALTER TABLE token_accounts ADD COLUMN ban_status TEXT"),
("nickname", "ALTER TABLE token_accounts ADD COLUMN nickname TEXT"),
("steam_level", "ALTER TABLE token_accounts ADD COLUMN steam_level INTEGER"),
("avatar_url", "ALTER TABLE token_accounts ADD COLUMN avatar_url TEXT"),
("last_online", "ALTER TABLE token_accounts ADD COLUMN last_online TEXT"),
]
for col, sql in tk_migrations:
if col not in tk_cols:
await db.execute(sql)
logger.info(f"Migration: added column '{col}' to token_accounts")
# Add UNIQUE index on login if missing
cursor = await db.execute("PRAGMA index_list(logpass_accounts)")
idx_rows = await cursor.fetchall()
idx_names = {row[1] for row in idx_rows}
if "uq_logpass_login" not in idx_names:
try:
await db.execute("CREATE UNIQUE INDEX uq_logpass_login ON logpass_accounts(login)")
logger.info("Migration: added unique index on logpass_accounts.login")
except Exception:
logger.warning("Could not create unique index on logpass_accounts.login (duplicates may exist)")
async def close_db() -> None:
global _db
if _db is not None:
await _db.close()
_db = None
logger.info("Database connection closed")
+246
View File
@@ -0,0 +1,246 @@
import json
from datetime import datetime
from pathlib import Path
from pydantic import BaseModel, Field, model_validator
class AccountCreate(BaseModel):
login: str
password: str
steam_id: str | None = None
email: str | None = None
email_password: str | None = None
phone: str | None = None
proxy: str | None = None
notes: str | None = None
class AccountUpdate(BaseModel):
login: str | None = None
password: str | None = None
steam_id: str | None = None
email: str | None = None
email_password: str | None = None
phone: str | None = None
proxy: str | None = None
status: str | None = None
notes: str | None = None
class AccountOut(BaseModel):
id: int
login: str
password: str
steam_id: str | None = None
email: str | None = None
email_password: str | None = None
phone: str | None = None
mafile_path: str | None = None
shared_secret: str | None = None
identity_secret: str | None = None
proxy: str | None = None
status: str
notes: str | None = None
nickname: str | None = None
avatar_url: str | None = None
steam_level: int | None = None
last_online: str | None = None
auto_accept: int = 0
ban_status: str | None = None
has_cookies: bool = False
has_revocation_code: bool = False
created_at: str
updated_at: str
@staticmethod
def _check_revocation_code(mafile_path: str | None) -> bool:
if not mafile_path:
return False
try:
p = Path(mafile_path)
if not p.exists():
return False
data = json.loads(p.read_text(encoding="utf-8"))
return bool(data.get("revocation_code", ""))
except Exception:
return False
@model_validator(mode="before")
@classmethod
def _compute_flags(cls, data):
if isinstance(data, dict):
data["has_cookies"] = bool(data.get("session_cookies"))
data["has_revocation_code"] = AccountOut._check_revocation_code(data.get("mafile_path"))
return data
class ProxyCreate(BaseModel):
address: str
protocol: str = "http"
class ProxyOut(BaseModel):
id: int
address: str
protocol: str
is_alive: bool
last_checked: str | None = None
fail_count: int
class MafileSession(BaseModel):
SessionID: str = ""
AccessToken: str = ""
RefreshToken: str = ""
SteamID: int = 0
SteamLoginSecure: str = ""
class MafileData(BaseModel):
shared_secret: str
serial_number: str = ""
revocation_code: str = ""
uri: str = ""
account_name: str = ""
token_gid: str = ""
identity_secret: str = ""
secret_1: str = ""
device_id: str = ""
server_time: str = ""
fully_enrolled: bool = False
Session: MafileSession = Field(default_factory=MafileSession)
class TaskOut(BaseModel):
id: str
type: str
status: str
progress: int
total: int
result: str | None = None
error: str | None = None
account_ids: str | None = None
account_results: str | None = None
created_at: str
updated_at: str
class ActionRequest(BaseModel):
account_ids: list[int]
action: str
params: dict | None = None
class BulkImportResult(BaseModel):
imported: int = 0
skipped: int = 0
errors: list[str] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Log:pass accounts
# ---------------------------------------------------------------------------
class LogpassAccountCreate(BaseModel):
login: str
password: str
steam_id: str | None = None
proxy: str | None = None
ban_status: str | None = None
prime: str | None = None
trophy: str | None = None
behavior: str | None = None
license: str | None = None
notes: str | None = None
class LogpassAccountUpdate(BaseModel):
login: str | None = None
password: str | None = None
steam_id: str | None = None
proxy: str | None = None
status: str | None = None
ban_status: str | None = None
nickname: str | None = None
steam_level: int | None = None
prime: str | None = None
trophy: str | None = None
behavior: str | None = None
license: str | None = None
notes: str | None = None
class LogpassAccountOut(BaseModel):
id: int
login: str
password: str
steam_id: str | None = None
proxy: str | None = None
status: str
ban_status: str | None = None
nickname: str | None = None
steam_level: int | None = None
prime: str | None = None
trophy: str | None = None
behavior: str | None = None
license: str | None = None
notes: str | None = None
avatar_url: str | None = None
last_online: str | None = None
has_cookies: bool = False
created_at: str
updated_at: str
@model_validator(mode="before")
@classmethod
def _compute_has_cookies(cls, data):
if isinstance(data, dict):
data["has_cookies"] = bool(data.get("session_cookies"))
return data
# ---------------------------------------------------------------------------
# Token accounts
# ---------------------------------------------------------------------------
class TokenAccountCreate(BaseModel):
login: str | None = None
token: str
steam_id: str | None = None
proxy: str | None = None
notes: str | None = None
class TokenAccountUpdate(BaseModel):
login: str | None = None
token: str | None = None
steam_id: str | None = None
proxy: str | None = None
status: str | None = None
notes: str | None = None
class TokenAccountOut(BaseModel):
id: int
login: str | None = None
token: str
steam_id: str | None = None
proxy: str | None = None
status: str
ban_status: str | None = None
nickname: str | None = None
steam_level: int | None = None
avatar_url: str | None = None
last_online: str | None = None
has_cookies: bool = False
notes: str | None = None
created_at: str
updated_at: str
@model_validator(mode="before")
@classmethod
def _compute_has_cookies(cls, data):
if isinstance(data, dict):
data["has_cookies"] = bool(data.get("session_cookies"))
return data
View File
+96
View File
@@ -0,0 +1,96 @@
import asyncio
import json
from loguru import logger
_active_browsers: list = []
STEAM_DOMAINS = [
".steamcommunity.com",
".steampowered.com",
".store.steampowered.com",
".help.steampowered.com",
".login.steampowered.com",
]
async def open_browser_with_cookies(account: dict) -> None:
"""Open a Chrome browser with Steam session cookies using nodriver."""
import nodriver as uc
cookies_json = account.get("session_cookies")
if not cookies_json:
raise ValueError("No session cookies saved. Validate the account first.")
cookies = json.loads(cookies_json)
browser = await uc.start()
_active_browsers.append(browser)
tab = await browser.get("about:blank")
for c in cookies:
domain = c.get("domain") or ".steamcommunity.com"
domains_to_set = [domain]
if "steam" in domain:
domains_to_set = list(set([domain] + STEAM_DOMAINS))
for d in domains_to_set:
try:
await tab.send(uc.cdp.network.set_cookie(
name=c["name"],
value=c["value"],
domain=d,
path=c.get("path") or "/",
secure=c.get("secure", True),
http_only=c.get("httpOnly", False),
))
except Exception:
pass
steam_id = account.get("steam_id") or ""
if steam_id:
start_url = f"https://steamcommunity.com/profiles/{steam_id}/"
else:
start_url = "https://steamcommunity.com/"
await browser.get(start_url)
logger.info(f"Browser opened for {account['login']}")
# Wait until browser is closed by user, suppress connection errors
try:
while browser:
await asyncio.sleep(1)
try:
if not browser.connection or browser.connection.closed:
break
except Exception:
break
except Exception:
pass
finally:
if browser in _active_browsers:
_active_browsers.remove(browser)
# Temporarily suppress nodriver's internal task errors during cleanup
# (Browser.update_targets() raises ConnectionRefusedError after close)
loop = asyncio.get_running_loop()
_original_handler = loop.get_exception_handler()
def _suppress_nodriver_close_errors(loop, context):
exc = context.get("exception")
if isinstance(exc, (ConnectionRefusedError, ConnectionResetError, OSError)):
return
if _original_handler:
_original_handler(loop, context)
else:
loop.default_exception_handler(context)
loop.set_exception_handler(_suppress_nodriver_close_errors)
try:
browser.stop()
except Exception:
pass
await asyncio.sleep(0.5)
loop.set_exception_handler(_original_handler)
logger.debug(f"Browser closed for {account['login']}")
+20
View File
@@ -0,0 +1,20 @@
from app.core.task_manager import task_manager
from app.services.steam_guard import remove_guard, generate_2fa
from app.services.steam_password import change_password, random_password
from app.services.steam_email import change_email, validate_account
from app.services.steam_phone import change_phone
from app.services.steam_checker import check_logpass_account, full_parse_logpass_account
from app.services.steam_token_checker import check_token_account
def register_all_handlers() -> None:
task_manager.register_handler("change_password", change_password)
task_manager.register_handler("random_password", random_password)
task_manager.register_handler("change_email", change_email)
task_manager.register_handler("change_phone", change_phone)
task_manager.register_handler("remove_guard", remove_guard)
task_manager.register_handler("generate_2fa", generate_2fa)
task_manager.register_handler("validate", validate_account)
task_manager.register_handler("logpass_validate", check_logpass_account)
task_manager.register_handler("logpass_full_parse", full_parse_logpass_account)
task_manager.register_handler("token_validate", check_token_account)
+192
View File
@@ -0,0 +1,192 @@
from __future__ import annotations
import json
from pathlib import Path
from typing import TYPE_CHECKING
import aiohttp
from aiohttp import ClientSession, ClientTimeout
from loguru import logger
from app.core.exceptions import SteamAuthError
from app.core.proxy_manager import proxy_manager
from pysteamauth.base.request import BaseRequestStrategy, DEFAULT_REQUEST_TIMEOUT
class ProxyRequestStrategy(BaseRequestStrategy):
"""Request strategy that routes traffic through a proxy connector."""
def __init__(self, connector: aiohttp.BaseConnector):
super().__init__()
self._proxy_connector = connector
def _create_session(self) -> ClientSession:
return ClientSession(
connector=self._proxy_connector,
timeout=DEFAULT_REQUEST_TIMEOUT,
)
def read_mafile_data(account: dict) -> dict | None:
"""Read and parse the account's mafile JSON from disk."""
mafile_path = account.get("mafile_path")
if not mafile_path:
return None
try:
return json.loads(Path(mafile_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError):
return None
async def _resolve_proxy(account: dict) -> dict | None:
"""Determine proxy dict {address, protocol} for the account."""
proxy_address = account.get("proxy")
if proxy_address:
from app.database import get_db
db = await get_db()
cursor = await db.execute(
"SELECT protocol FROM proxies WHERE address = ?", (proxy_address,)
)
row = await cursor.fetchone()
protocol = row["protocol"] if row else "http"
return {"address": proxy_address, "protocol": protocol}
if proxy_manager.count > 0:
return proxy_manager.get_next()
return None
async def create_steam_session(account: dict):
"""Create an authenticated Steam session for an account."""
try:
from pysteamauth.auth import Steam
except ImportError:
raise SteamAuthError("pysteamauth not installed: pip install pysteamauth")
proxy = await _resolve_proxy(account)
request_strategy = None
if proxy:
connector = proxy_manager.get_connector(proxy)
request_strategy = ProxyRequestStrategy(connector)
logger.debug(f"Using proxy {proxy['protocol']}://{proxy['address']} for {account['login']}")
mafile = read_mafile_data(account)
shared_secret = account.get("shared_secret", "")
identity_secret = account.get("identity_secret", "")
device_id = None
steamid = None
if mafile:
shared_secret = mafile.get("shared_secret", shared_secret)
identity_secret = mafile.get("identity_secret", identity_secret)
device_id = mafile.get("device_id")
steamid = int(mafile.get("Session", {}).get("SteamID", 0)) or None
steam = Steam(
login=account["login"],
password=account["password"],
shared_secret=shared_secret,
identity_secret=identity_secret,
device_id=device_id,
steamid=steamid,
request_strategy=request_strategy,
)
try:
await steam.login_to_steam()
except Exception as exc:
await close_steam(steam)
exc_str = str(exc)
# Pydantic ValidationError from pysteamauth = Steam rejected login
if "validation error" in exc_str.lower() and "FinalizeLoginStatus" in exc_str:
# Extract the raw response dict from the error
m = __import__("re").search(r"input_value=(\{[^}]+\})", exc_str)
raw = m.group(1) if m else ""
logger.error(f"Auth failed for {account['login']}: Steam rejected login: {raw}")
raise SteamAuthError(f"Login failed: Steam rejected credentials ({raw})")
logger.error(f"Auth failed for {account['login']}: {exc}")
raise SteamAuthError(f"Login failed: {exc}")
return steam
async def close_steam(steam) -> None:
"""Close the underlying aiohttp session and proxy connector of a pysteamauth Steam object."""
requests = getattr(steam, "_requests", None)
if requests is None:
return
session = getattr(requests, "_session", None)
if session is not None and not getattr(session, "closed", True):
try:
await session.close()
except Exception:
pass
requests._session = None
connector = getattr(requests, "_proxy_connector", None)
if connector is not None and not getattr(connector, "closed", True):
try:
await connector.close()
except Exception:
pass
def extract_session_cookies(steam) -> str | None:
"""Extract session cookies from a pysteamauth Steam object as JSON string.
Returns JSON string of cookies list, or None if extraction fails.
"""
try:
_req = getattr(steam, "_requests", None)
_sess = getattr(_req, "_session", None) if _req else None
if not _sess or not hasattr(_sess, "cookie_jar"):
return None
all_cookies = []
for c in _sess.cookie_jar:
all_cookies.append({
"name": c.key,
"value": c.value,
"domain": c.get("domain", ""),
"path": c.get("path", "/"),
"secure": str(c.get("secure", "")).lower() == "true" or c.get("secure") is True,
"httpOnly": str(c.get("httponly", "")).lower() == "true" or c.get("httponly") is True,
})
return json.dumps(all_cookies)
except Exception:
return None
async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool:
"""Check if Steam session cookies are still valid.
Makes a lightweight request to Steam's clientjstoken endpoint.
Returns True if logged in, False otherwise.
"""
try:
cookies = json.loads(cookies_json)
jar = aiohttp.CookieJar(unsafe=True)
for c in cookies:
jar.update_cookies(
{c["name"]: c["value"]},
response_url=__import__("yarl").URL(f"https://{c.get('domain', '.steamcommunity.com').lstrip('.')}"),
)
connector = proxy_manager.get_connector(proxy) if proxy else None
async with aiohttp.ClientSession(cookie_jar=jar, connector=connector) as session:
async with session.get(
"https://steamcommunity.com/chat/clientjstoken",
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status != 200:
return False
data = await resp.json(content_type=None)
return bool(data.get("logged_in"))
except Exception:
return False
async def raw_request(steam, url: str, method: str = "GET", **kwargs) -> aiohttp.ClientResponse:
"""Low-level request returning raw aiohttp.ClientResponse (for redirects, etc.)."""
from yarl import URL
host = URL(url).host or ""
cookies = await steam.cookies(host)
return await steam._requests.request(url=url, method=method, cookies=cookies, **kwargs)
+220
View File
@@ -0,0 +1,220 @@
"""Steam auto-accept login confirmations — ported from legacy/steam_auto_accept_logins.py."""
import asyncio
import base64
import hashlib
import hmac
import struct
import aiohttp
import rsa
from loguru import logger
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
CAuthentication_GetPasswordRSAPublicKey_Request,
CAuthentication_GetPasswordRSAPublicKey_Response,
CAuthentication_BeginAuthSessionViaCredentials_Request,
CAuthentication_BeginAuthSessionViaCredentials_Response,
CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request,
CAuthentication_PollAuthSessionStatus_Request,
CAuthentication_PollAuthSessionStatus_Response,
CAuthentication_GetAuthSessionsForAccount_Response,
CAuthentication_GetAuthSessionInfo_Request,
CAuthentication_GetAuthSessionInfo_Response,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request,
CAuthentication_UpdateAuthSessionWithMobileConfirmation_Response,
EAuthTokenPlatformType,
EAuthSessionGuardType,
)
BASE_URL = "https://api.steampowered.com"
HEADERS = {"User-Agent": "okhttp/4.9.2", "Cookie": "Steam_Language=english"}
def _generate_2fa_code(shared_secret: str, server_time: int | None = None) -> str:
"""Generate a Steam Guard TOTP code from shared_secret."""
import time as _time
if server_time is None:
server_time = int(_time.time())
key = base64.b64decode(shared_secret)
msg = struct.pack(">Q", server_time // 30)
mac = hmac.new(key, msg, hashlib.sha1).digest()
offset = mac[-1] & 0x0F
code_int = struct.unpack(">I", mac[offset : offset + 4])[0] & 0x7FFFFFFF
chars = "23456789BCDFGHJKMNPQRTVWXY"
code = ""
for _ in range(5):
code += chars[code_int % len(chars)]
code_int //= len(chars)
return code
def _confirmation_signature(shared_secret: str, client_id: int, steamid: int) -> bytes:
"""HMAC-SHA256 signature for login confirmation (version=1 + client_id + steamid)."""
key = base64.b64decode(shared_secret)
data = struct.pack("<HQQ", 1, client_id, steamid)
return hmac.new(key, data, hashlib.sha256).digest()
async def mobile_login(session: aiohttp.ClientSession, account: dict) -> str | None:
"""Perform mobile login flow, return access_token or None."""
login = account["login"]
password = account["password"]
shared_secret = account.get("shared_secret", "")
try:
# GetPasswordRSAPublicKey
req = CAuthentication_GetPasswordRSAPublicKey_Request()
req.account_name = login
encoded = base64.b64encode(req.SerializeToString()).decode()
async with session.get(
f"{BASE_URL}/IAuthenticationService/GetPasswordRSAPublicKey/v1",
params={"origin": "SteamMobile", "input_protobuf_encoded": encoded},
headers=HEADERS,
) as resp:
if resp.status != 200:
logger.error(f"[auto-accept] RSA key failed for {login}: HTTP {resp.status}")
return None
rsa_resp = CAuthentication_GetPasswordRSAPublicKey_Response.FromString(await resp.read())
pub_key = rsa.PublicKey(int(rsa_resp.publickey_mod, 16), int(rsa_resp.publickey_exp, 16))
encrypted_pw = base64.b64encode(rsa.encrypt(password.encode(), pub_key)).decode()
# BeginAuthSession
begin = CAuthentication_BeginAuthSessionViaCredentials_Request()
begin.account_name = login
begin.encrypted_password = encrypted_pw
begin.encryption_timestamp = rsa_resp.timestamp
begin.platform_type = EAuthTokenPlatformType.k_EAuthTokenPlatformType_MobileApp
begin.device_friendly_name = "Android Device"
begin.persistence = 1
data = aiohttp.FormData()
data.add_field("input_protobuf_encoded", base64.b64encode(begin.SerializeToString()).decode())
async with session.post(
f"{BASE_URL}/IAuthenticationService/BeginAuthSessionViaCredentials/v1",
data=data,
headers=HEADERS,
) as resp:
if resp.status != 200:
logger.error(f"[auto-accept] BeginAuth failed for {login}: HTTP {resp.status}")
return None
begin_resp = CAuthentication_BeginAuthSessionViaCredentials_Response.FromString(await resp.read())
# Send 2FA code
if shared_secret:
code = _generate_2fa_code(shared_secret)
upd = CAuthentication_UpdateAuthSessionWithSteamGuardCode_Request()
upd.client_id = begin_resp.client_id
upd.steamid = begin_resp.steamid
upd.code = code
upd.code_type = EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceCode
data = aiohttp.FormData()
data.add_field("input_protobuf_encoded", base64.b64encode(upd.SerializeToString()).decode())
async with session.post(
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithSteamGuardCode/v1",
data=data,
headers=HEADERS,
) as resp:
if resp.status != 200:
logger.error(f"[auto-accept] 2FA submit failed for {login}: HTTP {resp.status}")
return None
# PollAuthSessionStatus
poll = CAuthentication_PollAuthSessionStatus_Request()
poll.client_id = begin_resp.client_id
poll.request_id = begin_resp.request_id
data = aiohttp.FormData()
data.add_field("input_protobuf_encoded", base64.b64encode(poll.SerializeToString()).decode())
async with session.post(
f"{BASE_URL}/IAuthenticationService/PollAuthSessionStatus/v1",
data=data,
headers=HEADERS,
) as resp:
if resp.status != 200:
logger.error(f"[auto-accept] Poll failed for {login}: HTTP {resp.status}")
return None
poll_resp = CAuthentication_PollAuthSessionStatus_Response.FromString(await resp.read())
if not poll_resp.access_token:
logger.error(f"[auto-accept] No access_token for {login}")
return None
logger.success(f"[auto-accept] Mobile login OK for {login}")
return poll_resp.access_token
except Exception as exc:
logger.error(f"[auto-accept] Login error for {login}: {exc}")
return None
async def get_pending_sessions(session: aiohttp.ClientSession, access_token: str) -> list[int] | None:
"""Get pending auth session client_ids. Returns None on 401 (token expired)."""
try:
async with session.get(
f"{BASE_URL}/IAuthenticationService/GetAuthSessionsForAccount/v1",
params={
"access_token": access_token,
"origin": "SteamMobile",
"input_protobuf_encoded": "",
},
headers=HEADERS,
) as resp:
if resp.status == 401:
return None
if resp.status != 200:
return []
data = CAuthentication_GetAuthSessionsForAccount_Response.FromString(await resp.read())
return list(data.client_ids)
except Exception as exc:
logger.warning(f"[auto-accept] get_pending_sessions error: {exc}")
return []
async def confirm_session(
session: aiohttp.ClientSession,
access_token: str,
account: dict,
client_id: int,
) -> bool:
"""Confirm a pending login session."""
steam_id = int(account.get("steam_id", 0))
shared_secret = account.get("shared_secret", "")
if not steam_id or not shared_secret:
return False
try:
signature = _confirmation_signature(shared_secret, client_id, steam_id)
msg = CAuthentication_UpdateAuthSessionWithMobileConfirmation_Request()
msg.version = 1
msg.client_id = client_id
msg.steamid = steam_id
msg.signature = signature
msg.confirm = True
msg.persistence = 1
data = aiohttp.FormData()
data.add_field("input_protobuf_encoded", base64.b64encode(msg.SerializeToString()).decode())
async with session.post(
f"{BASE_URL}/IAuthenticationService/UpdateAuthSessionWithMobileConfirmation/v1",
params={"access_token": access_token},
data=data,
headers=HEADERS,
) as resp:
if resp.status == 200:
logger.success(f"[auto-accept] Confirmed login for {account['login']} (client_id={client_id})")
return True
logger.warning(f"[auto-accept] Confirm failed for {account['login']}: HTTP {resp.status}")
return False
except Exception as exc:
logger.error(f"[auto-accept] Confirm error for {account['login']}: {exc}")
return False
+29
View File
@@ -0,0 +1,29 @@
"""Steam account ban/alert checking via supportmessages page."""
from loguru import logger
async def check_ban(steam) -> str:
"""Check if account has active bans/alerts.
Returns: 'BANNED', 'NO BAN', or '' on error.
"""
try:
response_html = await steam.request(
"https://store.steampowered.com/supportmessages/",
method="GET",
)
if isinstance(response_html, bytes):
response_html = response_html.decode("utf-8", errors="replace")
if "support_message_page" in response_html or "This account has been locked" in response_html:
return "BANNED"
elif "It doesn't appear that you have any active account alerts" in response_html:
return "NO BAN"
else:
return "NO BAN"
except Exception as exc:
logger.warning(f"Ban check failed: {exc}")
return ""
+297
View File
@@ -0,0 +1,297 @@
"""Validation service for log:pass accounts (no mafile)."""
from pathlib import Path
from bs4 import BeautifulSoup
from loguru import logger
from app.services.steam_auth import _resolve_proxy, ProxyRequestStrategy, close_steam, extract_session_cookies
from app.services.steam_ban import check_ban
from app.services.steam_profile import fetch_profile
from app.core.proxy_manager import proxy_manager
from app.core.task_manager import task_manager
async def check_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
"""Login with login:pass only, get steamid/nickname, check ban, update DB row."""
try:
from pysteamauth.auth import Steam
except ImportError:
raise RuntimeError("pysteamauth not installed")
acc_id = account["id"]
proxy = await _resolve_proxy(account)
request_strategy = None
if proxy:
connector = proxy_manager.get_connector(proxy)
request_strategy = ProxyRequestStrategy(connector)
logger.debug(f"[checker] Using proxy for {account['login']}")
steam = Steam(
login=account["login"],
password=account["password"],
request_strategy=request_strategy,
)
try:
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id)
# Pre-validate password before attempting RSA encryption (Steam RSA-2048 max 245 bytes)
password = account.get("password") or ""
if len(password.encode("utf-8")) > 245:
raise ValueError(
f"Password is too long ({len(password)} chars) — possible mafile data imported as password. "
"Re-import the account in login:password format."
)
await steam.login_to_steam()
# Save session cookies right after login
try:
session_cookies_json = extract_session_cookies(steam)
if session_cookies_json:
from app.database import get_db as _get_db
_db = await _get_db()
await _db.execute(
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
(session_cookies_json, acc_id),
)
await _db.commit()
logger.debug(f"[checker] {account['login']}: saved session cookies")
except Exception as cookie_err:
logger.warning(f"[checker] {account['login']}: failed to save cookies — {cookie_err}")
from app.config import read_validation_settings
val_settings = read_validation_settings()
steam_id = str(steam.steamid)
ban_status = ""
if val_settings.get("check_ban"):
await task_manager.set_step(task_id, 2, 3, "Проверка бана", acc_id)
ban_status = await check_ban(steam)
nickname = None
steam_level = None
avatar_url = None
last_online = None
if steam_id and steam_id != "0" and val_settings.get("fetch_profile"):
await task_manager.set_step(task_id, 3, 3, "Получение профиля", acc_id)
profile = await fetch_profile(steam_id, steam=steam)
if profile:
nickname = profile.get("nickname")
steam_level = profile.get("steam_level")
avatar_url = profile.get("avatar_url")
last_online = profile.get("last_online")
from app.database import get_db
db = await get_db()
await db.execute(
"""UPDATE logpass_accounts
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
ban_status = ?, status = 'valid', updated_at = datetime('now')
WHERE id = ?""",
(steam_id, nickname, steam_level, avatar_url, last_online, ban_status, acc_id),
)
await db.commit()
logger.success(f"[checker] {account['login']} → valid, ban={ban_status}")
except Exception as exc:
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
(acc_id,),
)
await db.commit()
logger.error(f"[checker] {account['login']}{exc}")
raise
finally:
await close_steam(steam)
async def _check_cs2_prime(steam, steam_id: str) -> str:
"""Check CS2 Prime status."""
try:
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/730/?tab=primeaccount"
resp = await steam.request(url, method="GET")
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
if "no_personal_data_stored_message" in html:
return "Disabled"
if "personaldata_elements_container" in html and "Enabled" in html:
return "Enabled"
return "Disabled"
except Exception as e:
logger.debug(f"[full_parse] prime check error: {e}")
return "Disabled"
async def _check_dota_trophy(steam, steam_id: str) -> str | None:
"""Check Dota 2 Trophy Score."""
try:
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Stats&tab=Trophy"
resp = await steam.request(url, method="GET")
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
if "no_personal_data_stored_message" in html:
return None
if "personaldata_elements_container" in html:
soup = BeautifulSoup(html, "html.parser")
for row in soup.find_all("tr"):
cells = row.find_all("td")
if len(cells) >= 3:
try:
int(cells[0].get_text(strip=True))
score = cells[1].get_text(strip=True)
if score.isdigit():
return score
except (ValueError, AttributeError):
continue
return None
except Exception as e:
logger.debug(f"[full_parse] trophy check error: {e}")
return None
async def _check_dota_behavior(steam, steam_id: str) -> str | None:
"""Check Dota 2 Behavior Score."""
try:
url = f"https://steamcommunity.com/profiles/{steam_id}/gcpd/570/?category=Account&tab=MatchPlayerReportIncoming"
resp = await steam.request(url, method="GET")
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
if "no_personal_data_stored_message" in html:
return None
if "personaldata_elements_container" in html:
soup = BeautifulSoup(html, "html.parser")
for row in soup.find_all("tr"):
cells = row.find_all("td")
if cells:
last_cell = cells[-1].get_text(strip=True)
if last_cell.isdigit():
return last_cell
return None
except Exception as e:
logger.debug(f"[full_parse] behavior check error: {e}")
return None
async def _check_licenses(steam) -> str:
"""Get list of licenses (games) from Steam account."""
try:
resp = await steam.request("https://store.steampowered.com/account/licenses/", method="GET")
html = resp if isinstance(resp, str) else resp.decode("utf-8", errors="ignore")
soup = BeautifulSoup(html, "html.parser")
table = soup.find("table", class_="account_table")
if not table:
return ""
licenses = []
rows = table.find_all("tr")
for row in rows[1:]:
cells = row.find_all("td")
if len(cells) >= 2:
game_cell = cells[1]
remove_div = game_cell.find("div", class_="free_license_remove_link")
if remove_div:
remove_div.decompose()
name = game_cell.get_text(strip=True)
if name:
licenses.append(name)
return ", ".join(licenses)
except Exception as e:
logger.debug(f"[full_parse] licenses check error: {e}")
return ""
async def full_parse_logpass_account(account: dict, params: dict, *, task_id: str) -> None:
"""Full parse: login, ban, profile, prime, trophy, behavior, licenses."""
try:
from pysteamauth.auth import Steam
except ImportError:
raise RuntimeError("pysteamauth not installed")
acc_id = account["id"]
proxy = await _resolve_proxy(account)
request_strategy = None
if proxy:
connector = proxy_manager.get_connector(proxy)
request_strategy = ProxyRequestStrategy(connector)
steam = Steam(
login=account["login"],
password=account["password"],
request_strategy=request_strategy,
)
try:
await task_manager.set_step(task_id, 1, 6, "Авторизация", acc_id)
await steam.login_to_steam()
# Save session cookies
try:
session_cookies_json = extract_session_cookies(steam)
if session_cookies_json:
from app.database import get_db as _get_db
_db = await _get_db()
await _db.execute(
"UPDATE logpass_accounts SET session_cookies = ? WHERE id = ?",
(session_cookies_json, acc_id),
)
await _db.commit()
except Exception as cookie_err:
logger.warning(f"[full_parse] {account['login']}: failed to save cookies — {cookie_err}")
steam_id = str(steam.steamid)
await task_manager.set_step(task_id, 2, 6, "Проверка бана", acc_id)
ban_status = await check_ban(steam)
nickname = avatar_url = last_online = None
steam_level = None
if steam_id and steam_id != "0":
await task_manager.set_step(task_id, 3, 6, "Профиль", acc_id)
profile = await fetch_profile(steam_id, steam=steam)
if profile:
nickname = profile.get("nickname")
steam_level = profile.get("steam_level")
avatar_url = profile.get("avatar_url")
last_online = profile.get("last_online")
prime = "Disabled"
trophy = None
behavior = None
if steam_id and steam_id != "0":
await task_manager.set_step(task_id, 4, 6, "Prime / Trophy", acc_id)
prime = await _check_cs2_prime(steam, steam_id)
trophy = await _check_dota_trophy(steam, steam_id)
behavior = await _check_dota_behavior(steam, steam_id)
await task_manager.set_step(task_id, 5, 6, "Лицензии", acc_id)
license_str = await _check_licenses(steam)
await task_manager.set_step(task_id, 6, 6, "Сохранение", acc_id)
from app.database import get_db
db = await get_db()
await db.execute(
"""UPDATE logpass_accounts
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
ban_status = ?, prime = ?, trophy = ?, behavior = ?, license = ?,
status = 'valid', updated_at = datetime('now')
WHERE id = ?""",
(steam_id, nickname, steam_level, avatar_url, last_online,
ban_status, prime, trophy, behavior, license_str, acc_id),
)
await db.commit()
logger.success(f"[full_parse] {account['login']} → valid, ban={ban_status}, prime={prime}, trophy={trophy}, behavior={behavior}, licenses={len(license_str.split(', ')) if license_str else 0}")
except Exception as exc:
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE logpass_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
(acc_id,),
)
await db.commit()
logger.error(f"[full_parse] {account['login']}{exc}")
raise
finally:
await close_steam(steam)
+201
View File
@@ -0,0 +1,201 @@
import asyncio
import json
from loguru import logger
from app.services.steam_auth import create_steam_session, close_steam, extract_session_cookies
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
def _get_fresh_sessionid(steam) -> str:
"""Get sessionid from the live aiohttp cookie jar."""
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
async def change_email(account: dict, params: dict, task_id: str = "") -> None:
"""Change account email via Steam recovery wizard + interactive prompts."""
from app.core.task_manager import task_manager
login = account["login"]
new_email = params.get("new_email", "")
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
logger.info(f"[change_email] {login}: logging in to Steam...")
steam = await create_steam_session(account)
try:
password = account["password"]
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
logger.info(f"[change_email] {login}: auth ok, starting email change wizard...")
wizard = await run_common_wizard(
steam,
entry_url="https://help.steampowered.com/wizard/HelpChangeEmail?redir=store/account/",
login=login,
password=password,
)
logger.info(f"[change_email] {login}: wizard done")
if not new_email:
new_email = await task_manager.prompt_user(task_id, f"Введите новый email для {login}", login=login)
if not new_email:
raise ValueError("new_email is required")
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
logger.info(f"[change_email] {login}: requesting email change -> {new_email}...")
for attempt in range(2):
fresh_sid = _get_fresh_sessionid(steam)
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangeEmail/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"email": new_email,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[change_email] {login}: change request attempt {attempt+1} response: {data}")
if data.get("errorMsg"):
error_code = data.get("success", 0)
if error_code == 24:
raise PermissionError(f"Insufficient privileges for email change: {data['errorMsg']}")
raise RuntimeError(f"Email change request failed: {data['errorMsg']}")
if attempt == 0:
await asyncio.sleep(3)
await task_manager.set_step(task_id, 4, 5, "Код подтверждения", acc_id=account["id"])
email_code = await task_manager.prompt_user(task_id, f"Введите код подтверждения с email ({new_email})", login=login)
if not email_code:
raise ValueError("email_code is required")
email_code = email_code.strip()
logger.info(f"[change_email] {login}: confirming email change...")
fresh_sid = _get_fresh_sessionid(steam)
confirm_text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangeEmail/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"email": new_email,
"email_change_code": email_code,
},
headers=AJAX_HEADERS,
)
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
logger.debug(f"[change_email] {login}: confirm response: success={confirm_data.get('success')}")
error_code = confirm_data.get("success", 0)
if confirm_data.get("errorMsg") and error_code != 29:
raise RuntimeError(f"Email confirmation failed: {confirm_data['errorMsg']}")
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE accounts SET email = ?, updated_at = datetime('now') WHERE id = ?",
(new_email, account["id"]),
)
await db.commit()
logger.success(f"[change_email] {login}: email changed successfully -> {new_email}")
finally:
await close_steam(steam)
async def validate_account(account: dict, params: dict, task_id: str = "") -> None:
"""Validate account credentials by attempting login. Fetches profile and ban status on success."""
from app.database import get_db
from app.services.steam_auth import close_steam
from app.services.steam_profile import fetch_profile
from app.services.steam_ban import check_ban
from app.core.task_manager import task_manager
from app.config import read_validation_settings
steam = None
error_msg = None
ban_status = ""
val_settings = read_validation_settings()
login = account["login"]
profile = None
try:
await task_manager.set_step(task_id, 1, 4, "Авторизация", acc_id=account["id"])
logger.info(f"[validate] {login}: logging in to Steam...")
steam = await create_steam_session(account)
status = "valid"
logger.info(f"[validate] {login}: auth ok")
if status == "valid" and steam is not None:
if val_settings.get("check_ban"):
await task_manager.set_step(task_id, 2, 4, "Проверка бана", acc_id=account["id"])
logger.info(f"[validate] {login}: checking ban status...")
ban_status = await check_ban(steam)
logger.info(f"[validate] {login}: ban = {ban_status or 'none'}")
if account.get("steam_id") and val_settings.get("fetch_profile"):
await task_manager.set_step(task_id, 3, 4, "Профиль", acc_id=account["id"])
logger.info(f"[validate] {login}: fetching profile (id={account['steam_id']})...")
profile = await fetch_profile(account["steam_id"], steam=steam)
if profile:
logger.info(f"[validate] {login}: profile fetched — {profile.get('nickname')}, lvl={profile.get('steam_level')}")
else:
logger.warning(f"[validate] {login}: failed to fetch profile")
await task_manager.set_step(task_id, 4, 4, "Сохранение", acc_id=account["id"])
if steam is not None and status == "valid":
try:
session_cookies = extract_session_cookies(steam)
if session_cookies:
_db = await get_db()
await _db.execute(
"UPDATE accounts SET session_cookies = ?, updated_at = datetime('now') WHERE id = ?",
(session_cookies, account["id"]),
)
await _db.commit()
logger.debug(f"[validate] {login}: saved session cookies")
except Exception as cookie_err:
logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}")
except Exception as exc:
status = "invalid"
error_msg = str(exc)
logger.warning(f"[validate] {login}: error — {error_msg}")
finally:
if steam is not None:
await close_steam(steam)
db = await get_db()
await db.execute(
"UPDATE accounts SET status = ?, ban_status = ?, updated_at = datetime('now') WHERE id = ?",
(status, ban_status or None, account["id"]),
)
await db.commit()
if profile:
sets = []
vals = []
for col, key in [("nickname", "nickname"), ("avatar_url", "avatar_url"), ("steam_level", "steam_level"), ("last_online", "last_online")]:
if profile.get(key) is not None:
sets.append(f"{col} = ?")
vals.append(profile[key])
if sets:
sets.append("updated_at = datetime('now')")
vals.append(account["id"])
await db.execute(f"UPDATE accounts SET {', '.join(sets)} WHERE id = ?", vals)
await db.commit()
logger.info(f"[validate] {login}: profile saved — {profile.get('nickname')}")
if error_msg:
raise Exception(f"{error_msg}")
else:
ban_info = f" | ban={ban_status}" if ban_status else ""
logger.info(f"[validate] {login}: result — {status}{ban_info}")
+83
View File
@@ -0,0 +1,83 @@
import base64
import hashlib
import hmac
import struct
import time
from loguru import logger
def generate_2fa_code(shared_secret: str) -> str:
"""Generate a Steam Guard 2FA code from shared_secret."""
timestamp = int(time.time()) // 30
msg = struct.pack(">Q", timestamp)
key = base64.b64decode(shared_secret)
auth = hmac.new(key, msg, hashlib.sha1).digest()
offset = auth[19] & 0xF
code = struct.unpack(">I", auth[offset : offset + 4])[0] & 0x7FFFFFFF
chars = "23456789BCDFGHJKMNPQRTVWXY"
result = []
for _ in range(5):
result.append(chars[code % len(chars)])
code //= len(chars)
return "".join(result)
async def remove_guard(account: dict, params: dict, task_id: str = "") -> None:
"""Remove Steam Guard 2FA via revocation code from mafile."""
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
from app.core.task_manager import task_manager
mafile = read_mafile_data(account)
revocation_code = (mafile or {}).get("revocation_code", "") if mafile else ""
if not revocation_code:
raise ValueError("revocation_code not found in mafile")
login = account["login"]
await task_manager.set_step(task_id, 1, 3, "Авторизация", acc_id=account["id"])
logger.info(f"[remove_guard] {login}: logging in to Steam...")
steam = await create_steam_session(account)
logger.info(f"[remove_guard] {login}: auth ok")
try:
await task_manager.set_step(task_id, 2, 3, "Удаление Guard", acc_id=account["id"])
logger.info(f"[remove_guard] {login}: sending Guard removal request...")
sessionid = await steam.sessionid("store.steampowered.com")
await steam.request(
url="https://store.steampowered.com/twofactor/remove",
method="GET",
params={"step": "promptdevice"},
)
await steam.request(
url="https://store.steampowered.com/twofactor/remove",
method="GET",
params={"step": "promptrcode"},
)
await steam.request(
url="https://store.steampowered.com/twofactor/manage_action",
method="POST",
data={"action": "removercode", "sessionid": sessionid},
)
response = await steam.request(
url="https://store.steampowered.com/twofactor/manage_remove_revocation_code",
method="POST",
data={"revocation_code": revocation_code, "sessionid": sessionid},
)
await task_manager.set_step(task_id, 3, 3, "Готово", acc_id=account["id"])
logger.success(f"[remove_guard] {login}: Guard removed")
finally:
await close_steam(steam)
async def generate_2fa(account: dict, params: dict, task_id: str = "") -> None:
"""Generate and log a 2FA code for an account."""
shared_secret = account.get("shared_secret", "")
if not shared_secret:
raise ValueError("No shared_secret for this account")
code = generate_2fa_code(shared_secret)
logger.debug(f"[generate_2fa] {account['login']}: code = {code}")
+122
View File
@@ -0,0 +1,122 @@
import json
import secrets
import string
from pathlib import Path
from loguru import logger
from app.core.crypto import encrypt_password
from app.services.steam_auth import create_steam_session, close_steam, read_mafile_data
from app.services.steam_wizard import (
AJAX_HEADERS,
get_rsa_key,
run_common_wizard,
)
async def change_password(account: dict, params: dict, task_id: str = "") -> None:
"""Change account password via Steam recovery wizard."""
from app.core.task_manager import task_manager
new_password = params.get("new_password", "")
if not new_password:
raise ValueError("new_password is required")
login = account["login"]
mafile_data = read_mafile_data(account)
if not mafile_data:
raise ValueError(f"No mafile found for {login} — cannot change password without 2FA")
identity_secret = mafile_data.get("identity_secret", "")
device_id = mafile_data.get("device_id")
if not identity_secret:
raise ValueError(f"No identity_secret in mafile for {login} — mobile confirmation impossible")
if not device_id:
raise ValueError(f"No device_id in mafile for {login} — mobile confirmation impossible")
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
logger.info(f"[change_password] {login}: logging in to Steam...")
steam = await create_steam_session(account)
logger.info(f"[change_password] {login}: auth ok")
try:
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
logger.info(f"[change_password] {login}: starting password change wizard...")
wizard = await run_common_wizard(
steam,
entry_url="https://help.steampowered.com/wizard/HelpChangePassword?redir=store/account/",
login=login,
password=account["password"],
)
await task_manager.set_step(task_id, 3, 5, "RSA ключ", acc_id=account["id"])
logger.info(f"[change_password] {login}: wizard done, getting RSA key...")
mod, exp, timestamp = await get_rsa_key(steam, login)
logger.info(f"[change_password] {login}: checking new password availability...")
sessionid = await steam.sessionid("help.steampowered.com")
check_text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxCheckPasswordAvailable/",
method="POST",
data={
"sessionid": sessionid,
"wizard_ajax": 1,
"password": new_password,
},
headers=AJAX_HEADERS,
)
check_data = json.loads(check_text) if isinstance(check_text, str) else check_text
if not check_data.get("available"):
raise ValueError("New password is not available (too weak or reused)")
encrypted_new = encrypt_password(new_password, mod, exp)
await task_manager.set_step(task_id, 4, 5, "Отправка запроса", acc_id=account["id"])
logger.info(f"[change_password] {login}: password available, sending change request...")
result_text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePassword/",
method="POST",
data={
"sessionid": sessionid,
"wizard_ajax": 1,
"s": wizard.s,
"account": wizard.account,
"password": encrypted_new,
"rsatimestamp": timestamp,
},
headers=AJAX_HEADERS,
)
result_data = json.loads(result_text) if isinstance(result_text, str) else result_text
logger.debug(f"[change_password] {login}: change response: success={result_data.get('success')}, hasHash={bool(result_data.get('hash'))}")
if result_data.get("errorMsg"):
raise RuntimeError(f"Password change rejected by Steam: {result_data['errorMsg']}")
if not result_data.get("hash"):
raise RuntimeError(f"Password change failed — no confirmation hash from Steam: {result_data}")
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE accounts SET password = ?, updated_at = datetime('now') WHERE id = ?",
(new_password, account["id"]),
)
await db.commit()
logger.success(f"[change_password] {login}: password changed")
finally:
await close_steam(steam)
def _generate_random_password(length: int = 24) -> str:
alphabet = string.ascii_letters + string.digits + "!"
return "".join(secrets.choice(alphabet) for _ in range(length))
async def random_password(account: dict, params: dict, task_id: str = "") -> None:
"""Change account password to a randomly generated one."""
new_password = _generate_random_password()
params["new_password"] = new_password
await change_password(account, params)
+216
View File
@@ -0,0 +1,216 @@
import json
import re
from loguru import logger
from app.services.steam_auth import create_steam_session, close_steam
from app.services.steam_wizard import AJAX_HEADERS, run_common_wizard
_PHONE_RE = re.compile(r"^\+\d{7,15}$")
def _get_fresh_sessionid(steam) -> str:
"""Get sessionid from the live aiohttp cookie jar (not from stale storage)."""
return steam._requests.cookies("help.steampowered.com").get("sessionid", "")
def _validate_phone(phone: str) -> str:
"""Strip spaces/dashes and verify E.164-like format."""
cleaned = re.sub(r"[\s\-()]", "", phone)
if cleaned and not cleaned.startswith("+"):
cleaned = "+" + cleaned
if not _PHONE_RE.match(cleaned):
raise ValueError(
f"Неверный формат номера: '{phone}'. "
"Ожидается международный формат, например +79123456789"
)
return cleaned
async def change_phone(account: dict, params: dict, task_id: str = "") -> None:
"""Change phone number on a Steam account via recovery wizard + interactive prompts."""
from app.core.task_manager import task_manager
login = account["login"]
new_phone = params.get("new_phone", "")
if new_phone:
new_phone = _validate_phone(new_phone)
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
logger.info(f"[change_phone] {login}: logging in to Steam...")
steam = await create_steam_session(account)
try:
password = account["password"]
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
logger.info(f"[change_phone] {login}: auth ok, starting phone change wizard...")
wizard = await run_common_wizard(
steam,
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
login=login,
password=password,
)
logger.info(f"[change_phone] {login}: wizard done")
if not new_phone:
new_phone = await task_manager.prompt_user(task_id, f"Введите новый номер телефона для {login}", login=login)
if not new_phone:
raise ValueError("new_phone is required")
new_phone = _validate_phone(new_phone)
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
logger.info(f"[change_phone] {login}: requesting phone change -> {new_phone}...")
fresh_sid = _get_fresh_sessionid(steam)
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"phone_number": new_phone,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.info(f"[change_phone] {login}: change request response: {data}")
if data.get("errorMsg"):
err = data["errorMsg"]
hint = ""
if "112" in err:
hint = " (номер уже привязан к другому аккаунту, виртуальный/VoIP, или не поддерживается Steam)"
raise RuntimeError(f"Phone change request failed: {err}{hint}")
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
if not sms_code:
raise ValueError("sms_code is required")
sms_code = sms_code.strip()
logger.info(f"[change_phone] {login}: confirming with SMS code...")
fresh_sid = _get_fresh_sessionid(steam)
confirm_text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"phone_number": new_phone,
"phone_change_code": sms_code,
},
headers=AJAX_HEADERS,
)
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
logger.info(f"[change_phone] {login}: confirm response: {confirm_data}")
if confirm_data.get("errorMsg"):
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE accounts SET phone = ?, updated_at = datetime('now') WHERE id = ?",
(new_phone, account["id"]),
)
await db.commit()
logger.success(f"[change_phone] {login}: phone changed -> {new_phone}")
finally:
await close_steam(steam)
async def remove_phone(account: dict, params: dict, task_id: str = "") -> None:
"""Remove phone by changing it to a disposable number + SMS confirmation."""
from app.core.task_manager import task_manager
login = account["login"]
new_phone = params.get("new_phone", "")
if new_phone:
new_phone = _validate_phone(new_phone)
await task_manager.set_step(task_id, 1, 5, "Авторизация", acc_id=account["id"])
logger.info(f"[remove_phone] {login}: logging in to Steam...")
steam = await create_steam_session(account)
try:
password = account["password"]
await task_manager.set_step(task_id, 2, 5, "Wizard", acc_id=account["id"])
logger.info(f"[remove_phone] {login}: starting phone removal wizard...")
wizard = await run_common_wizard(
steam,
entry_url="https://help.steampowered.com/en/wizard/HelpRemovePhoneNumber?redir=store/account",
login=login,
password=password,
)
if not new_phone:
new_phone = await task_manager.prompt_user(task_id, "Введите новый номер телефона (на который переставится старый)", login=login)
if not new_phone:
raise ValueError("new_phone is required for phone removal")
new_phone = _validate_phone(new_phone)
await task_manager.set_step(task_id, 3, 5, "Запрос смены", acc_id=account["id"])
logger.info(f"[remove_phone] {login}: requesting phone change -> {new_phone}...")
fresh_sid = _get_fresh_sessionid(steam)
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryChangePhone/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"phone_number": new_phone,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.info(f"[remove_phone] {login}: change request response: {data}")
if data.get("errorMsg"):
raise RuntimeError(f"Phone change request failed: {data['errorMsg']}")
await task_manager.set_step(task_id, 4, 5, "SMS код", acc_id=account["id"])
sms_code = await task_manager.prompt_user(task_id, f"Введите SMS код с номера {new_phone}", login=login)
if not sms_code:
raise ValueError("sms_code is required")
sms_code = sms_code.strip()
logger.info(f"[remove_phone] {login}: confirming with SMS code...")
fresh_sid = _get_fresh_sessionid(steam)
confirm_text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryConfirmChangePhone/",
method="POST",
data={
"s": wizard.s,
"account": wizard.account,
"sessionid": fresh_sid,
"wizard_ajax": 1,
"gamepad": 0,
"phone_number": new_phone,
"phone_change_code": sms_code,
},
headers=AJAX_HEADERS,
)
confirm_data = json.loads(confirm_text) if isinstance(confirm_text, str) else confirm_text
logger.info(f"[remove_phone] {login}: confirm response: {confirm_data}")
if confirm_data.get("errorMsg"):
raise RuntimeError(f"Phone confirmation failed: {confirm_data['errorMsg']}")
await task_manager.set_step(task_id, 5, 5, "Сохранение", acc_id=account["id"])
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE accounts SET phone = NULL, updated_at = datetime('now') WHERE id = ?",
(account["id"],),
)
await db.commit()
logger.success(f"[remove_phone] {login}: phone removed (changed to {new_phone})")
finally:
await close_steam(steam)
+90
View File
@@ -0,0 +1,90 @@
import re
import aiohttp
from loguru import logger
async def fetch_profile(steam_id: str, steam=None) -> dict | None:
"""Fetch persona name and avatar URL from Steam community profile page.
If *steam* session is provided, uses authenticated cookies.
Returns {"nickname": str, "avatar_url": str, "steam_level": int|None} or None on failure.
"""
url = f"https://steamcommunity.com/profiles/{steam_id}/"
try:
if steam is not None:
from app.services.steam_auth import raw_request
resp = await raw_request(steam, url, method="GET", allow_redirects=True)
if resp.status != 200:
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
return None
html = await resp.text()
else:
headers = {"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"}
async with aiohttp.ClientSession(headers=headers) as session:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as resp:
if resp.status != 200:
logger.warning(f"Profile fetch for {steam_id}: HTTP {resp.status}")
return None
html = await resp.text()
except Exception as exc:
logger.warning(f"Profile fetch error for {steam_id}: {exc}")
return None
nickname = _parse_persona_name(html)
avatar_url = _parse_avatar(html)
steam_level = _parse_level(html)
last_online = _parse_last_online(html)
if not nickname and not avatar_url:
return None
return {"nickname": nickname, "avatar_url": avatar_url, "steam_level": steam_level, "last_online": last_online}
def _parse_persona_name(html: str) -> str | None:
# g_rgProfileData = {"personaname":"jeffreyoelze1999",...}
m = re.search(r'"personaname"\s*:\s*"([^"]*)"', html)
if m:
return m.group(1)
# Fallback: <span class="actual_persona_name">...</span>
m = re.search(r'<span class="actual_persona_name">([^<]+)</span>', html)
return m.group(1) if m else None
def _parse_avatar(html: str) -> str | None:
# 1) g_rgProfileData JSON: {"avatarfull":"https://avatars.fastly.steamstatic.com/..."}
m = re.search(r'"avatarfull"\s*:\s*"(https://[^"]+)"', html)
if m:
return m.group(1).replace("_full.jpg", "_medium.jpg")
# 2) <meta property="og:image" content="https://avatars...">
m = re.search(r'<meta\s+property="og:image"\s+content="(https://avatars[^"]+)"', html)
if m:
return m.group(1).replace("_full.jpg", "_medium.jpg")
# 3) playerAvatarAutoSizeInner img
m = re.search(r'playerAvatarAutoSizeInner[\s\S]{0,200}?<img[^>]+srcset="(https://[^"]+)"', html)
if m:
return m.group(1).replace("_full.jpg", "_medium.jpg")
return None
def _parse_level(html: str) -> int | None:
# <span class="friendPlayerLevelNum">100</span>
m = re.search(r'<span\s+class="friendPlayerLevelNum">(\d+)</span>', html)
return int(m.group(1)) if m else None
def _parse_last_online(html: str) -> str | None:
# "Last Online X days ago" → "Xd", "X hrs ago" → "Xh", "X min ago" → "Xm"
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+day', html, re.IGNORECASE)
if m:
return f"{m.group(1)}d"
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+hr', html, re.IGNORECASE)
if m:
return f"{m.group(1)}h"
m = re.search(r'Last\s+Online[^<]{0,150}?(\d+)\s+min', html, re.IGNORECASE)
if m:
return f"{m.group(1)}m"
if re.search(r'Currently\s+(In-Game|Online)', html, re.IGNORECASE):
return "online"
return None
+176
View File
@@ -0,0 +1,176 @@
"""Validation service for token accounts (refresh token → cookies)."""
import base64
import json as _json
import aiohttp
from aiohttp import FormData
from loguru import logger
from app.services.steam_auth import _resolve_proxy
from app.core.proxy_manager import proxy_manager
from app.core.task_manager import task_manager
from pysteamauth.auth.schemas import FinalizeLoginStatus
def _decode_jwt_payload(token: str) -> dict:
"""Decode the payload section of a JWT without verifying the signature."""
parts = token.split(".")
if len(parts) != 3:
raise ValueError("Token must have 3 parts (header.payload.signature)")
payload_b64 = parts[1]
# Pad base64 if needed
payload_b64 += "=" * (-len(payload_b64) % 4)
payload_bytes = base64.urlsafe_b64decode(payload_b64)
return _json.loads(payload_bytes)
async def check_token_account(account: dict, params: dict, *, task_id: str) -> None:
"""Convert refresh_token → web cookies, fetch profile via cookies, update DB."""
acc_id = account["id"]
token = account["token"]
# Step 1: Decode JWT to get steam_id
await task_manager.set_step(task_id, 1, 4, "Декодирование токена", acc_id)
try:
jwt_payload = _decode_jwt_payload(token)
except Exception as exc:
logger.error(f"[token_checker] {account.get('login', acc_id)}: bad JWT — {exc}")
await _mark_invalid(acc_id)
raise
steam_id = jwt_payload.get("sub")
# Step 2: GET steamcommunity.com for sessionid cookie
await task_manager.set_step(task_id, 2, 4, "Получение sessionid", acc_id)
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
jar = aiohttp.CookieJar(unsafe=True)
headers = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Origin": "https://steamcommunity.com",
}
timeout = aiohttp.ClientTimeout(total=30)
try:
async with aiohttp.ClientSession(
connector=connector, cookie_jar=jar, headers=headers, timeout=timeout,
) as session:
async with session.get("https://steamcommunity.com") as resp:
await resp.read()
sessionid = None
for cookie in jar:
if cookie.key == "sessionid":
sessionid = cookie.value
break
if not sessionid:
raise RuntimeError("Failed to acquire sessionid cookie")
# Step 3: POST /jwt/finalizelogin with refresh_token as nonce
await task_manager.set_step(task_id, 3, 4, "Получение куки", acc_id)
form = FormData(fields=[
("nonce", token),
("sessionid", sessionid),
("redir", "https://steamcommunity.com/login/home/?goto="),
])
async with session.post(
"https://login.steampowered.com/jwt/finalizelogin",
data=form,
) as resp:
resp_text = await resp.text()
if resp.status != 200:
raise RuntimeError(f"finalizelogin HTTP {resp.status}: {resp_text[:200]}")
resp_json = _json.loads(resp_text)
if not resp_json.get("steamID"):
error_msg = resp_json.get("message", resp_text[:200])
raise RuntimeError(f"finalizelogin failed: {error_msg}")
finalize = FinalizeLoginStatus.model_validate(resp_json)
steam_id = finalize.steamID or steam_id
# Post to each transfer_info URL to set domain cookies
for ti in finalize.transfer_info:
form = FormData(fields=[
("nonce", ti.params.nonce),
("auth", ti.params.auth),
("steamID", str(steam_id)),
])
try:
async with session.post(ti.url, data=form) as _resp:
await _resp.read()
except Exception:
pass
# Visit extra domains to collect all cookies
for url in ("https://store.steampowered.com", "https://help.steampowered.com"):
try:
async with session.get(url) as _resp:
await _resp.read()
except Exception:
pass
# Collect cookies from jar
all_cookies = []
for c in jar:
all_cookies.append({
"name": c.key,
"value": c.value,
"domain": c.get("domain", ""),
"path": c.get("path", "/"),
"secure": "secure" in str(c).lower(),
"httpOnly": "httponly" in str(c).lower(),
})
session_cookies_json = _json.dumps(all_cookies)
logger.debug(f"[token_checker] {account.get('login', acc_id)}: got {len(all_cookies)} cookies")
# Step 4: Fetch profile using authenticated session (cookies set)
await task_manager.set_step(task_id, 4, 4, "Получение профиля", acc_id)
nickname = None
steam_level = None
avatar_url = None
last_online = None
if steam_id and steam_id != "0":
profile_url = f"https://steamcommunity.com/profiles/{steam_id}/"
try:
async with session.get(profile_url) as resp:
if resp.status == 200:
html = await resp.text()
from app.services.steam_profile import (
_parse_persona_name, _parse_avatar, _parse_level, _parse_last_online,
)
nickname = _parse_persona_name(html)
avatar_url = _parse_avatar(html)
steam_level = _parse_level(html)
last_online = _parse_last_online(html)
except Exception as exc:
logger.warning(f"[token_checker] profile fetch error: {exc}")
# Update DB
from app.database import get_db
db = await get_db()
await db.execute(
"""UPDATE token_accounts
SET steam_id = ?, nickname = ?, steam_level = ?, avatar_url = ?, last_online = ?,
session_cookies = ?, status = 'valid', updated_at = datetime('now')
WHERE id = ?""",
(steam_id, nickname, steam_level, avatar_url, last_online, session_cookies_json, acc_id),
)
await db.commit()
logger.success(f"[token_checker] {account.get('login', acc_id)} → valid, steam_id={steam_id}")
except Exception as exc:
await _mark_invalid(acc_id)
logger.error(f"[token_checker] {account.get('login', acc_id)}{exc}")
raise
async def _mark_invalid(acc_id: int) -> None:
from app.database import get_db
db = await get_db()
await db.execute(
"UPDATE token_accounts SET status = 'invalid', updated_at = datetime('now') WHERE id = ?",
(acc_id,),
)
await db.commit()
+397
View File
@@ -0,0 +1,397 @@
"""Shared Steam account recovery wizard flow.
Steps 1-9 are identical across password change, email change, and phone removal.
This module extracts that common logic.
"""
import asyncio
import json
from dataclasses import dataclass
from urllib.parse import parse_qs, urlparse
from loguru import logger
from app.core.crypto import encrypt_password
from app.services.steam_auth import raw_request
BROWSER_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/"
"537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36"
)
AJAX_HEADERS = {
"Accept": "*/*",
"X-Requested-With": "XMLHttpRequest",
"User-Agent": BROWSER_UA,
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
"Origin": "https://help.steampowered.com",
}
@dataclass
class WizardParams:
s: int
account: int
reset: int
issueid: int
lost: int = 0
sessionid: str = ""
async def parse_wizard_redirect(steam, entry_url: str) -> WizardParams:
"""Step 1-2: Navigate to wizard entry URL, follow redirects, parse params from final URL."""
logger.debug(f"[Wizard] Step 1-2: GET {entry_url}")
response = await raw_request(
steam,
url=entry_url,
method="GET",
headers={"User-Agent": BROWSER_UA},
allow_redirects=True,
)
final_url = str(response.url)
logger.debug(f"[Wizard] Redirect → {final_url}")
query = parse_qs(urlparse(final_url).query)
sessionid = await steam.sessionid("help.steampowered.com")
params = WizardParams(
s=int(query.get("s", [0])[0]),
account=int(query.get("account", [0])[0]),
reset=int(query.get("reset", [0])[0]),
issueid=int(query.get("issueid", [0])[0]),
lost=int(query.get("lost", [0])[0]),
sessionid=sessionid,
)
logger.debug(f"[Wizard] Parsed: s={params.s}, account={params.account}, reset={params.reset}, issueid={params.issueid}")
return params
async def login_info_enter_code(steam, params: WizardParams) -> dict:
"""Step 3: Enter code mode."""
logger.debug("[Wizard] Step 3: HelpWithLoginInfoEnterCode")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/HelpWithLoginInfoEnterCode",
method="GET",
params={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 3 response: {result}")
return result
async def send_recovery_code(steam, params: WizardParams) -> dict:
"""Step 4: Send account recovery code (method=8 = mobile app)."""
logger.debug("[Wizard] Step 4: AjaxSendAccountRecoveryCode (method=8, mobile)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxSendAccountRecoveryCode",
method="POST",
data={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
"method": 8,
"link": "",
"n": 1,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 4 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"SendRecoveryCode failed: {result['errorMsg']}")
return result
async def mobile_confirm(steam, params: WizardParams, retries: int = 5) -> None:
"""Step 5: Confirm via Steam mobile confirmations API (getlist → find by creator_id → ajaxop allow)."""
logger.debug("[Wizard] Step 5: Mobile confirmation")
try:
sid = steam.steamid
except (ValueError, AttributeError):
sid = None
if not sid:
raise RuntimeError("No steamid on Steam object — cannot perform mobile confirmation")
try:
did = steam.device_id
except (ValueError, AttributeError):
did = None
if not did:
raise RuntimeError("No device_id on Steam object — cannot perform mobile confirmation")
try:
_ = steam.identity_secret
except (ValueError, AttributeError):
raise RuntimeError("No identity_secret on Steam object — cannot perform mobile confirmation")
logger.debug(f"[MobileConfirm] steamid={sid}, device_id={did[:20]}..., creator_id(s)={params.s}")
# Steam needs time to create the confirmation after send_recovery_code
logger.debug("[MobileConfirm] Waiting 3s for Steam to create confirmation...")
await asyncio.sleep(3)
for attempt in range(retries):
try:
server_time = await steam.get_server_time()
conf_hash = steam.get_confirmation_hash(server_time=server_time)
logger.debug(f"[MobileConfirm] Attempt {attempt + 1}/{retries}: getlist (server_time={server_time})")
conf_text = await steam.request(
url="https://steamcommunity.com/mobileconf/getlist",
method="GET",
cookies={
"mobileClient": "ios",
"mobileClientVersion": "2.0.20",
"steamid": str(sid),
"Steam_Language": "english",
},
params={
"p": did,
"a": str(sid),
"k": conf_hash,
"t": server_time,
"m": "react",
"tag": "conf",
},
)
conf_data = json.loads(conf_text) if isinstance(conf_text, str) else conf_text
confs = conf_data.get("conf", [])
logger.debug(f"[MobileConfirm] getlist response: success={conf_data.get('success')}, {len(confs)} confirmation(s)")
for i, c in enumerate(confs):
logger.debug(
f"[MobileConfirm] [{i}] id={c.get('id')}, type={c.get('type')}, "
f"type_name={c.get('type_name', '?')}, creator_id={c.get('creator_id')}, "
f"headline={c.get('headline', '')}"
)
if not conf_data.get("success"):
logger.warning(f"[MobileConfirm] getlist failed: {conf_data}")
raise RuntimeError(f"getlist failed: {conf_data}")
target = None
creator = str(params.s)
for c in confs:
if str(c.get("creator_id", "")) == creator:
target = c
break
if not target:
ids = [str(c.get("creator_id", "")) for c in confs]
logger.warning(f"[MobileConfirm] No match for creator_id={creator}, available: {ids}")
raise RuntimeError(f"No confirmation found for creator_id={creator}")
logger.debug(
f"[MobileConfirm] Found match! id={target['id']}, nonce={target.get('nonce')}, "
f"creator_id={target.get('creator_id')}"
)
allow_time = await steam.get_server_time()
allow_hash = steam.get_confirmation_hash(server_time=allow_time, tag="allow")
logger.debug(f"[MobileConfirm] Calling ajaxop allow (server_time={allow_time})")
allow_text = await steam.request(
url="https://steamcommunity.com/mobileconf/ajaxop",
method="GET",
cookies={
"mobileClient": "ios",
"mobileClientVersion": "2.0.20",
},
params={
"op": "allow",
"p": did,
"a": str(sid),
"k": allow_hash,
"t": allow_time,
"m": "react",
"tag": "allow",
"cid": target["id"],
"ck": target["nonce"],
},
)
allow_data = json.loads(allow_text) if isinstance(allow_text, str) else allow_text
logger.debug(f"[MobileConfirm] ajaxop response: {allow_data}")
if allow_data.get("success"):
logger.debug(f"[MobileConfirm] ✓ Confirmation accepted (attempt {attempt + 1})")
return
raise RuntimeError(f"ajaxop returned success=false: {allow_data}")
except Exception as exc:
logger.warning(f"[MobileConfirm] Attempt {attempt + 1}/{retries} failed: {exc}")
if attempt < retries - 1:
logger.debug(f"[MobileConfirm] Retrying in 3s...")
await asyncio.sleep(3)
raise RuntimeError(f"Mobile confirmation failed after {retries} retries")
async def poll_recovery_confirmation(steam, params: WizardParams) -> dict:
"""Step 6: Poll account recovery confirmation (single call, matches old chemail.py)."""
logger.debug("[Wizard] Step 6: AjaxPollAccountRecoveryConfirmation")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxPollAccountRecoveryConfirmation",
method="POST",
data={
"s": params.s,
"reset": params.reset,
"lost": params.lost,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
"method": 8,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 6 response: {data}")
if data.get("errorMsg"):
raise RuntimeError(f"Poll error from Steam: {data['errorMsg']}")
return data
async def verify_recovery_code(steam, params: WizardParams) -> dict:
"""Step 7: Verify recovery code (empty for mobile method)."""
logger.debug("[Wizard] Step 7: AjaxVerifyAccountRecoveryCode (code=empty, method=8)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxVerifyAccountRecoveryCode",
method="GET",
params={
"code": "",
"s": params.s,
"reset": params.reset,
"lost": params.lost,
"method": 8,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 7 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 7 (VerifyRecoveryCode) failed: {result['errorMsg']}")
return result
async def get_next_step(steam, params: WizardParams) -> dict:
"""Step 8: Get next wizard step (lost=2 hardcoded)."""
logger.debug("[Wizard] Step 8: AjaxAccountRecoveryGetNextStep (lost=2)")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryGetNextStep",
method="POST",
data={
"s": params.s,
"account": params.account,
"reset": params.reset,
"lost": 2,
"issueid": params.issueid,
"sessionid": params.sessionid,
"wizard_ajax": 1,
"gamepad": 0,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 8 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 8 (GetNextStep) failed: {result['errorMsg']}")
return result
async def get_rsa_key(steam, login: str) -> tuple[str, str, int]:
"""Step 9a: Get RSA public key from Steam."""
logger.debug(f"[Wizard] Getting RSA key for {login}")
sessionid = await steam.sessionid("help.steampowered.com")
text = await steam.request(
url="https://help.steampowered.com/en/login/getrsakey/",
method="POST",
data={
"sessionid": sessionid,
"username": login,
},
headers=AJAX_HEADERS,
)
data = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] RSA key received: success={data.get('success')}, timestamp={data.get('timestamp')}")
return data["publickey_mod"], data["publickey_exp"], int(data["timestamp"])
async def verify_password(steam, params: WizardParams, login: str, password: str) -> dict:
"""Step 9: Verify current password via RSA encryption."""
logger.debug(f"[Wizard] Step 9: Verifying current password for {login}")
mod, exp, timestamp = await get_rsa_key(steam, login)
encrypted = encrypt_password(password, mod, exp)
sessionid = await steam.sessionid("help.steampowered.com")
text = await steam.request(
url="https://help.steampowered.com/en/wizard/AjaxAccountRecoveryVerifyPassword/",
method="POST",
data={
"sessionid": sessionid,
"s": params.s,
"lost": 2,
"reset": 1,
"password": encrypted,
"rsatimestamp": timestamp,
},
headers=AJAX_HEADERS,
)
result = json.loads(text) if isinstance(text, str) else text
logger.debug(f"[Wizard] Step 9 response: {result}")
if result.get("errorMsg"):
raise RuntimeError(f"Step 9 (VerifyPassword) failed: {result['errorMsg']}")
return result
async def run_common_wizard(steam, entry_url: str, login: str, password: str) -> WizardParams:
"""Execute the full shared wizard flow (steps 1-9).
Returns WizardParams ready for the service-specific final steps.
"""
logger.debug(f"[Wizard] ===== Starting wizard for {login} =====")
logger.debug(f"[Wizard] Entry URL: {entry_url}")
params = await parse_wizard_redirect(steam, entry_url)
await asyncio.sleep(1)
await login_info_enter_code(steam, params)
await asyncio.sleep(1)
await send_recovery_code(steam, params)
await asyncio.sleep(1)
await mobile_confirm(steam, params)
await poll_recovery_confirmation(steam, params)
await asyncio.sleep(1)
await verify_recovery_code(steam, params)
await asyncio.sleep(1)
await get_next_step(steam, params)
await asyncio.sleep(1)
await verify_password(steam, params, login, password)
logger.debug(f"[Wizard] ===== Wizard complete for {login} =====")
return params
BIN
View File
Binary file not shown.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+4
View File
@@ -0,0 +1,4 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<title>Steam</title>
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+14
View File
@@ -0,0 +1,14 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title>
<script type="module" crossorigin src="/assets/index-Bxw--tTv.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cm89176E.css">
</head>
<body>
<div id="root"></div>
</body>
</html>
+97
View File
@@ -0,0 +1,97 @@
![SteamPanel](steampanel_en.png)
# SteamPanel
> [Русская версия](../README.md) | **Author:** [@lolzdm](https://t.me/lolzdm)
Self-hosted web panel for bulk Steam account management. FastAPI + SQLite backend, React frontend. Operations run in parallel, proxy support included, real-time task progress via SSE.
Supports **3 account types**:
- **Mafile** — full accounts with secrets (shared_secret, identity_secret)
- **Log:Pass** — login + password
- **Token** — access/refresh tokens *(work in progress, not functional)*
---
## Features
**Accounts**
- Import from `.txt`: `login:pass`, `login:pass:email:email_pass`, with mafile — any format
- Bulk operations on selected or all accounts
- Search by login, Steam ID, notes, level (`lvl>10`)
- Configurable columns per tab
**Steam Actions**
- Change password (manual / random)
- Change email
- Change / remove phone
- Remove Steam Guard
- Auto-accept logins
- Open Steam in browser via [NoDriver](https://github.com/ultrafunkamsterdam/nodriver) (Chrome automation)
**Validation**
- Login check, ban check, profile parsing (nickname, avatar, level, last online)
- Configurable thread count
**Proxies**
- HTTP / SOCKS5
- Formats: `login:pass@ip:port`, `ip:port:login:pass`, etc. + custom format builder
- Check (alive/dead), round-robin assignment, clear
**Mafile**
- Mafiles are linked to accounts via the **Accounts** section (import or manual assignment)
- Export to ZIP: select specific mafile and Session block fields
- Export formats: **flat** (all files at root), **per-folder** (one folder per account), **single JSON** (one merged file)
- File name templates with `{username}`, `{steamid}` variables
- `.txt` generation: global `accounts.txt`, per-folder `.txt`, custom line format
- Available variables: `{login}`, `{password}`, `{email}`, `{email_password}`, `{steam_id}`, `{proxy}`, `{mafile}`
- “Dont create folder per account” option when global `.txt` is enabled
**Tools**
- 2FA code generator by `shared_secret` — click result to copy
- Validation settings: toggle profile parsing (nickname / avatar), ban check, thread count (150)
- Password hiding (spoiler mode)
---
## Installation
### Option 1 — `run.bat` (Windows, recommended)
Download the archive, unpack, run `run.bat`.
The script will:
1. Check for Python
2. Create a `venv` virtual environment
3. Install dependencies
4. Start main.py
**Requirement:** Python 3.14 — [python.org](https://python.org)
---
### Option 2 — manual
```bash
git clone https://github.com/LOLZ-dev/SteamPanel.git
cd SteamPanel
python -m venv venv
venv\scripts\activate
pip install -r requirements.txt
python main.py
```
Panel opens at `http://127.0.0.1:8000`.
> Chrome/Chromium is only needed for the "Open in browser" feature.
---
## License
MIT — **by [@lolzdm](https://t.me/lolzdm)**
Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 286 KiB

+24
View File
@@ -0,0 +1,24 @@
# Logs
/logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+73
View File
@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
+23
View File
@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3619
View File
File diff suppressed because it is too large Load Diff
+35
View File
@@ -0,0 +1,35 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"lucide-react": "^0.577.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"zustand": "^5.0.11"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/postcss": "^4.2.1",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.0",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"postcss": "^8.5.8",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.56.1",
"vite": "^8.0.0"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
'@tailwindcss/postcss': {},
},
}
+4
View File
@@ -0,0 +1,4 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" height="24" width="24">
<title>Steam</title>
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z" fill="#ffffff"/>
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

+36
View File
@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { useUiStore } from "@/stores/uiStore";
import { Header } from "@/components/layout/Header";
import { TabNav } from "@/components/layout/TabNav";
import { ToastContainer } from "@/components/layout/Toast";
import { AccountsTab } from "@/components/accounts/AccountsTab";
import { ToolsTab } from "@/components/tools/ToolsTab";
import { MafileTab } from "@/components/mafiles/MafileTab";
import { LogsTab } from "@/components/logs/LogsTab";
export default function App() {
const activeTab = useUiStore((s) => s.activeTab);
const loadDisplaySettings = useUiStore((s) => s.loadDisplaySettings);
const loadColumnSettings = useUiStore((s) => s.loadColumnSettings);
const loadLogpassColumnSettings = useUiStore((s) => s.loadLogpassColumnSettings);
useEffect(() => {
loadDisplaySettings();
loadColumnSettings();
loadLogpassColumnSettings();
}, []);
return (
<div className="h-dvh bg-dark-900 text-gray-300 flex flex-col overflow-hidden">
<Header />
<TabNav />
<main className="flex-1 min-h-0 p-4 w-full overflow-y-auto">
{activeTab === "accounts" && <AccountsTab />}
{activeTab === "tools" && <ToolsTab />}
{activeTab === "mafiles" && <MafileTab />}
{activeTab === "logs" && <LogsTab />}
</main>
<ToastContainer />
</div>
);
}
+182
View File
@@ -0,0 +1,182 @@
import type {
Account, AccountCreate, AccountUpdate, BulkImportResult,
Proxy, Task, MafileInfo, MafileExportRequest,
ActionRequest, ValidationSettings, DisplaySettings, ColumnSettings, LogpassColumnSettings, LogEntry,
LogpassAccount, LogpassAccountCreate, LogpassAccountUpdate,
TokenAccount, TokenAccountCreate, TokenColumnSettings,
} from "./types";
const BASE = "/api";
async function request<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
...init,
headers: { "Content-Type": "application/json", ...init?.headers },
});
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }));
throw new Error(body.detail ?? `HTTP ${res.status}`);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
// Version
getVersion: () => request<{ version: string }>("/version"),
// Accounts
getAccounts: () => request<Account[]>("/accounts"),
getAccount: (id: number) => request<Account>(`/accounts/${id}`),
createAccount: (data: AccountCreate) =>
request<Account>("/accounts", { method: "POST", body: JSON.stringify(data) }),
updateAccount: (id: number, data: AccountUpdate) =>
request<Account>(`/accounts/${id}`, { method: "PUT", body: JSON.stringify(data) }),
deleteAccount: (id: number) =>
request<void>(`/accounts/${id}`, { method: "DELETE" }),
deleteBulk: (ids: number[]) =>
request<{ deleted: number }>("/accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
assignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/assign-proxies", { method: "POST" }),
reassignProxies: () =>
request<{ assigned: number; proxies_used: number }>("/accounts/reassign-proxies", { method: "POST" }),
clearProxies: () =>
request<{ cleared: number }>("/accounts/clear-proxies", { method: "POST" }),
importAccounts: (file: File) => {
const form = new FormData();
form.append("file", file);
return fetch(`${BASE}/accounts/import`, { method: "POST", body: form }).then(async (r) => {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
return r.json() as Promise<BulkImportResult>;
});
},
// Actions
executeAction: (data: ActionRequest) =>
request<{ task_id: string; accounts_count: number }>("/actions", { method: "POST", body: JSON.stringify(data) }),
respondToPrompt: (taskId: string, value: string, login?: string) =>
request<{ status: string }>(`/tasks/${taskId}/respond`, { method: "POST", body: JSON.stringify({ value, login: login || "" }) }),
generate2FA: (shared_secret: string) =>
request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }),
generate2FAByAccount: (account_id: number) =>
request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }),
// Proxies
getProxies: () => request<Proxy[]>("/proxies"),
addProxy: (address: string, protocol = "http") =>
request<Proxy>("/proxies", { method: "POST", body: JSON.stringify({ address, protocol }) }),
deleteProxy: (id: number) =>
request<void>(`/proxies/${id}`, { method: "DELETE" }),
deleteAllProxies: () =>
request<{ deleted: number }>("/proxies/all", { method: "DELETE" }),
bulkAddProxies: (proxies: { address: string; protocol: string }[]) =>
request<{ added: number }>("/proxies/bulk", { method: "POST", body: JSON.stringify(proxies) }),
checkProxies: () =>
request<{ total: number; alive: number; dead: number }>("/proxies/check", { method: "POST" }),
// Tasks
getTasks: () => request<Task[]>("/tasks"),
getTask: (id: string) => request<Task>(`/tasks/${id}`),
cancelTask: (id: string) => request<void>(`/tasks/${id}`, { method: "DELETE" }),
// Mafiles
getMafiles: () => request<MafileInfo[]>("/mafiles"),
uploadMafiles: (files: File[]) => {
const form = new FormData();
files.forEach((f) => form.append("files", f));
return fetch(`${BASE}/mafiles/upload`, { method: "POST", body: form }).then(async (r) => {
if (!r.ok) throw new Error((await r.json().catch(() => ({}))).detail ?? r.statusText);
return r.json() as Promise<{ uploaded: number; errors: string[] }>;
});
},
deleteMafile: (filename: string) =>
request<void>(`/mafiles/${encodeURIComponent(filename)}`, { method: "DELETE" }),
exportSecrets: () => request<{ account_name: string; shared_secret: string; identity_secret: string; steam_id: string }[]>("/mafiles/export/all"),
exportZip: (body: MafileExportRequest) =>
fetch(`${BASE}/mafiles/export/zip`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}).then((r) => {
if (!r.ok) throw new Error(`Export failed: ${r.status}`);
return r.blob();
}),
// Settings
getValidationSettings: () => request<ValidationSettings>("/settings/validation"),
updateValidationSettings: (data: ValidationSettings) =>
request<ValidationSettings>("/settings/validation", { method: "PUT", body: JSON.stringify(data) }),
getDisplaySettings: () => request<DisplaySettings>("/settings/display"),
updateDisplaySettings: (data: DisplaySettings) =>
request<DisplaySettings>("/settings/display", { method: "PUT", body: JSON.stringify(data) }),
getColumnSettings: () => request<ColumnSettings>("/settings/columns"),
updateColumnSettings: (data: ColumnSettings) =>
request<ColumnSettings>("/settings/columns", { method: "PUT", body: JSON.stringify(data) }),
getLogpassColumnSettings: () => request<LogpassColumnSettings>("/settings/logpass-columns"),
updateLogpassColumnSettings: (data: LogpassColumnSettings) =>
request<LogpassColumnSettings>("/settings/logpass-columns", { method: "PUT", body: JSON.stringify(data) }),
// Logs
getLogs: () => request<LogEntry[]>("/logs"),
// Auto-accept
startAutoAccept: (ids: number[]) =>
request<{ started: number[] }>("/auto-accept/start", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
stopAutoAccept: (ids: number[]) =>
request<{ stopped: number[] }>("/auto-accept/stop", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
getAutoAcceptStatus: () =>
request<{ running: number[] }>("/auto-accept/status"),
// Browser
openBrowser: (id: number) =>
request<{ status: string; message: string; task_id?: string }>(`/accounts/${id}/browser`, { method: "POST" }),
// Logpass accounts
getLogpassAccounts: () => request<LogpassAccount[]>("/logpass"),
createLogpassAccount: (data: LogpassAccountCreate) =>
request<LogpassAccount>("/logpass", { method: "POST", body: JSON.stringify(data) }),
updateLogpassAccount: (id: number, data: LogpassAccountUpdate) =>
request<LogpassAccount>(`/logpass/${id}`, { method: "PUT", body: JSON.stringify(data) }),
deleteLogpassAccount: (id: number) =>
request<void>(`/logpass/${id}`, { method: "DELETE" }),
deleteLogpassBulk: (ids: number[]) =>
request<{ deleted: number }>("/logpass/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
importLogpass: (lines: string[]) =>
request<BulkImportResult>("/logpass/import", { method: "POST", body: JSON.stringify({ lines }) }),
validateLogpass: (account_ids: number[]) =>
request<{ task_id: string; accounts_count: number }>("/logpass/validate", { method: "POST", body: JSON.stringify({ account_ids }) }),
fullParseLogpass: (account_ids: number[]) =>
request<{ task_id: string; accounts_count: number }>("/logpass/full-parse", { method: "POST", body: JSON.stringify({ account_ids }) }),
assignLogpassProxies: () =>
request<{ assigned: number; proxies_used: number }>("/logpass/assign-proxies", { method: "POST" }),
reassignLogpassProxies: () =>
request<{ assigned: number; proxies_used: number }>("/logpass/reassign-proxies", { method: "POST" }),
clearLogpassProxies: () =>
request<{ cleared: number }>("/logpass/clear-proxies", { method: "POST" }),
openLogpassBrowser: (id: number) =>
request<{ status: string; message: string; task_id?: string }>(`/logpass/${id}/browser`, { method: "POST" }),
// Token accounts
getTokenAccounts: () => request<TokenAccount[]>("/token-accounts"),
createTokenAccount: (data: TokenAccountCreate) =>
request<TokenAccount>("/token-accounts", { method: "POST", body: JSON.stringify(data) }),
deleteTokenAccount: (id: number) =>
request<void>(`/token-accounts/${id}`, { method: "DELETE" }),
deleteTokenBulk: (ids: number[]) =>
request<{ deleted: number }>("/token-accounts/delete-bulk", { method: "POST", body: JSON.stringify({ ids }) }),
importTokens: (lines: string[]) =>
request<BulkImportResult>("/token-accounts/import", { method: "POST", body: JSON.stringify({ lines }) }),
validateTokens: (ids: number[]) =>
request<{ task_id: string; accounts_count: number }>("/token-accounts/validate", { method: "POST", body: JSON.stringify({ account_ids: ids }) }),
openTokenBrowser: (id: number) =>
request<{ status: string; message: string; task_id?: string }>(`/token-accounts/${id}/browser`, { method: "POST" }),
assignTokenProxies: () =>
request<{ assigned: number; proxies_used: number }>("/token-accounts/assign-proxies", { method: "POST" }),
reassignTokenProxies: () =>
request<{ assigned: number; proxies_used: number }>("/token-accounts/reassign-proxies", { method: "POST" }),
clearTokenProxies: () =>
request<{ cleared: number }>("/token-accounts/clear-proxies", { method: "POST" }),
getTokenColumnSettings: () => request<TokenColumnSettings>("/settings/token-columns"),
updateTokenColumnSettings: (data: TokenColumnSettings) =>
request<TokenColumnSettings>("/settings/token-columns", { method: "PUT", body: JSON.stringify(data) }),
};
+260
View File
@@ -0,0 +1,260 @@
export interface Account {
id: number;
login: string;
password: string;
steam_id: string | null;
email: string | null;
email_password: string | null;
phone: string | null;
mafile_path: string | null;
shared_secret: string | null;
identity_secret: string | null;
proxy: string | null;
status: string;
notes: string | null;
nickname: string | null;
avatar_url: string | null;
steam_level: number | null;
last_online: string | null;
auto_accept: number;
ban_status: string | null;
has_cookies: boolean;
has_revocation_code: boolean;
created_at: string;
updated_at: string;
}
export interface AccountCreate {
login: string;
password: string;
steam_id?: string;
email?: string;
email_password?: string;
phone?: string;
proxy?: string;
notes?: string;
}
export interface AccountUpdate {
login?: string;
password?: string;
steam_id?: string;
email?: string;
email_password?: string;
phone?: string;
proxy?: string;
status?: string;
notes?: string;
}
export interface Proxy {
id: number;
address: string;
protocol: string;
is_alive: boolean;
last_checked: string | null;
fail_count: number;
}
export interface Task {
id: string;
type: string;
status: string;
progress: number;
total: number;
result: string | null;
error: string | null;
prompt?: string;
prompt_login?: string;
step?: number;
total_steps?: number;
step_label?: string;
active_count?: number;
account_ids?: string | null;
account_results?: Record<string, { status: string; error?: string }> | string | null;
account_steps?: Record<string, { step: number; total: number }>;
created_at: string;
updated_at: string;
}
export interface MafileInfo {
filename: string;
account_name: string;
steam_id: string;
has_shared_secret: boolean;
has_identity_secret: boolean;
fully_enrolled: boolean;
error?: string;
}
export interface MafileExportRequest {
fields: string[];
session_fields: string[];
format: "flat_mafiles" | "per_account_folder" | "single_file";
account_ids: number[];
folder_name_template: string;
mafile_name_template: string;
txt_name_template: string;
include_txt_per_folder: boolean;
include_global_txt: boolean;
skip_folders: boolean;
txt_format: string;
}
export interface BulkImportResult {
imported: number;
skipped: number;
errors: string[];
}
export interface ActionRequest {
account_ids: number[];
action: string;
params?: Record<string, unknown>;
}
export interface ValidationSettings {
fetch_profile: boolean;
check_ban: boolean;
max_threads: number;
auto_revalidate_browser: boolean;
}
export interface DisplaySettings {
hide_passwords: boolean;
}
export interface ColumnSettings {
browser: boolean;
profile: boolean;
steam_id: boolean;
login: boolean;
password: boolean;
login_pass: boolean;
email: boolean;
email_pass: boolean;
email_login_pass: boolean;
phone: boolean;
status: boolean;
ban: boolean;
twofa: boolean;
mafile: boolean;
proxy: boolean;
notes: boolean;
actions: boolean;
last_online: boolean;
}
export interface LogpassColumnSettings {
browser: boolean;
profile: boolean;
steam_id: boolean;
login: boolean;
password: boolean;
login_pass: boolean;
status: boolean;
ban: boolean;
prime: boolean;
trophy: boolean;
behavior: boolean;
license: boolean;
proxy: boolean;
notes: boolean;
actions: boolean;
last_online: boolean;
}
export interface TokenColumnSettings {
browser: boolean;
profile: boolean;
last_online: boolean;
steam_id: boolean;
login: boolean;
token: boolean;
status: boolean;
proxy: boolean;
notes: boolean;
actions: boolean;
}
export interface LogEntry {
ts: string;
level: string;
icon: string;
msg: string;
}
export interface LogpassAccount {
id: number;
login: string;
password: string;
steam_id: string | null;
proxy: string | null;
status: string;
ban_status: string | null;
nickname: string | null;
steam_level: number | null;
prime: string | null;
trophy: string | null;
behavior: string | null;
license: string | null;
notes: string | null;
avatar_url: string | null;
last_online: string | null;
has_cookies: boolean;
created_at: string;
updated_at: string;
}
export interface LogpassAccountCreate {
login: string;
password: string;
steam_id?: string;
proxy?: string;
notes?: string;
}
export interface LogpassAccountUpdate {
login?: string;
password?: string;
steam_id?: string;
proxy?: string;
status?: string;
notes?: string;
}
export interface TokenAccount {
id: number;
login: string | null;
token: string;
steam_id: string | null;
proxy: string | null;
status: string;
ban_status: string | null;
nickname: string | null;
steam_level: number | null;
avatar_url: string | null;
last_online: string | null;
has_cookies: boolean;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface TokenAccountCreate {
login?: string;
token: string;
steam_id?: string;
proxy?: string;
notes?: string;
}
export type TabId = "accounts" | "tools" | "mafiles" | "logs";
export type ToastType = "info" | "success" | "error" | "warn";
export interface Toast {
id: number;
type: ToastType;
message: string;
}
@@ -0,0 +1,283 @@
import { useState } from "react";
import type { Account, ColumnSettings } from "@/api/types";
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 { ActionMenu } from "./ActionMenu";
import { SteamLevelBadge } from "./SteamLevelBadge";
import { IconCheck, IconAlertTriangle, IconEye, IconEyeOff, IconKey, IconRefresh, IconEdit, IconTrash } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
account: Account;
visibleColumns: ColumnSettings;
proxyLabel: string | null;
isProcessing: boolean;
accountResult?: { status: string; error?: string };
accountStep?: { step: number; total: number };
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onToggleAutoAccept: (id: number) => void;
onOpenBrowser: (id: number) => void;
}
export function AccountRow({ account: a, visibleColumns: v, proxyLabel, isProcessing, accountResult, accountStep, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
const selectedIds = useAccountStore((s) => s.selectedIds);
const toggleSelect = useAccountStore((s) => s.toggleSelect);
const addToast = useUiStore((s) => s.addToast);
const hidePasswords = useUiStore((s) => s.hidePasswords);
const [revealed, setRevealed] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesValue, setNotesValue] = useState(a.notes || "");
const t = useT();
const handleCopy = async (text: string) => {
const ok = await copyText(text);
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
};
const masked = hidePasswords && !revealed;
const dots = "••••••••";
return (
<tr className="hover:bg-dark-700/50 transition">
<td className="px-3 py-2">
<input
type="checkbox"
checked={selectedIds.has(a.id)}
onChange={() => toggleSelect(a.id)}
/>
</td>
<td className="px-1 py-2 w-5">
{isProcessing ? (
<div className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
{accountStep && (
<span className="text-[10px] text-gray-500 whitespace-nowrap">[{accountStep.step}/{accountStep.total}]</span>
)}
</div>
) : accountResult?.status === "ok" ? (
<IconCheck size={16} className="text-gray-400" />
) : accountResult?.status === "error" ? (
<span title={accountResult.error?.toLowerCase().includes("proxy") || accountResult.error?.toLowerCase().includes("network") || accountResult.error?.includes("WinError") ? t("tip.proxyError", { error: accountResult.error }) : accountResult.error}>
<IconAlertTriangle size={16} className="text-red-400" />
</span>
) : null}
</td>
{v.browser && (
<td className="px-3 py-2 whitespace-nowrap text-center">
{a.has_cookies ? (
<button
onClick={() => onOpenBrowser(a.id)}
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
title={t("tip.openBrowser")}
>
{t("col.browser")}
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
)}
{v.profile && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
<span className="text-xs">{a.nickname || "—"}</span>
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
</div>
</td>
)}
{v.last_online && (
<td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">
{a.last_online || "—"}
</td>
)}
{v.steam_id && (
<td className="px-3 py-2 text-xs">
{a.steam_id ? (
<a
href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`}
target="_blank"
rel="noopener noreferrer"
className="text-blue-400 hover:text-blue-300 hover:underline"
>
{a.steam_id}
</a>
) : "—"}
</td>
)}
{v.login && <td className="px-3 py-2 font-medium">{a.login}</td>}
{v.password && (
<td className="px-3 py-2 text-gray-400 select-none whitespace-nowrap">
<span
className="cell-copy cursor-pointer"
onClick={() => handleCopy(a.password)}
title={t("tip.copyPassword")}
>
{masked ? dots : a.password}
</span>
<button
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
title={masked ? t("tip.show") : t("tip.hide")}
>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
</td>
)}
{v.login_pass && (
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
<span
className="cell-copy cursor-pointer"
onClick={() => handleCopy(`${a.login}:${a.password}`)}
title={t("tip.copyLoginPass")}
>
{masked ? `${a.login}:${dots}` : `${a.login}:${a.password}`}
</span>
<button
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
title={masked ? t("tip.show") : t("tip.hide")}
>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
</td>
)}
{v.email && <td className="px-3 py-2 text-gray-400 text-xs">{a.email || "—"}</td>}
{v.email_pass && (
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
<span
className="cell-copy cursor-pointer"
onClick={() => { if (a.email_password) handleCopy(a.email_password); }}
title={a.email_password ? t("tip.copyEmailPass") : ""}
>
{a.email_password ? (masked ? dots : a.email_password) : "—"}
</span>
{a.email_password && (
<button
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
title={masked ? t("tip.show") : t("tip.hide")}
>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
)}
</td>
)}
{v.email_login_pass && (
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
<span
className="cell-copy cursor-pointer"
onClick={() => { if (a.email && a.email_password) handleCopy(`${a.email}:${a.email_password}`); }}
title={a.email && a.email_password ? t("tip.copyEmailLoginPass") : ""}
>
{a.email && a.email_password ? (masked ? `${a.email}:${dots}` : `${a.email}:${a.email_password}`) : "—"}
</span>
{a.email && a.email_password && (
<button
onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }}
className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle"
title={masked ? t("tip.show") : t("tip.hide")}
>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
)}
</td>
)}
{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.twofa && (
<td className="px-3 py-2 whitespace-nowrap">
{a.shared_secret ? (
<button
onClick={async () => {
try {
const { code } = await api.generate2FA(a.shared_secret!);
await copyText(code);
addToast("success", t("twofa.copied", { code }));
} catch (e: unknown) {
addToast("error", t("twofa.error", { error: e instanceof Error ? e.message : String(e) }));
}
}}
className="text-accent hover:underline text-xs"
title={t("tip.gen2fa")}
>
<IconKey size={12} className="inline mr-0.5" /> {t("tip.code")}
</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
)}
{v.mafile && (
<td className="px-3 py-2">
<MafileBadge hasMafile={!!a.mafile_path} />
</td>
)}
{v.proxy && (
<td className="px-3 py-2 text-xs">
{a.proxy ? (
<span
className="text-blue-400 cursor-pointer hover:underline"
onClick={() => handleCopy(a.proxy!)}
title={a.proxy}
>
{proxyLabel || a.proxy}
</span>
) : "—"}
</td>
)}
{v.notes && (
<td className="px-3 py-2 text-xs max-w-[180px]">
{editingNotes ? (
<input
autoFocus
className="w-full bg-dark-600 border border-dark-500 rounded px-1.5 py-0.5 text-xs text-gray-200 outline-none focus:border-accent"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={async () => {
setEditingNotes(false);
if (notesValue !== (a.notes || "")) {
try {
await api.updateAccount(a.id, { notes: notesValue });
addToast("success", t("toast.noteSaved"));
useAccountStore.getState().loadAccounts();
} catch {
addToast("error", t("toast.noteSaveError"));
}
}
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setNotesValue(a.notes || ""); setEditingNotes(false); } }}
/>
) : (
<span
className="cursor-pointer text-gray-400 hover:text-gray-200 truncate block"
onClick={() => { setNotesValue(a.notes || ""); setEditingNotes(true); }}
title={a.notes || t("tip.addNote")}
>
{a.notes || "—"}
</span>
)}
</td>
)}
{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} />
<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>
) : null}
</div>
</td>
)}
</tr>
);
}
@@ -0,0 +1,183 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { Account, ColumnSettings } from "@/api/types";
import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore";
import { AccountRow } from "./AccountRow";
import { useT } from "@/lib/i18n";
const COLUMN_KEYS: (keyof ColumnSettings)[] = [
"browser", "profile", "last_online", "steam_id", "login", "password", "login_pass",
"email", "email_pass", "email_login_pass", "phone", "status", "ban", "twofa", "mafile", "proxy", "notes", "actions",
];
const COL_LABEL_KEYS: Record<keyof ColumnSettings, 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", 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",
notes: "col.notes", actions: "col.actions",
};
interface Props {
accounts: Account[];
processingIds: Set<number>;
accountResults: Record<string, { status: string; error?: string }>;
accountSteps: Record<string, { step: number; total: number }>;
onEdit: (id: number) => void;
onDelete: (id: number) => void;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onToggleAutoAccept: (id: number) => void;
onOpenBrowser: (id: number) => void;
}
export function AccountTable({ accounts, processingIds, accountResults, accountSteps, onEdit, onDelete, onAction, onToggleAutoAccept, onOpenBrowser }: Props) {
const selectAll = useAccountStore((s) => s.selectAll);
const clearSelection = useAccountStore((s) => s.clearSelection);
const selectedIds = useAccountStore((s) => s.selectedIds);
const cols = useUiStore((s) => s.columnVisibility);
const t = useT();
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const visibleKeys = COLUMN_KEYS.filter((k) => cols[k]);
const BATCH = 100;
const [visibleCount, setVisibleCount] = useState(BATCH);
const sentinelRef = useRef<HTMLTableRowElement>(null);
type SortField = "last_online" | 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(field);
setSortDir("asc");
}
};
const sorted = useMemo(() => {
if (!sortField) return accounts;
const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol();
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;
if (va === EMPTY) return 1;
if (vb === EMPTY) return -1;
if (va === vb) return 0;
return (va < vb ? -1 : 1) * dir;
});
}, [accounts, sortField, sortDir]);
useEffect(() => { setVisibleCount(BATCH); }, [accounts, sortField, sortDir]);
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
}, { rootMargin: "400px" });
obs.observe(el);
return () => obs.disconnect();
}, [sorted.length, visibleCount]);
// Build proxy labels: unique proxies get numbered "Прокси 1", "Прокси 2", etc.
const proxyLabels = useMemo(() => {
const map = new Map<number, string>();
const uniqueProxies: string[] = [];
for (const a of accounts) {
if (a.proxy && !uniqueProxies.includes(a.proxy)) {
uniqueProxies.push(a.proxy);
}
}
const proxyToNum = new Map<string, number>();
uniqueProxies.forEach((p, i) => proxyToNum.set(p, i + 1));
for (const a of accounts) {
if (a.proxy) {
const num = proxyToNum.get(a.proxy);
map.set(a.id, num ? t("proxy.label", { num }) : a.proxy);
}
}
return map;
}, [accounts]);
if (!accounts.length) {
return (
<div className="text-center text-gray-500 py-8">
{t("empty.accounts")}
</div>
);
}
return (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800 z-10">
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
<th className="px-3 py-2">
<div className="flex items-center gap-1.5">
<input
type="checkbox"
checked={allSelected}
onChange={() => allSelected ? clearSelection() : selectAll()}
/>
{selectedIds.size > 0 && (
<span className="text-accent font-medium">{selectedIds.size}</span>
)}
</div>
</th>
<th className="w-5"></th>
{visibleKeys.map((k) => {
if (k === "last_online") {
return (
<th key={k} className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}>
{t(COL_LABEL_KEYS[k])}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}
</th>
);
}
return <th key={k} className={`px-3 py-2${k === "browser" ? " text-center" : ""}`}>{t(COL_LABEL_KEYS[k])}</th>;
})}
</tr>
</thead>
<tbody>
{visible.map((a) => (
<AccountRow
key={a.id}
account={a}
visibleColumns={cols}
proxyLabel={proxyLabels.get(a.id) ?? null}
isProcessing={processingIds.has(a.id)}
accountResult={accountResults[String(a.id)]}
accountStep={accountSteps[String(a.id)]}
onEdit={onEdit}
onDelete={onDelete}
onAction={onAction}
onToggleAutoAccept={onToggleAutoAccept}
onOpenBrowser={onOpenBrowser}
/>
))}
{visibleCount < accounts.length && (
<tr ref={sentinelRef}><td colSpan={visibleKeys.length + 2} className="text-center py-3 text-xs text-gray-500">{t("paging.shown", { visible: visibleCount, total: accounts.length })}</td></tr>
)}
</tbody>
</table>
</div>
);
}
@@ -0,0 +1,689 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { api } from "@/api/client";
import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore";
import { useTaskStore } from "@/stores/taskStore";
import { useLogpassStore } from "@/stores/logpassStore";
import { useTokenStore } from "@/stores/tokenStore";
import { AccountTable } from "./AccountTable";
import { EditModal } from "./EditModal";
import { ImportModal } from "./ImportModal";
import { AddModal } from "./AddModal";
import { ConfirmModal } from "@/components/shared/Modals";
import { ColumnSettingsDropdown } from "./ColumnSettingsDropdown";
import {
IconDownload, IconPlus, IconCheckCircle, IconXCircle,
IconFolder, IconGlobe, IconRefresh, IconBan,
IconPlay, IconLock, IconKey, IconZap,
} from "@/components/shared/Icons";
import { LogpassTab } from "./LogpassTab";
import { TokenTab } from "./TokenTab";
import type { AccountCreate, AccountUpdate, Task } from "@/api/types";
import { useT } from "@/lib/i18n";
type AccountSection = "mafile" | "logpass" | "token";
type AccResults = Record<string, { status: string; error?: string }>;
function parseAccResults(raw: Task["account_results"]): AccResults | null {
if (!raw) return null;
if (typeof raw === "string") {
try { return JSON.parse(raw) as AccResults; } catch { return null; }
}
return raw;
}
function parseIds(raw: Task["account_ids"]): number[] {
if (!raw) return [];
try { return JSON.parse(raw) as number[]; } catch { return []; }
}
const BULK_ACTIONS = [
{ value: "", labelKey: "action.selectAction" },
{ value: "validate", labelKey: "action.validate" },
{ value: "change_password", labelKey: "action.changePassword" },
{ value: "random_password", labelKey: "action.randomPassword" },
{ value: "change_email", labelKey: "action.changeEmail" },
{ value: "remove_guard", labelKey: "action.removeGuard" },
{ value: "enable_auto_accept", labelKey: "action.enableAutoAccept" },
] as const;
function BulkActionDropdown({ value, onChange, disabledActions }: { value: string; onChange: (v: string) => void; disabledActions?: Set<string> }) {
const t = useT();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, [open]);
const label = t(BULK_ACTIONS.find((a) => a.value === value)?.labelKey || "action.selectAction");
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen((p) => !p)}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-sm flex items-center gap-1 min-w-[170px] justify-between hover:border-dark-500 transition-colors"
>
<span className="truncate">{label}</span>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" className={`shrink-0 transition-transform ${open ? "rotate-180" : ""}`}>
<path d="M3 4.5L6 7.5L9 4.5" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
{open && (
<div className="absolute top-full left-0 mt-1 z-50 bg-dark-700 border border-dark-600 rounded shadow-lg min-w-[100px] py-1 max-h-[320px] overflow-y-auto">
{BULK_ACTIONS.map((a) => {
const disabled = !!(a.value && disabledActions?.has(a.value));
return (
<button
key={a.value}
type="button"
onClick={() => { if (!disabled) { onChange(a.value); setOpen(false); } }}
disabled={disabled}
title={disabled ? t("action.noRevocationCode") : undefined}
className={`w-full text-left px-3 py-2 text-sm transition-colors ${disabled ? "opacity-40 cursor-not-allowed" : "hover:bg-dark-600"} ${a.value === value ? "text-accent bg-dark-600/50" : ""} ${!a.value ? "text-gray-500" : ""}`}
>
{t(a.labelKey)}
</button>
);
})}
</div>
)}
</div>
);
}
function SectionAccountCount() {
const section = useUiStore((s) => s.accountSection);
const mafileCount = useAccountStore((s) => s.accounts.length);
const logpassCount = useLogpassStore((s) => s.accounts.length);
const tokenCount = useTokenStore((s) => s.accounts.length);
const loadLogpass = useLogpassStore((s) => s.loadAccounts);
const loadTokens = useTokenStore((s) => s.loadAccounts);
const [displayCount, setDisplayCount] = useState(0);
const rafRef = useRef<number>(0);
const displayRef = useRef(0);
const prevSection = useRef(section);
useEffect(() => {
if (section === "logpass" && logpassCount === 0) loadLogpass();
if (section === "token" && tokenCount === 0) loadTokens();
}, [section]);
const count = section === "logpass" ? logpassCount : section === "token" ? tokenCount : mafileCount;
useEffect(() => {
cancelAnimationFrame(rafRef.current);
const sectionChanged = prevSection.current !== section;
prevSection.current = section;
if (sectionChanged) {
displayRef.current = count;
setDisplayCount(count);
return;
}
const start = displayRef.current;
const diff = count - start;
if (diff === 0) return;
const duration = Math.min(400, Math.max(150, Math.abs(diff) * 2));
const startTime = performance.now();
const step = (now: number) => {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3);
const value = Math.max(0, Math.round(start + diff * eased));
displayRef.current = value;
setDisplayCount(value);
if (progress < 1) rafRef.current = requestAnimationFrame(step);
};
rafRef.current = requestAnimationFrame(step);
return () => cancelAnimationFrame(rafRef.current);
}, [count, section]);
return (
<div className="mt-1 px-3 py-2 border-t border-dark-600 flex items-center gap-2">
<div className="relative shrink-0" style={{ width: 14, height: 14 }}>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-gray-400">
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="icon-shimmer absolute inset-0" style={{ pointerEvents: "none" }}>
<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/>
</svg>
</div>
<span className="text-sm text-gray-300 font-medium tabular-nums">{displayCount}</span>
</div>
);
}
export function AccountsTab() {
const accounts = useAccountStore((s) => s.accounts);
const selectedIds = useAccountStore((s) => s.selectedIds);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const clearSelection = useAccountStore((s) => s.clearSelection);
const sendToMafileManager = useAccountStore((s) => s.sendToMafileManager);
const setTab = useUiStore((s) => s.setTab);
const addToast = useUiStore((s) => s.addToast);
const loadTasks = useTaskStore((s) => s.loadTasks);
const t = useT();
const [editId, setEditId] = useState<number | null>(null);
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [confirmMsg, setConfirmMsg] = useState<string | null>(null);
const [onConfirmAction, setOnConfirmAction] = useState<(() => void) | null>(null);
const [bulkAction, setBulkAction] = useState("");
const section = useUiStore((s) => s.accountSection);
const setSection = useUiStore((s) => s.setAccountSection);
const [activeTask, setActiveTask] = useState<Task | null>(null);
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [promptData, setPromptData] = useState<{ taskId: string; message: string; login?: string } | null>(null);
const [promptValue, setPromptValue] = useState("");
const [accountResults, setAccountResults] = useState<Record<string, { status: string; error?: string }>>({});
const [accountSteps, setAccountSteps] = useState<Record<string, { step: number; total: number }>>({});
const filteredAccounts = useMemo(() => {
if (!searchQuery.trim()) return accounts;
const q = searchQuery.trim();
// lvl comparison syntax: lvl>10, lvl>=10, lvl<10, lvl<=10, lvl=10
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
if (lvlMatch) {
const op = lvlMatch[1];
const val = parseInt(lvlMatch[2], 10);
return accounts.filter((a) => {
const lvl = a.steam_level;
if (lvl == null) return false;
if (op === ">") return lvl > val;
if (op === ">=") return lvl >= val;
if (op === "<") return lvl < val;
if (op === "<=") return lvl <= val;
return lvl === val;
});
}
const ql = q.toLowerCase();
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.notes) {
const notesLower = a.notes.toLowerCase();
return words.some((w) => notesLower.includes(w));
}
return false;
});
}, [accounts, searchQuery]);
const bulkDisabledActions = useMemo(() => {
const disabled = new Set<string>();
const selected = accounts.filter((a) => selectedIds.has(a.id));
if (!selected.some((a) => a.has_revocation_code)) {
disabled.add("remove_guard");
}
return disabled;
}, [accounts, selectedIds]);
useEffect(() => { loadAccounts(); }, []);
const confirm = (msg: string, action: () => void) => {
setConfirmMsg(msg);
setOnConfirmAction(() => action);
};
const watchTask = useCallback((taskId: string, accountIds: number[] = [], clearResults = true) => {
if (clearResults) {
setAccountResults((prev) => {
const next = { ...prev };
accountIds.forEach((id) => delete next[String(id)]);
return next;
});
}
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (e) => {
try {
const data = JSON.parse(e.data) as Task;
setActiveTask(data);
const parsed = parseAccResults(data.account_results);
if (parsed) {
setAccountResults((prev) => ({ ...prev, ...parsed }));
}
if (data.account_steps) {
setAccountSteps((prev) => ({ ...prev, ...data.account_steps }));
}
if (data.prompt) {
setPromptData({ taskId, message: data.prompt, login: data.prompt_login });
setPromptValue("");
}
if (data.status === "completed" || data.status === "failed" || data.status === "cancelled") {
if (data.status === "completed") {
addToast("success", `${data.type}: ${data.result || t("toast.done")}`);
} else if (data.status === "cancelled") {
addToast("info", t("toast.taskCancelled"));
} else {
addToast("error", `${data.type}: ${data.error || t("toast.error")}`);
}
setProcessingIds((prev) => {
const s = new Set(prev);
accountIds.forEach((id) => s.delete(id));
return s;
});
setPromptData(null);
loadAccounts();
loadTasks();
es.close();
setTimeout(() => setActiveTask(null), 3000);
}
} catch { /* ignore */ }
};
es.onerror = () => es.close();
}, [addToast, loadAccounts, loadTasks]);
// Restore running/completed task state on page load
useEffect(() => {
let cancelled = false;
(async () => {
try {
const tasks = await api.getTasks();
if (cancelled) return;
const running = tasks.find((t) => t.status === "running");
if (running) {
const ids = parseIds(running.account_ids);
setActiveTask(running);
setProcessingIds(new Set(ids));
const restored = parseAccResults(running.account_results);
if (restored) setAccountResults(restored);
watchTask(running.id, ids, false);
return;
}
const recent = tasks.find((t) => t.status === "completed" || t.status === "failed");
if (recent) {
const restored = parseAccResults(recent.account_results);
if (restored && Object.keys(restored).length > 0) {
setAccountResults(restored);
}
}
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [watchTask]);
const handleAction = async (id: number, action: string, params: Record<string, string>) => {
const acc = accounts.find((a) => a.id === id);
const login = acc?.login ?? `#${id}`;
try {
setProcessingIds((prev) => new Set(prev).add(id));
const result = await api.executeAction({ account_ids: [id], action, params });
addToast("info", t("misc.taskAction", { id: result.task_id, action, login }));
watchTask(result.task_id, [id]);
} catch (e: unknown) {
setProcessingIds((prev) => { const s = new Set(prev); s.delete(id); return s; });
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handlePromptSubmit = async () => {
if (!promptData || !promptValue) return;
try {
await api.respondToPrompt(promptData.taskId, promptValue, promptData.login);
setPromptData(null);
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handlePromptCancel = async () => {
if (promptData) {
try { await api.cancelTask(promptData.taskId); } catch { /* task may already be done */ }
}
setPromptData(null);
};
const handleBulkAction = () => {
if (!bulkAction) return addToast("warn", t("toast.selectAction"));
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
if (bulkAction === "enable_auto_accept") {
const ids = [...selectedIds];
clearSelection();
api.startAutoAccept(ids)
.then(() => { addToast("success", t("toast.autoAcceptEnabled", { count: ids.length })); loadAccounts(); })
.catch((e: unknown) => addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) })));
return;
}
const capturedIds = [...selectedIds];
const capturedAction = bulkAction;
const actionLabel = t(BULK_ACTIONS.find((a) => a.value === capturedAction)?.labelKey || "action.selectAction");
confirm(
t("confirm.executeBulk", { action: actionLabel, count: capturedIds.length }),
async () => {
try {
const result = await api.executeAction({ account_ids: capturedIds, action: capturedAction });
clearSelection();
setProcessingIds((prev) => { const s = new Set(prev); capturedIds.forEach((id) => s.add(id)); return s; });
addToast("info", t("misc.taskCount", { id: result.task_id, count: result.accounts_count }));
watchTask(result.task_id, capturedIds);
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
},
);
};
const handleToggleAutoAccept = async (id: number) => {
const acc = accounts.find((a) => a.id === id);
if (!acc) return;
const enable = !acc.auto_accept;
try {
if (enable) await api.startAutoAccept([id]);
else await api.stopAutoAccept([id]);
addToast("info", enable ? t("toast.autoAcceptOn") : t("toast.autoAcceptOff"));
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) => {
try {
await api.updateAccount(id, data);
setEditId(null);
addToast("success", t("toast.saved"));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleDelete = (id: number) => {
confirm(t("confirm.deleteAccount"), async () => {
try {
await api.deleteAccount(id);
addToast("success", t("toast.accountDeleted"));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const handleDeleteSelected = () => {
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
confirm(t("confirm.deleteSelected", { count: selectedIds.size }), async () => {
try {
const result = await api.deleteBulk([...selectedIds]);
clearSelection();
addToast("success", t("toast.deleted", { count: result.deleted }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const handleDeleteAll = () => {
if (!accounts.length) return addToast("warn", t("toast.noAccounts"));
confirm(t("confirm.deleteAll", { count: accounts.length }), async () => {
try {
const result = await api.deleteBulk([]);
clearSelection();
addToast("success", t("toast.deleted", { count: result.deleted }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const handleAddAccount = async (data: AccountCreate) => {
try {
await api.createAccount(data);
setShowAdd(false);
addToast("success", t("toast.accountAdded"));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleSendToMafiles = () => {
if (!selectedIds.size) return addToast("warn", t("toast.selectAccounts"));
sendToMafileManager([...selectedIds]);
setTab("mafiles");
addToast("info", t("toast.sentToMafile", { count: selectedIds.size }));
};
const handleOpenBrowser = async (id: number) => {
try {
const result = await api.openBrowser(id);
if (result.status === "revalidating") {
addToast("warn", t("toast.cookiesExpiredRevalidating"));
if (result.task_id) {
setProcessingIds((prev) => new Set(prev).add(id));
watchTask(result.task_id, [id]);
}
} else {
addToast("info", t("toast.browserOpening"));
}
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleAssignProxies = async () => {
confirm(t("confirm.assignProxies"), async () => {
try {
const r = await api.assignProxies();
addToast("success", t("toast.proxiesAssigned", { used: r.proxies_used, count: r.assigned }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const handleReassignProxies = async () => {
confirm(t("confirm.reassignProxies"), async () => {
try {
const r = await api.reassignProxies();
addToast("success", t("toast.proxiesReassigned", { used: r.proxies_used, count: r.assigned }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const handleClearProxies = async () => {
confirm(t("confirm.clearProxies"), async () => {
try {
const r = await api.clearProxies();
addToast("success", t("toast.proxiesClearedCount", { count: r.cleared }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
});
};
const editAccount = editId ? accounts.find((a) => a.id === editId) : null;
const SECTIONS: { id: AccountSection; label: string; icon: React.ReactNode }[] = [
{ id: "mafile", label: "Mafile", icon: <IconLock size={14} /> },
{ id: "logpass", label: "Log:Pass", icon: <IconKey size={14} /> },
{ id: "token", label: "Token", icon: <IconZap size={14} /> },
];
return (
<div className="flex gap-3 h-full min-h-0 overflow-hidden">
{/* Section sidebar */}
<div className="w-36 shrink-0 flex flex-col gap-1 bg-dark-800 border border-dark-600 rounded-lg p-2 self-start">
<p className="text-xs font-medium text-gray-500 px-2 py-1 uppercase tracking-wider">{t("section.dataType")}</p>
{SECTIONS.map((s) => (
<button
key={s.id}
onClick={() => setSection(s.id)}
className={`flex items-center gap-2 px-3 py-2 rounded text-sm text-left w-full transition-colors ${
section === s.id ? "bg-accent/20 text-accent" : "text-gray-400 hover:text-gray-200 hover:bg-dark-700"
}`}
>
{s.icon}{s.label}
</button>
))}
<SectionAccountCount />
</div>
{section === "logpass" && <div className="flex-1 min-w-0 min-h-0"><LogpassTab /></div>}
{section === "token" && <div className="flex-1 min-w-0 min-h-0"><TokenTab /></div>}
{section === "mafile" && <div className="flex flex-col gap-4 flex-1 min-w-0 min-h-0 overflow-hidden">
{/* Toolbar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
{/* Inline progress indicator */}
{activeTask && (
<>
<div className="h-5 border-l border-dark-500" />
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400 whitespace-nowrap">
{activeTask.type === "validate" ? t("task.validate") :
activeTask.type === "change_password" ? t("task.changePassword") :
activeTask.type === "random_password" ? t("task.randomPassword") :
activeTask.type === "change_email" ? t("task.changeEmail") :
activeTask.type === "change_phone" ? t("task.changePhone") :
activeTask.type === "remove_guard" ? t("task.removeGuard") :
activeTask.type}
</span>
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
activeTask.status === "completed" ? "bg-green-500" :
activeTask.status === "failed" ? "bg-red-500" :
"bg-accent"
}`}
style={{ width: `${activeTask.total > 1
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
/>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{activeTask.total > 1
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
{activeTask.total > 1
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
</span>
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
</div>
</>
)}
<div className="ml-auto flex items-center gap-2">
<ColumnSettingsDropdown />
<div className="h-5 border-l border-dark-500" />
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
</div>
</div>
{/* Table with action bar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
{/* Action bar (table header panel) */}
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
<BulkActionDropdown value={bulkAction} onChange={setBulkAction} disabledActions={bulkDisabledActions} />
<button onClick={handleBulkAction} disabled={!!activeTask || processingIds.size > 0} className="btn-accent text-sm disabled:opacity-40 disabled:cursor-not-allowed"><IconPlay size={12} className="inline mr-1" />{t("btn.execute")}</button>
{activeTask && activeTask.status === "running" && (
<button
onClick={async () => { try { await api.cancelTask(activeTask.id); } catch {} }}
className="btn-danger text-sm"
>
<IconXCircle size={12} className="inline mr-1" />{t("btn.cancel")}
</button>
)}
<div className="h-5 border-l border-dark-500" />
<button onClick={handleSendToMafiles} className="btn-secondary text-sm"><IconFolder size={14} className="inline mr-1" />В Mafile Manager</button>
<div className="h-5 border-l border-dark-500" />
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
<div className="ml-auto">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("ph.search")}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
/>
</div>
</div>
{/* Table */}
<div className="overflow-auto flex-1 min-h-0">
<AccountTable
accounts={filteredAccounts}
processingIds={processingIds}
accountResults={accountResults}
accountSteps={accountSteps}
onEdit={handleEdit}
onDelete={handleDelete}
onAction={handleAction}
onToggleAutoAccept={handleToggleAutoAccept}
onOpenBrowser={handleOpenBrowser}
/>
</div>
</div>
</div>}
{/* Modals */}
{editAccount && (
<EditModal account={editAccount} onSave={handleSaveEdit} onClose={() => setEditId(null)} />
)}
{showImport && <ImportModal onClose={() => setShowImport(false)} />}
{showAdd && <AddModal onSave={handleAddAccount} onClose={() => setShowAdd(false)} />}
{confirmMsg && onConfirmAction && (
<ConfirmModal
message={confirmMsg}
onConfirm={() => { onConfirmAction(); setConfirmMsg(null); setOnConfirmAction(null); }}
onCancel={() => { setConfirmMsg(null); setOnConfirmAction(null); }}
/>
)}
{promptData && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onMouseDown={handlePromptCancel}>
<div className="bg-dark-800 border border-dark-600 rounded-lg p-5 w-96 shadow-xl" onMouseDown={(e) => e.stopPropagation()}>
<p className="text-sm text-gray-300 mb-3">{promptData.message}</p>
<input
autoFocus
value={promptValue}
onChange={(e) => setPromptValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handlePromptSubmit()}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
placeholder={t("prompt.enterValue")}
/>
<div className="flex gap-2">
<button onClick={handlePromptSubmit} className="btn-primary flex-1">OK</button>
<button onClick={handlePromptCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,219 @@
import { useState, useRef, useEffect, useCallback } from "react";
import { createPortal } from "react-dom";
import type { Account } from "@/api/types";
import { api } from "@/api/client";
import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore";
import { IconZap } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
const ROW_ACTION_KEYS = [
{ action: "validate", labelKey: "action.validate" },
{ action: "change_password", labelKey: "action.changePassword" },
{ action: "random_password", labelKey: "action.randomPassword" },
{ action: "change_email", labelKey: "action.changeEmail" },
{ action: "change_phone", labelKey: "action.changePhone" },
{ action: "remove_guard", labelKey: "action.removeGuard" },
] as const;
const PARAM_ACTION_KEYS: Record<string, string> = {
change_password: "prompt.newPassword",
change_email: "prompt.newEmail",
change_phone: "prompt.newPhone",
};
const PARAM_KEYS: Record<string, string> = {
change_password: "new_password",
change_email: "new_email",
change_phone: "new_phone",
};
const CONFIRM_ACTION_KEYS: Record<string, string> = {
remove_guard: "confirm.removeGuard",
};
interface Props {
account: Account;
onAction: (id: number, action: string, params: Record<string, string>) => void;
onToggleAutoAccept: (id: number) => void;
}
export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
const [open, setOpen] = useState(false);
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
const [paramValue, setParamValue] = useState("");
const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null);
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const t = useT();
const updatePosition = useCallback(() => {
if (!btnRef.current) return;
const rect = btnRef.current.getBoundingClientRect();
const menuHeight = 320;
const spaceBelow = window.innerHeight - rect.bottom;
const top = spaceBelow < menuHeight
? Math.max(4, rect.top - menuHeight)
: rect.bottom + 4;
const left = Math.max(4, rect.right - 180);
setMenuPos({ top, left });
}, []);
useEffect(() => {
if (!open) return;
updatePosition();
function handleClick(e: MouseEvent) {
if (
menuRef.current && !menuRef.current.contains(e.target as Node) &&
btnRef.current && !btnRef.current.contains(e.target as Node)
) {
setOpen(false);
}
}
function handleScroll() { setOpen(false); }
document.addEventListener("mousedown", handleClick);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("mousedown", handleClick);
window.removeEventListener("scroll", handleScroll, true);
};
}, [open, updatePosition]);
const handleAction = (action: string, _label: string) => {
setOpen(false);
const confirmKey = CONFIRM_ACTION_KEYS[action];
if (confirmKey) {
setConfirmPrompt({ action, title: t(confirmKey) });
return;
}
const promptKey = PARAM_ACTION_KEYS[action];
if (promptKey) {
setParamPrompt({ action, title: t(promptKey) });
setParamValue("");
} else {
onAction(account.id, action, {});
}
};
const submitParam = () => {
if (!paramPrompt || !paramValue) return;
const paramKey = PARAM_KEYS[paramPrompt.action] || "value";
onAction(account.id, paramPrompt.action, { [paramKey]: paramValue });
setParamPrompt(null);
};
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
const handleRemoveProxy = async () => {
setOpen(false);
try {
await api.updateAccount(account.id, { proxy: "" });
addToast("success", t("toast.proxyCleared", { name: account.login }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleReassignProxy = async () => {
setOpen(false);
try {
const r = await api.reassignProxies();
addToast("success", t("toast.proxiesReassigned", { used: r.proxies_used, count: r.assigned }));
await loadAccounts();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
return (
<>
<div className="relative inline-block">
<button ref={btnRef} onClick={() => setOpen(!open)} className="btn-secondary px-2 py-1 text-xs">
<IconZap size={12} />
</button>
{open && menuPos && createPortal(
<div
ref={menuRef}
className="action-menu-dropdown"
style={{ position: "fixed", top: menuPos.top, left: menuPos.left }}
>
{ROW_ACTION_KEYS.map(({ action, labelKey }) => {
const disabled = action === "remove_guard" && !account.has_revocation_code;
if (disabled) {
return (
<span key={action} data-tooltip={t("action.noRevocationCode")} className="block">
<button className="action-menu-item opacity-40 cursor-not-allowed" disabled>
{t(labelKey)}
</button>
</span>
);
}
return (
<button key={action} onClick={() => handleAction(action, t(labelKey))} className="action-menu-item">
{t(labelKey)}
</button>
);
})}
<hr className="border-dark-600 my-1" />
<button onClick={handleRemoveProxy} className="action-menu-item">
{t("proxy.removeProxy")}
</button>
<button onClick={handleReassignProxy} className="action-menu-item">
{t("proxy.reassignProxy")}
</button>
<hr className="border-dark-600 my-1" />
<button onClick={() => { setOpen(false); onToggleAutoAccept(account.id); }} className="action-menu-item">
{aaLabel}
</button>
</div>,
document.body,
)}
</div>
{paramPrompt && (
<div className="confirm-overlay" onMouseDown={() => setParamPrompt(null)}>
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
<p className="text-sm text-gray-300 mb-3">{paramPrompt.title}</p>
<input
autoFocus
value={paramValue}
onChange={(e) => setParamValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && submitParam()}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
placeholder={paramPrompt.action.includes("phone") ? "+7 9123456789" : undefined}
/>
<div className="flex gap-2">
<button onClick={submitParam} className="btn-primary flex-1">OK</button>
<button onClick={() => setParamPrompt(null)} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
)}
{confirmPrompt && (
<div className="confirm-overlay" onMouseDown={() => setConfirmPrompt(null)}>
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
<p className="text-sm text-gray-300 mb-3">{confirmPrompt.title}</p>
<div className="flex gap-2">
<button
autoFocus
onClick={() => {
onAction(account.id, confirmPrompt.action, {});
setConfirmPrompt(null);
}}
className="btn-primary flex-1"
>
{t("confirm.yes")}
</button>
<button onClick={() => setConfirmPrompt(null)} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
)}
</>
);
}
@@ -0,0 +1,41 @@
import { useState } from "react";
import type { AccountCreate } from "@/api/types";
import { useT } from "@/lib/i18n";
interface Props {
onSave: (data: AccountCreate) => void;
onClose: () => void;
}
export function AddModal({ onSave, onClose }: Props) {
const t = useT();
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [email, setEmail] = useState("");
const [proxy, setProxy] = useState("");
const handleSave = () => {
if (!login || !password) return;
onSave({
login,
password,
email: email || undefined,
proxy: proxy || undefined,
});
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3">
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,37 @@
import { IconLock, IconUnlock } from "@/components/shared/Icons";
import { t } from "@/lib/i18n";
interface Props {
status: string;
}
const STATUS_KEYS: Record<string, [string, string]> = {
unknown: ["badge-unknown", "status.unknown"],
valid: ["badge-ok", "status.valid"],
invalid: ["badge-error", "status.invalid"],
locked: ["badge-error", "status.locked"],
limited: ["badge-running", "status.limited"],
};
export function StatusBadge({ status }: Props) {
const entry = STATUS_KEYS[status];
const [cls, text] = entry ? [entry[0], t(entry[1])] : ["badge-unknown", status];
return <span className={`badge ${cls}`}>{text}</span>;
}
export function BanBadge({ status }: { status: string | null }) {
if (status === "BANNED") return <span className="badge badge-error">{t("status.banned")}</span>;
if (status === "NO BAN") return <span className="badge badge-ok">{t("status.ok")}</span>;
return <span className="badge badge-unknown"></span>;
}
export function LockBadge({ status }: Props) {
if (status === "locked") return <span className="badge badge-error"><IconLock size={12} /></span>;
return <span className="badge badge-ok"><IconUnlock size={12} /></span>;
}
export function MafileBadge({ hasMafile }: { hasMafile: boolean }) {
return hasMafile
? <span className="badge badge-ok"></span>
: <span className="badge badge-unknown"></span>;
}
@@ -0,0 +1,59 @@
import { useState, useRef, useEffect } from "react";
import { useUiStore } from "@/stores/uiStore";
import type { ColumnSettings } from "@/api/types";
import { IconSettings } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
const COL_LABEL_KEYS: Record<keyof ColumnSettings, 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", 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",
notes: "col.notes", actions: "col.actions",
};
export function ColumnSettingsDropdown() {
const [open, setOpen] = useState(false);
const cols = useUiStore((s) => s.columnVisibility);
const toggleColumn = useUiStore((s) => s.toggleColumn);
const ref = useRef<HTMLDivElement>(null);
const t = useT();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="btn-secondary text-sm px-2 py-2"
title={t("tip.columnSettings")}
>
<IconSettings size={14} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
{(Object.keys(COL_LABEL_KEYS) as (keyof ColumnSettings)[]).map((key) => (
<label
key={key}
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
>
<input
type="checkbox"
checked={cols[key]}
onChange={() => toggleColumn(key)}
/>
{t(COL_LABEL_KEYS[key])}
</label>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,47 @@
import { useState, useRef, useEffect } from "react";
import type { Account, AccountUpdate } from "@/api/types";
import { useT } from "@/lib/i18n";
interface Props {
account: Account;
onSave: (id: number, data: AccountUpdate) => void;
onClose: () => void;
}
export function EditModal({ account, onSave, onClose }: Props) {
const t = useT();
const [login, setLogin] = useState(account.login);
const [password, setPassword] = useState(account.password);
const [email, setEmail] = useState(account.email ?? "");
const [proxy, setProxy] = useState(account.proxy ?? "");
const [notes, setNotes] = useState(account.notes ?? "");
const ref = useRef<HTMLInputElement>(null);
useEffect(() => { ref.current?.focus(); }, []);
const handleSave = () => {
onSave(account.id, {
login,
password,
email: email || undefined,
proxy: proxy || undefined,
notes: notes || undefined,
});
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.editAccount")}</h3>
<div className="space-y-3">
<input ref={ref} value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxy")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notes")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<button onClick={handleSave} className="btn-primary w-full">{t("btn.save")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,74 @@
import { useState, useRef } from "react";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useAccountStore } from "@/stores/accountStore";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
}
export function ImportModal({ onClose }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState<string>("");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) setFile(f);
};
const doImport = async () => {
if (!file) return;
setUploading(true);
try {
const res = await api.importAccounts(file);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
setUploading(false);
}
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formats")} <span className="text-gray-500 font-normal">({t("import.delimiter")} <code className="text-accent">:</code> {t("import.or")} <code className="text-accent">|</code>)</span></p>
<p className="font-mono text-xs">login:pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass</p>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
</div>
</div>
);
}
@@ -0,0 +1,43 @@
import { useState } from "react";
import { api } from "@/api/client";
import { useLogpassStore } from "@/stores/logpassStore";
import type { LogpassAccountCreate } from "@/api/types";
import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
}
export function LogpassAddModal({ onClose }: Props) {
const t = useT();
const [login, setLogin] = useState("");
const [password, setPassword] = useState("");
const [proxy, setProxy] = useState("");
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
const handleSave = async () => {
if (!login || !password) return;
const data: LogpassAccountCreate = {
login,
password,
proxy: proxy || undefined,
};
await api.createLogpassAccount(data);
await loadAccounts();
onClose();
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3">
<input autoFocus value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.login")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={password} onChange={(e) => setPassword(e.target.value)} placeholder={t("ph.password")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<button onClick={handleSave} className="btn-primary w-full">{t("btn.add")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,58 @@
import { useState, useRef, useEffect } from "react";
import { useUiStore } from "@/stores/uiStore";
import type { LogpassColumnSettings } from "@/api/types";
import { IconSettings } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
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",
prime: "col.prime", trophy: "col.trophy", behavior: "col.behavior",
license: "col.license", proxy: "col.proxy", notes: "col.notes", actions: "col.actions",
};
export function LogpassColumnSettingsDropdown() {
const [open, setOpen] = useState(false);
const cols = useUiStore((s) => s.logpassColumnVisibility);
const toggleColumn = useUiStore((s) => s.toggleLogpassColumn);
const ref = useRef<HTMLDivElement>(null);
const t = useT();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="btn-secondary text-sm px-2 py-2"
title={t("tip.columnSettings")}
>
<IconSettings size={14} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
{(Object.keys(COL_LABEL_KEYS) as (keyof LogpassColumnSettings)[]).map((key) => (
<label
key={key}
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
>
<input
type="checkbox"
checked={cols[key]}
onChange={() => toggleColumn(key)}
/>
{t(COL_LABEL_KEYS[key])}
</label>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,70 @@
import { useState } from "react";
import { api } from "@/api/client";
import { useLogpassStore } from "@/stores/logpassStore";
import type { LogpassAccount } from "@/api/types";
import { useT } from "@/lib/i18n";
interface Props {
account: LogpassAccount;
onClose: () => void;
}
export function LogpassEditModal({ account, onClose }: Props) {
const t = useT();
const [login, setLogin] = useState(account.login);
const [password, setPassword] = useState(account.password);
const [proxy, setProxy] = useState(account.proxy ?? "");
const [notes, setNotes] = useState(account.notes ?? "");
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
const handleSave = async () => {
if (!login || !password) return;
await api.updateLogpassAccount(account.id, {
login,
password,
proxy: proxy || undefined,
notes: notes || undefined,
});
await loadAccounts();
onClose();
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.editAccount")}</h3>
<div className="space-y-3">
<input
autoFocus
value={login}
onChange={(e) => setLogin(e.target.value)}
placeholder={t("ph.login")}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder={t("ph.password")}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={proxy}
onChange={(e) => setProxy(e.target.value)}
placeholder={t("ph.proxyOptional")}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<input
value={notes}
onChange={(e) => setNotes(e.target.value)}
placeholder={t("ph.notes")}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm"
/>
<div className="flex gap-2">
<button onClick={handleSave} className="btn-primary flex-1">{t("btn.save")}</button>
<button onClick={onClose} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,78 @@
import { useState, useRef } from "react";
import { api } from "@/api/client";
import { useLogpassStore } from "@/stores/logpassStore";
import { useUiStore } from "@/stores/uiStore";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
}
export function LogpassImportModal({ onClose }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) setFile(f);
};
const doImport = async () => {
if (!file) return;
setUploading(true);
try {
const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
const res = await api.importLogpass(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
setUploading(false);
}
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importAccounts")}</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
<p className="font-mono text-xs">login:pass</p>
<p className="font-mono text-xs">login|pass</p>
<p className="font-mono text-xs">CSV: login,password,steam_id,ban,prime,trophy,behavior,license</p>
<p className="text-xs text-yellow-500/80 mt-1"> Mafile-файлы и JSON не принимаются. Только log:pass / log|pass.</p>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxtCsv")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt,.csv" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
</div>
</div>
);
}
@@ -0,0 +1,658 @@
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { api } from "@/api/client";
import { useLogpassStore } from "@/stores/logpassStore";
import { useUiStore } from "@/stores/uiStore";
import { ConfirmModal } from "@/components/shared/Modals";
import { LogpassImportModal } from "./LogpassImportModal";
import { LogpassAddModal } from "./LogpassAddModal";
import { LogpassEditModal } from "./LogpassEditModal";
import { LogpassColumnSettingsDropdown } from "./LogpassColumnSettingsDropdown";
import { StatusBadge, BanBadge } from "./Badges";
import { SteamLevelBadge } from "./SteamLevelBadge";
import { copyText } from "@/lib/clipboard";
import {
IconDownload, IconPlus, IconPlay, IconTrash, IconCheckCircle, IconXCircle,
IconEye, IconEyeOff, IconGlobe, IconRefresh, IconBan,
IconCheck, IconAlertTriangle, IconEdit,
} from "@/components/shared/Icons";
import type { Task, LogpassColumnSettings } from "@/api/types";
import { useT } from "@/lib/i18n";
type AccResults = Record<string, { status: string; error?: string }>;
type AccSteps = Record<string, { step: number; total: number }>;
function parseAccResults(raw: Task["account_results"]): AccResults | null {
if (!raw) return null;
if (typeof raw === "string") {
try { return JSON.parse(raw) as AccResults; } catch { return null; }
}
return raw;
}
export function LogpassTab() {
const accounts = useLogpassStore((s) => s.accounts);
const selectedIds = useLogpassStore((s) => s.selectedIds);
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
const selectAll = useLogpassStore((s) => s.selectAll);
const clearSelection = useLogpassStore((s) => s.clearSelection);
const addToast = useUiStore((s) => s.addToast);
const cols = useUiStore((s) => s.logpassColumnVisibility);
const t = useT();
const [searchQuery, setSearchQuery] = useState("");
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [editAccountId, setEditAccountId] = useState<number | null>(null);
const [confirmModal, setConfirmModal] = useState<{ open: boolean; message: string; onConfirm: () => void }>({ open: false, message: "", onConfirm: () => {} });
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
const [accountResults, setAccountResults] = useState<AccResults>({});
const [accountSteps, setAccountSteps] = useState<AccSteps>({});
const [activeTask, setActiveTask] = useState<Task | null>(null);
const BATCH = 100;
const [visibleCount, setVisibleCount] = useState(BATCH);
type SortField = "last_online" | "prime" | "trophy" | "behavior" | 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(field);
setSortDir("asc");
}
};
const sentinelRef = useRef<HTMLTableRowElement>(null);
useEffect(() => { loadAccounts(); }, [loadAccounts]);
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
const watchTask = useCallback((taskId: string) => {
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data) as Task;
setActiveTask(data);
if (data.account_results) {
const r = parseAccResults(data.account_results);
if (r) setAccountResults(r);
}
if (data.account_steps) setAccountSteps(data.account_steps as AccSteps);
if (data.status !== "running") {
es.close();
setProcessingIds(new Set());
setActiveTask(null);
loadAccounts();
}
} catch { /* ignore */ }
};
es.onerror = () => es.close();
}, [loadAccounts]);
const handleValidate = useCallback(async () => {
const ids = [...selectedIds];
if (!ids.length) return;
clearSelection();
setProcessingIds(new Set(ids));
setAccountResults({});
setAccountSteps({});
try {
const { task_id } = await api.validateLogpass(ids);
setActiveTask({ id: task_id, type: "logpass_validate", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleValidateSingle = useCallback(async (id: number) => {
setProcessingIds(new Set([id]));
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; });
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; });
try {
const { task_id } = await api.validateLogpass([id]);
setActiveTask({ id: task_id, type: "logpass_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [watchTask, addToast]);
const handleFullParse = useCallback(async () => {
const ids = [...selectedIds];
if (!ids.length) return;
clearSelection();
setProcessingIds(new Set(ids));
setAccountResults({});
setAccountSteps({});
try {
const { task_id } = await api.fullParseLogpass(ids);
setActiveTask({ id: task_id, type: "logpass_full_parse", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleDeleteSelected = () => {
const ids = [...selectedIds];
if (!ids.length) return;
confirm(t("confirm.deleteLogpassSelected", { count: ids.length }), async () => {
await api.deleteLogpassBulk(ids);
clearSelection();
loadAccounts();
});
};
const handleDeleteAll = () => {
confirm(t("confirm.deleteLogpassAll", { count: accounts.length }), async () => {
await api.deleteLogpassBulk(accounts.map((a) => a.id));
clearSelection();
loadAccounts();
});
};
const handleAssignProxies = () => {
confirm(t("confirm.assignProxies"), async () => {
try {
const r = await api.assignLogpassProxies();
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const handleReassignProxies = () => {
confirm(t("confirm.reassignProxiesAll"), async () => {
try {
const r = await api.reassignLogpassProxies();
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const handleClearProxies = () => {
confirm(t("confirm.clearProxies"), async () => {
try {
const r = await api.clearLogpassProxies();
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const filtered = useMemo(() => {
if (!searchQuery.trim()) return accounts;
const q = searchQuery.trim();
// lvl comparison syntax: lvl>10, lvl<10, lvl>=10, lvl<=10, lvl=10
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
if (lvlMatch) {
const op = lvlMatch[1];
const val = parseInt(lvlMatch[2], 10);
return accounts.filter((a) => {
const lvl = a.steam_level;
if (lvl == null) return false;
if (op === ">") return lvl > val;
if (op === ">=") return lvl >= val;
if (op === "<") return lvl < val;
if (op === "<=") return lvl <= val;
return lvl === val;
});
}
const ql = q.toLowerCase();
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.license ?? "").toLowerCase().includes(ql)
);
}, [accounts, searchQuery]);
const sorted = useMemo(() => {
if (!sortField) return filtered;
const dir = sortDir === "asc" ? 1 : -1;
const EMPTY = Symbol();
return [...filtered].sort((a, b) => {
let va: number | typeof EMPTY = EMPTY;
let vb: number | typeof EMPTY = EMPTY;
if (sortField === "last_online") {
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;
};
va = parseOnline(a.last_online);
vb = parseOnline(b.last_online);
} else if (sortField === "prime") {
va = a.prime === "Enabled" ? 0 : a.prime === "Disabled" ? 1 : EMPTY;
vb = b.prime === "Enabled" ? 0 : b.prime === "Disabled" ? 1 : EMPTY;
} else if (sortField === "trophy") {
va = a.trophy != null && a.trophy !== "\u2014" ? parseInt(a.trophy, 10) || 0 : EMPTY;
vb = b.trophy != null && b.trophy !== "\u2014" ? parseInt(b.trophy, 10) || 0 : EMPTY;
} else if (sortField === "behavior") {
va = a.behavior != null && a.behavior !== "\u2014" ? parseInt(a.behavior, 10) || 0 : EMPTY;
vb = b.behavior != null && b.behavior !== "\u2014" ? parseInt(b.behavior, 10) || 0 : EMPTY;
}
// Empty values always sink to the bottom
if (va === EMPTY && vb === EMPTY) return 0;
if (va === EMPTY) return 1;
if (vb === EMPTY) return -1;
if (va === vb) return 0;
return (va < vb ? -1 : 1) * dir;
});
}, [filtered, sortField, sortDir]);
useEffect(() => { setVisibleCount(BATCH); }, [searchQuery, sortField, sortDir]);
const visible = useMemo(() => sorted.slice(0, visibleCount), [sorted, visibleCount]);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const obs = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting) setVisibleCount((c) => Math.min(c + BATCH, sorted.length));
}, { rootMargin: "400px" });
obs.observe(el);
return () => obs.disconnect();
}, [sorted.length, visibleCount]);
const proxyLabels = useMemo(() => {
const map = new Map<number, string>();
const unique: string[] = [];
for (const a of accounts) {
if (a.proxy && !unique.includes(a.proxy)) unique.push(a.proxy);
}
const proxyToNum = new Map<string, number>();
unique.forEach((p, i) => proxyToNum.set(p, i + 1));
for (const a of accounts) {
if (a.proxy) map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
}
return map;
}, [accounts]);
const handleOpenBrowser = async (id: number) => {
try {
const result = await api.openLogpassBrowser(id);
if (result.status === "revalidating") {
addToast("warn", t("toast.cookiesExpiredRevalidating"));
if (result.task_id) {
setProcessingIds(new Set([id]));
setActiveTask({ id: result.task_id, type: "logpass_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
watchTask(result.task_id);
}
} else {
addToast("success", t("toast.browserOpening"));
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
};
return (
<div className="flex flex-col gap-4 h-full min-h-0">
{/* Toolbar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
{/* Progress indicator — exact match to AccountsTab */}
{activeTask && (
<>
<div className="h-5 border-l border-dark-500" />
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400 whitespace-nowrap">{t("task.validate")}</span>
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
activeTask.status === "completed" ? "bg-green-500" :
activeTask.status === "failed" ? "bg-red-500" : "bg-accent"
}`}
style={{ width: `${activeTask.total > 1
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
/>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{activeTask.total > 1
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
{activeTask.total > 1
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
</span>
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
</div>
</>
)}
<div className="ml-auto flex items-center gap-2">
<LogpassColumnSettingsDropdown />
<div className="h-5 border-l border-dark-500" />
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
</div>
</div>
{/* Table with action bar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
{/* Action bar */}
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
<button
onClick={handleValidate}
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-accent text-sm disabled:opacity-40"
>
<IconPlay size={12} className="inline mr-1" />{t("btn.validate")}
</button>
<button
onClick={handleFullParse}
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-secondary text-sm disabled:opacity-40"
>
<IconPlay size={12} className="inline mr-1" />{t("btn.fullParse")}
</button>
{activeTask && activeTask.status === "running" && (
<button
onClick={async () => { try { await api.cancelTask(activeTask.id); } catch {} }}
className="btn-danger text-sm"
>
<IconXCircle size={12} className="inline mr-1" />{t("btn.cancel")}
</button>
)}
<div className="h-5 border-l border-dark-500" />
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
<div className="ml-auto">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("ph.searchLogpass")}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
/>
</div>
</div>
{/* Table */}
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800 z-10">
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
<th className="px-3 py-2">
<div className="flex items-center gap-1.5">
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} />
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>}
</div>
</th>
<th className="w-5" />
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>}
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>}
{cols.last_online && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("last_online")}>{t("col.lastOnline")}{sortField === "last_online" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
{cols.steam_id && <th className="px-3 py-2">Steam ID</th>}
{cols.login && <th className="px-3 py-2">{t("col.login")}</th>}
{cols.password && <th className="px-3 py-2">{t("col.password")}</th>}
{cols.login_pass && <th className="px-3 py-2">Login:Pass</th>}
{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.prime && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("prime")}>Prime{sortField === "prime" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
{cols.trophy && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("trophy")}>Trophy{sortField === "trophy" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
{cols.behavior && <th className="px-3 py-2 cursor-pointer select-none hover:text-gray-300" onClick={() => toggleSort("behavior")}>Behavior{sortField === "behavior" ? (sortDir === "asc" ? " ▲" : " ▼") : ""}</th>}
{cols.license && <th className="px-3 py-2">{t("col.license")}</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 && <th className="px-3 py-2">{t("col.actions")}</th>}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={16} className="text-center py-12 text-gray-500">{t("empty.logpass")}</td></tr>
) : (
<>
{visible.map((a) => (
<LogpassRow
key={a.id}
account={a}
cols={cols}
isProcessing={processingIds.has(a.id)}
accountResult={accountResults[String(a.id)]}
accountStep={accountSteps[String(a.id)]}
proxyLabel={proxyLabels.get(a.id) ?? null}
onDelete={(id) => confirm(t("confirm.deleteLogpass", { name: a.login }), async () => { await api.deleteLogpassAccount(id); loadAccounts(); })}
onOpenBrowser={handleOpenBrowser}
onValidate={handleValidateSingle}
onEdit={(id) => setEditAccountId(id)}
/>
))}
{visibleCount < filtered.length && (
<tr ref={sentinelRef}><td colSpan={16} className="text-center py-3 text-xs text-gray-500">{t("paging.shown", { visible: visibleCount, total: filtered.length })}</td></tr>
)}
</>
)}
</tbody>
</table>
</div>
</div>
{editAccountId != null && (() => {
const acc = accounts.find((a) => a.id === editAccountId);
return acc ? <LogpassEditModal account={acc} onClose={() => setEditAccountId(null)} /> : null;
})()}
{showImport && <LogpassImportModal onClose={() => setShowImport(false)} />}
{showAdd && <LogpassAddModal onClose={() => setShowAdd(false)} />}
{confirmModal.open && (
<ConfirmModal
message={confirmModal.message}
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
/>
)}
</div>
);
}
/* ─── Row component ─── */
function LogpassRow({ account: a, cols, isProcessing, accountResult, accountStep, proxyLabel, onDelete, onOpenBrowser, onValidate, onEdit }: {
account: import("@/api/types").LogpassAccount;
cols: LogpassColumnSettings;
isProcessing: boolean;
accountResult?: { status: string; error?: string };
accountStep?: { step: number; total: number };
proxyLabel: string | null;
onDelete: (id: number) => void;
onOpenBrowser: (id: number) => void;
onValidate: (id: number) => void;
onEdit: (id: number) => void;
}) {
const selectedIds = useLogpassStore((s) => s.selectedIds);
const toggleSelect = useLogpassStore((s) => s.toggleSelect);
const addToast = useUiStore((s) => s.addToast);
const hidePasswords = useUiStore((s) => s.hidePasswords);
const loadAccounts = useLogpassStore((s) => s.loadAccounts);
const [revealed, setRevealed] = useState(false);
const [editingNotes, setEditingNotes] = useState(false);
const [notesValue, setNotesValue] = useState(a.notes || "");
const [licExpanded, setLicExpanded] = useState(false);
const t = useT();
const masked = hidePasswords && !revealed;
const dots = "••••••••";
const handleCopy = async (text: string) => {
const ok = await copyText(text);
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
};
return (
<tr className="hover:bg-dark-700/50 transition">
<td className="px-3 py-2">
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => toggleSelect(a.id)} />
</td>
{/* Progress/status icon */}
<td className="px-1 py-2 w-5">
{isProcessing ? (
<div className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
{accountStep && <span className="text-[10px] text-gray-500 whitespace-nowrap">[{accountStep.step}/{accountStep.total}]</span>}
</div>
) : accountResult?.status === "ok" ? (
<IconCheck size={16} className="text-gray-400" />
) : accountResult?.status === "error" ? (
<span title={accountResult.error}><IconAlertTriangle size={16} className="text-red-400" /></span>
) : null}
</td>
{/* Browser login button */}
{cols.browser && (
<td className="px-3 py-2 whitespace-nowrap text-center">
{a.has_cookies ? (
<button
onClick={() => onOpenBrowser(a.id)}
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
title={t("tip.openBrowser")}
>{t("col.browser")}</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
)}
{/* Profile — avatar + nickname + level */}
{cols.profile && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
<span className="text-xs">{a.nickname || "—"}</span>
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
</div>
</td>
)}
{/* Last online */}
{cols.last_online && (
<td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">
{a.last_online || "—"}
</td>
)}
{/* Steam ID */}
{cols.steam_id && (
<td className="px-3 py-2 text-xs">
{a.steam_id ? (
<a href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 hover:underline">
{a.steam_id}
</a>
) : "—"}
</td>
)}
{/* Login */}
{cols.login && <td className="px-3 py-2 font-medium">{a.login}</td>}
{/* Password */}
{cols.password && (
<td className="px-3 py-2 text-gray-400 select-none whitespace-nowrap">
<span className="cursor-pointer" onClick={() => handleCopy(a.password)} title={t("tip.copyPassword")}>
{masked ? dots : a.password}
</span>
<button onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }} className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle" title={masked ? t("tip.show") : t("tip.hide")}>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
</td>
)}
{/* Login:Pass */}
{cols.login_pass && (
<td className="px-3 py-2 text-gray-400 text-xs select-none whitespace-nowrap">
<span className="cursor-pointer" onClick={() => handleCopy(`${a.login}:${a.password}`)} title={t("tip.copyLoginPass")}>
{masked ? `${a.login}:${dots}` : `${a.login}:${a.password}`}
</span>
<button onClick={(e) => { e.stopPropagation(); setRevealed(!revealed); }} className="ml-1.5 text-gray-500 hover:text-gray-300 inline-flex align-middle" title={masked ? t("tip.show") : t("tip.hide")}>
{masked ? <IconEye size={14} /> : <IconEyeOff size={14} />}
</button>
</td>
)}
{/* Status */}
{cols.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
{/* Ban */}
{cols.ban && <td className="px-3 py-2"><BanBadge status={a.ban_status} /></td>}
{/* Prime, Trophy, Behavior */}
{cols.prime && <td className="px-3 py-2 text-xs text-gray-400">{a.prime ?? "—"}</td>}
{cols.trophy && <td className="px-3 py-2 text-xs text-gray-400">{a.trophy ?? "—"}</td>}
{cols.behavior && <td className="px-3 py-2 text-xs text-gray-400">{a.behavior ?? "—"}</td>}
{/* License — expandable on click */}
{cols.license && (
<td
className={`px-3 py-2 text-xs text-gray-400 cursor-pointer select-none ${licExpanded ? "max-w-none whitespace-normal break-words" : "truncate max-w-[200px] whitespace-nowrap"}`}
title={licExpanded ? undefined : (a.license ?? "")}
onClick={() => setLicExpanded((v) => !v)}
>
{a.license ?? "—"}
</td>
)}
{/* Proxy */}
{cols.proxy && (
<td className="px-3 py-2 text-xs">
{a.proxy ? (
<span className="text-blue-400 cursor-pointer hover:underline" onClick={() => handleCopy(a.proxy!)} title={a.proxy}>
{proxyLabel || a.proxy}
</span>
) : "—"}
</td>
)}
{/* Notes — inline editable */}
{cols.notes && (
<td className="px-3 py-2 text-xs max-w-[180px]">
{editingNotes ? (
<input
autoFocus
className="w-full bg-dark-600 border border-dark-500 rounded px-1.5 py-0.5 text-xs text-gray-200 outline-none focus:border-accent"
value={notesValue}
onChange={(e) => setNotesValue(e.target.value)}
onBlur={async () => {
setEditingNotes(false);
if (notesValue !== (a.notes || "")) {
try {
await api.updateLogpassAccount(a.id, { notes: notesValue });
addToast("success", t("toast.noteSaved"));
loadAccounts();
} catch { addToast("error", t("toast.noteSaveError")); }
}
}}
onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setNotesValue(a.notes || ""); setEditingNotes(false); } }}
/>
) : (
<span
className="cursor-pointer text-gray-400 hover:text-gray-200 truncate block"
onClick={() => { setNotesValue(a.notes || ""); setEditingNotes(true); }}
title={a.notes || t("tip.addNote")}
>
{a.notes || "—"}
</span>
)}
</td>
)}
{/* Actions */}
{cols.actions && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<button onClick={() => onValidate(a.id)} disabled={isProcessing} className="text-gray-400 hover:text-accent disabled:opacity-40" title={t("tip.validate")}><IconPlay size={14} /></button>
<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>
</div>
</td>
)}
</tr>
);
}
@@ -0,0 +1,21 @@
interface Props {
level: number;
}
export function SteamLevelBadge({ level }: Props) {
if (level >= 100) {
const hundred = Math.floor(level / 100) * 100;
return (
<div className={`steam-lvl l100p img-${hundred}`} title={`Level ${level}`}>
<span>{level}</span>
</div>
);
}
const ten = Math.floor(level / 10) * 10;
return (
<div className={`steam-lvl l${ten}`} title={`Level ${level}`}>
<span>{level}</span>
</div>
);
}
@@ -0,0 +1,53 @@
import { useState } from "react";
import { api } from "@/api/client";
import { useTokenStore } from "@/stores/tokenStore";
import type { TokenAccountCreate } from "@/api/types";
import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
}
export function TokenAddModal({ onClose }: Props) {
const t = useT();
const [token, setToken] = useState("");
const [login, setLogin] = useState("");
const [proxy, setProxy] = useState("");
const [notes, setNotes] = useState("");
const loadAccounts = useTokenStore((s) => s.loadAccounts);
const handleSave = async () => {
if (!token.trim()) return;
const data: TokenAccountCreate = {
token: token.trim(),
login: login.trim() || undefined,
proxy: proxy.trim() || undefined,
notes: notes.trim() || undefined,
};
await api.createTokenAccount(data);
await loadAccounts();
onClose();
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.addToken")}</h3>
<div className="space-y-3">
<textarea
autoFocus
value={token}
onChange={(e) => setToken(e.target.value)}
placeholder="Refresh token"
rows={3}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm font-mono resize-none"
/>
<input value={login} onChange={(e) => setLogin(e.target.value)} placeholder={t("ph.loginOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<input value={notes} onChange={(e) => setNotes(e.target.value)} placeholder={t("ph.notesOptional")} className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
<button onClick={handleSave} disabled={!token.trim()} className="btn-primary w-full disabled:opacity-40">{t("btn.add")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,56 @@
import { useState, useRef, useEffect } from "react";
import { useUiStore } from "@/stores/uiStore";
import type { TokenColumnSettings } from "@/api/types";
import { IconSettings } from "@/components/shared/Icons";
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",
};
export function TokenColumnSettingsDropdown() {
const [open, setOpen] = useState(false);
const cols = useUiStore((s) => s.tokenColumnVisibility);
const toggleColumn = useUiStore((s) => s.toggleTokenColumn);
const ref = useRef<HTMLDivElement>(null);
const t = useT();
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div className="relative" ref={ref}>
<button
onClick={() => setOpen(!open)}
className="btn-secondary text-sm px-2 py-2"
title={t("tip.columnSettings")}
>
<IconSettings size={14} />
</button>
{open && (
<div className="absolute right-0 top-full mt-1 bg-dark-700 border border-dark-600 rounded-lg shadow-xl z-50 p-2 min-w-[160px]">
{(Object.keys(COL_LABEL_KEYS) as (keyof TokenColumnSettings)[]).map((key) => (
<label
key={key}
className="flex items-center gap-2 px-2 py-1 hover:bg-dark-600 rounded cursor-pointer text-sm"
>
<input
type="checkbox"
checked={cols[key]}
onChange={() => toggleColumn(key)}
/>
{t(COL_LABEL_KEYS[key])}
</label>
))}
</div>
)}
</div>
);
}
@@ -0,0 +1,75 @@
import { useState, useRef } from "react";
import { api } from "@/api/client";
import { useTokenStore } from "@/stores/tokenStore";
import { useUiStore } from "@/stores/uiStore";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
onClose: () => void;
}
export function TokenImportModal({ onClose }: Props) {
const t = useT();
const [file, setFile] = useState<File | null>(null);
const [result, setResult] = useState("");
const [uploading, setUploading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useTokenStore((s) => s.loadAccounts);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const f = e.dataTransfer.files[0];
if (f) setFile(f);
};
const doImport = async () => {
if (!file) return;
setUploading(true);
try {
const text = await file.text();
const lines = text.split("\n").map((l) => l.trim()).filter(Boolean);
if (!lines.length) { setResult(t("toast.fileEmpty")); setUploading(false); return; }
const res = await api.importTokens(lines);
setResult(t("toast.importResult", { imported: res.imported, skipped: res.skipped }));
addToast("success", `${res.imported} ok, ${res.skipped} skip`);
await loadAccounts();
} catch (e: unknown) {
setResult(e instanceof Error ? e.message : t("misc.errorStr"));
} finally {
setUploading(false);
}
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div className="confirm-box max-w-lg w-full" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.importTokens")}</h3>
<div className="text-sm text-gray-400 mb-3 space-y-1">
<p className="font-medium text-gray-300">{t("import.formatsColon")}</p>
<p className="font-mono text-xs">{t("import.tokenFormat")}</p>
</div>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-dark-500 rounded-lg p-6 mb-3 text-center cursor-pointer transition-colors hover:border-accent"
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">
{file ? t("import.selected", { name: file.name }) : t("import.dragTxt")}
</p>
</div>
<input ref={inputRef} type="file" accept=".txt" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setFile(f);
}} />
<button onClick={doImport} disabled={!file || uploading} className="btn-primary w-full">
{uploading ? t("import.uploading") : t("import.importBtn")}
</button>
{result && <p className="mt-3 text-sm text-gray-300">{result}</p>}
</div>
</div>
);
}
@@ -0,0 +1,439 @@
import { useEffect, useCallback, useState, useMemo } from "react";
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 { SteamLevelBadge } from "./SteamLevelBadge";
import { TokenImportModal } from "./TokenImportModal";
import { TokenAddModal } from "./TokenAddModal";
import { TokenColumnSettingsDropdown } from "./TokenColumnSettingsDropdown";
import {
IconDownload, IconPlus, IconTrash, IconPlay, IconGlobe, IconRefresh, IconBan,
IconCheckCircle, IconXCircle, IconCheck, IconAlertTriangle,
} from "@/components/shared/Icons";
import type { Task } from "@/api/types";
import { useT } from "@/lib/i18n";
type AccResults = Record<string, { status: string; error?: string }>;
type AccSteps = Record<string, { step: number; total: number }>;
function parseAccResults(raw: Task["account_results"]): AccResults | null {
if (!raw) return null;
if (typeof raw === "string") {
try { return JSON.parse(raw) as AccResults; } catch { return null; }
}
return raw;
}
export function TokenTab() {
const accounts = useTokenStore((s) => s.accounts);
const selectedIds = useTokenStore((s) => s.selectedIds);
const loadAccounts = useTokenStore((s) => s.loadAccounts);
const toggleSelect = useTokenStore((s) => s.toggleSelect);
const selectAll = useTokenStore((s) => s.selectAll);
const clearSelection = useTokenStore((s) => s.clearSelection);
const addToast = useUiStore((s) => s.addToast);
const cols = useUiStore((s) => s.tokenColumnVisibility);
const loadTokenColumnSettings = useUiStore((s) => s.loadTokenColumnSettings);
const t = useT();
const [disclaimerAccepted, setDisclaimerAccepted] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [showImport, setShowImport] = useState(false);
const [showAdd, setShowAdd] = useState(false);
const [confirmModal, setConfirmModal] = useState<{ open: boolean; message: string; onConfirm: () => void }>({ open: false, message: "", onConfirm: () => {} });
const [processingIds, setProcessingIds] = useState<Set<number>>(new Set());
const [accountResults, setAccountResults] = useState<AccResults>({});
const [accountSteps, setAccountSteps] = useState<AccSteps>({});
const [activeTask, setActiveTask] = useState<Task | null>(null);
useEffect(() => { loadAccounts(); loadTokenColumnSettings(); }, [loadAccounts, loadTokenColumnSettings]);
const confirm = (msg: string, fn: () => void) => setConfirmModal({ open: true, message: msg, onConfirm: fn });
const watchTask = useCallback((taskId: string) => {
const es = new EventSource(`/api/tasks/${taskId}/stream`);
es.onmessage = (ev) => {
try {
const data = JSON.parse(ev.data) as Task;
setActiveTask(data);
if (data.account_results) {
const r = parseAccResults(data.account_results);
if (r) setAccountResults(r);
}
if (data.account_steps) setAccountSteps(data.account_steps as AccSteps);
if (data.status !== "running") {
es.close();
setProcessingIds(new Set());
loadAccounts();
}
} catch { /* ignore */ }
};
es.onerror = () => es.close();
}, [loadAccounts]);
const handleValidate = useCallback(async () => {
const ids = [...selectedIds];
if (!ids.length) return;
clearSelection();
setProcessingIds(new Set(ids));
setAccountResults({});
setAccountSteps({});
try {
const { task_id } = await api.validateTokens(ids);
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: ids.length, result: null, error: null, created_at: "", updated_at: "" });
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [selectedIds, clearSelection, watchTask, addToast]);
const handleValidateSingle = useCallback(async (id: number) => {
setProcessingIds(new Set([id]));
setAccountResults((r) => { const n = { ...r }; delete n[String(id)]; return n; });
setAccountSteps((s) => { const n = { ...s }; delete n[String(id)]; return n; });
try {
const { task_id } = await api.validateTokens([id]);
setActiveTask({ id: task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
watchTask(task_id);
} catch (err) {
setProcessingIds(new Set());
addToast("error", String(err));
}
}, [watchTask, addToast]);
const handleDeleteSelected = () => {
const ids = [...selectedIds];
if (!ids.length) return;
confirm(t("confirm.deleteTokenSelected", { count: ids.length }), async () => {
await api.deleteTokenBulk(ids);
clearSelection();
loadAccounts();
});
};
const handleDeleteAll = () => {
confirm(t("confirm.deleteTokenAll", { count: accounts.length }), async () => {
await api.deleteTokenBulk(accounts.map((a) => a.id));
clearSelection();
loadAccounts();
});
};
const handleAssignProxies = () => {
confirm(t("confirm.assignProxies"), async () => {
try {
const r = await api.assignTokenProxies();
addToast("success", t("toast.proxiesAssignedShort", { count: r.assigned }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const handleReassignProxies = () => {
confirm(t("confirm.reassignProxiesAll"), async () => {
try {
const r = await api.reassignTokenProxies();
addToast("success", t("toast.proxiesReassignedShort", { count: r.assigned }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const handleClearProxies = () => {
confirm(t("confirm.clearProxies"), async () => {
try {
const r = await api.clearTokenProxies();
addToast("success", t("toast.proxiesClearedShort", { count: r.cleared }));
loadAccounts();
} catch (e: unknown) { addToast("error", e instanceof Error ? e.message : String(e)); }
});
};
const handleOpenBrowser = async (id: number) => {
try {
const result = await api.openTokenBrowser(id);
if (result.status === "revalidating") {
addToast("warn", t("toast.cookiesExpiredRevalidating"));
if (result.task_id) {
setProcessingIds(new Set([id]));
setActiveTask({ id: result.task_id, type: "token_validate", status: "running", progress: 0, total: 1, result: null, error: null, created_at: "", updated_at: "" });
watchTask(result.task_id);
}
} else {
addToast("success", t("toast.browserOpening"));
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
}
};
const allSelected = accounts.length > 0 && selectedIds.size === accounts.length;
const filtered = useMemo(() => {
if (!searchQuery.trim()) return accounts;
const q = searchQuery.trim();
const lvlMatch = q.match(/^lvl\s*(>=|<=|>|<|=)\s*(\d+)$/i);
if (lvlMatch) {
const op = lvlMatch[1];
const val = parseInt(lvlMatch[2], 10);
return accounts.filter((a) => {
const lvl = a.steam_level;
if (lvl == null) return false;
if (op === ">") return lvl > val;
if (op === ">=") return lvl >= val;
if (op === "<") return lvl < val;
if (op === "<=") return lvl <= val;
return lvl === val;
});
}
const ql = q.toLowerCase();
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)
);
}, [accounts, searchQuery]);
const proxyLabels = useMemo(() => {
const map = new Map<number, string>();
const unique: string[] = [];
for (const a of accounts) {
if (a.proxy && !unique.includes(a.proxy)) unique.push(a.proxy);
}
const proxyToNum = new Map<string, number>();
unique.forEach((p, i) => proxyToNum.set(p, i + 1));
for (const a of accounts) {
if (a.proxy) map.set(a.id, t("proxy.label", { num: proxyToNum.get(a.proxy) ?? 0 }));
}
return map;
}, [accounts, t]);
return (
<div className="relative flex flex-col gap-4 h-full min-h-0">
{/* Development warning gate */}
{!disclaimerAccepted && (
<div className="absolute inset-0 z-50 bg-dark-900/80 backdrop-blur-sm flex items-center justify-center">
<div className="bg-dark-800 border border-yellow-500/40 rounded-xl p-6 max-w-lg mx-4 shadow-2xl">
<div className="flex items-center gap-3 mb-4">
<IconAlertTriangle size={28} className="text-yellow-400 shrink-0" />
<h3 className="text-lg font-semibold text-yellow-400">{t("token.disclaimerTitle")}</h3>
</div>
<div className="text-sm text-gray-300 space-y-2 mb-5">
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody1") }} />
<p className="text-red-400 font-medium" dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody2") }} />
<p dangerouslySetInnerHTML={{ __html: t("token.disclaimerBody3") }} />
</div>
<label className="flex items-center gap-2 cursor-pointer select-none group">
<input
type="checkbox"
checked={disclaimerAccepted}
onChange={(e) => setDisclaimerAccepted(e.target.checked)}
className="w-4 h-4 rounded border-yellow-500/50 accent-yellow-500"
/>
<span className="text-sm text-gray-400 group-hover:text-gray-300 transition">{t("token.acceptRisk")}</span>
</label>
</div>
</div>
)}
{/* Toolbar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg px-3 py-2 flex flex-wrap items-center gap-2 shrink-0">
<button onClick={() => setShowImport(true)} className="btn-primary"><IconDownload size={14} className="inline mr-1" />{t("btn.import")}</button>
<button onClick={() => setShowAdd(true)} className="btn-secondary"><IconPlus size={14} className="inline mr-1" />{t("btn.add")}</button>
{/* Progress indicator */}
{activeTask && (
<>
<div className="h-5 border-l border-dark-500" />
<div className="flex items-center gap-2 min-w-0">
<span className="text-xs text-gray-400 whitespace-nowrap">{t("task.validate")}</span>
<div className="w-28 bg-dark-600 rounded-full h-2.5 shrink-0">
<div
className={`h-2.5 rounded-full transition-all duration-300 ${
activeTask.status === "completed" ? "bg-green-500" :
activeTask.status === "failed" ? "bg-red-500" : "bg-accent"
}`}
style={{ width: `${activeTask.total > 1
? (activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0)
: (activeTask.total_steps ? Math.round(((activeTask.step ?? 0) / activeTask.total_steps) * 100) : 0)}%` }}
/>
</div>
<span className="text-xs text-gray-500 whitespace-nowrap">
{activeTask.total > 1
? `${activeTask.progress}/${activeTask.total}${activeTask.active_count ? ` (${activeTask.active_count})` : ""}`
: (activeTask.step_label || `${activeTask.progress}/${activeTask.total}`)}
{activeTask.total > 1
? ` (${activeTask.total > 0 ? Math.round((activeTask.progress / activeTask.total) * 100) : 0}%)`
: (activeTask.total_steps ? ` (${activeTask.step}/${activeTask.total_steps})` : "")}
</span>
{activeTask.status === "completed" && <IconCheckCircle size={14} className="text-green-400" />}
{activeTask.status === "failed" && <span title={activeTask.error || ""}><IconXCircle size={14} className="text-red-400" /></span>}
</div>
</>
)}
<div className="ml-auto flex items-center gap-2">
<TokenColumnSettingsDropdown />
<div className="h-5 border-l border-dark-500" />
<button onClick={handleDeleteSelected} className="btn-danger-outline text-xs">{t("btn.deleteSelected")}</button>
<button onClick={handleDeleteAll} className="btn-danger text-xs">{t("btn.deleteAll")}</button>
</div>
</div>
{/* Table with action bar */}
<div className="bg-dark-800 border border-dark-600 rounded-lg overflow-hidden flex-1 min-h-0 flex flex-col">
{/* Action bar */}
<div className="px-3 py-2 flex items-center gap-2 border-b border-dark-600 shrink-0 bg-dark-800">
<button
onClick={handleValidate}
disabled={selectedIds.size === 0 || processingIds.size > 0}
className="btn-accent text-sm disabled:opacity-40"
>
<IconPlay size={12} className="inline mr-1" />{t("btn.validate")}
</button>
<div className="h-5 border-l border-dark-500" />
<button onClick={handleAssignProxies} className="btn-secondary text-sm"><IconGlobe size={14} className="inline mr-1" />{t("btn.assignProxies")}</button>
<button onClick={handleReassignProxies} className="btn-secondary text-sm"><IconRefresh size={14} className="inline mr-1" />{t("btn.reassign")}</button>
<button onClick={handleClearProxies} className="btn-danger-outline text-sm"><IconBan size={14} className="inline mr-1" />{t("btn.clearProxies")}</button>
<div className="ml-auto">
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={t("ph.search")}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1.5 text-xs w-80 placeholder:text-gray-500 outline-none focus:border-accent"
/>
</div>
</div>
{/* Table */}
<div className="overflow-auto flex-1 min-h-0">
<table className="w-full text-sm">
<thead className="sticky top-0 bg-dark-800 z-10">
<tr className="text-left text-xs text-gray-500 border-b border-dark-600">
<th className="px-3 py-2">
<div className="flex items-center gap-1.5">
<input type="checkbox" checked={allSelected} onChange={() => allSelected ? clearSelection() : selectAll()} />
{selectedIds.size > 0 && <span className="text-accent font-medium">{selectedIds.size}</span>}
</div>
</th>
<th className="w-5" />
{cols.browser && <th className="px-3 py-2">{t("col.browser")}</th>}
{cols.profile && <th className="px-3 py-2">{t("col.profile")}</th>}
{cols.last_online && <th className="px-3 py-2">{t("col.lastOnline")}</th>}
{cols.steam_id && <th className="px-3 py-2">Steam ID</th>}
{cols.login && <th className="px-3 py-2">{t("col.login")}</th>}
{cols.token && <th className="px-3 py-2">{t("col.token")}</th>}
{cols.status && <th className="px-3 py-2">{t("col.status")}</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 && <th className="px-3 py-2">{t("col.actions")}</th>}
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr><td colSpan={12} className="text-center py-12 text-gray-500">{t("empty.tokens")}</td></tr>
) : (
filtered.map((a) => {
const isProcessing = processingIds.has(a.id);
const result = accountResults[String(a.id)];
const step = accountSteps[String(a.id)];
return (
<tr key={a.id} className="hover:bg-dark-700/50 transition border-t border-dark-700">
<td className="px-3 py-2">
<input type="checkbox" checked={selectedIds.has(a.id)} onChange={() => toggleSelect(a.id)} />
</td>
{/* Status icon */}
<td className="px-1 py-2 w-5">
{isProcessing ? (
<div className="flex items-center gap-1">
<div className="w-4 h-4 border-2 border-accent border-t-transparent rounded-full animate-spin shrink-0" />
{step && <span className="text-[10px] text-gray-500 whitespace-nowrap">[{step.step}/{step.total}]</span>}
</div>
) : result?.status === "ok" ? (
<IconCheck size={16} className="text-gray-400" />
) : result?.status === "error" ? (
<span title={result.error}><IconAlertTriangle size={16} className="text-red-400" /></span>
) : null}
</td>
{cols.browser && (
<td className="px-3 py-2 whitespace-nowrap text-center">
{a.has_cookies ? (
<button
onClick={() => handleOpenBrowser(a.id)}
className="px-2 py-0.5 text-xs font-medium rounded bg-blue-600/20 text-blue-400 border border-blue-500/30 hover:bg-blue-600/40 hover:text-blue-300 active:scale-95 transition"
title={t("tip.openBrowser")}
>{t("col.browser")}</button>
) : (
<span className="text-gray-600 text-xs"></span>
)}
</td>
)}
{cols.profile && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm" alt="" />}
<span className="text-xs">{a.nickname || "—"}</span>
{a.steam_level != null && <SteamLevelBadge level={a.steam_level} />}
</div>
</td>
)}
{cols.last_online && <td className="px-3 py-2 text-xs text-gray-400 whitespace-nowrap">{a.last_online || "—"}</td>}
{cols.steam_id && (
<td className="px-3 py-2 text-xs">
{a.steam_id ? (
<a href={`https://steamcommunity.com/profiles/${encodeURIComponent(a.steam_id)}/`} target="_blank" rel="noopener noreferrer" className="text-blue-400 hover:text-blue-300 hover:underline">
{a.steam_id}
</a>
) : "—"}
</td>
)}
{cols.login && <td className="px-3 py-2 font-medium">{a.login ?? "—"}</td>}
{cols.token && <td className="px-3 py-2 font-mono text-gray-400 truncate max-w-[180px]" title={a.token}>{a.token.slice(0, 24)}</td>}
{cols.status && <td className="px-3 py-2"><StatusBadge status={a.status} /></td>}
{cols.proxy && <td className="px-3 py-2 text-xs text-gray-500 whitespace-nowrap">{proxyLabels.get(a.id) ?? "—"}</td>}
{cols.notes && <td className="px-3 py-2 text-xs text-gray-500">{a.notes ?? "—"}</td>}
{cols.actions && (
<td className="px-3 py-2 whitespace-nowrap">
<div className="flex items-center gap-1.5">
<button
onClick={() => handleValidateSingle(a.id)}
disabled={isProcessing}
className="text-gray-400 hover:text-accent disabled:opacity-40 transition"
title={t("tip.validate")}
><IconPlay size={14} /></button>
<button
onClick={() => confirm(t("confirm.deleteToken", { name: a.login ?? a.token.slice(0, 16) }), async () => { await api.deleteTokenAccount(a.id); loadAccounts(); })}
className="text-gray-400 hover:text-red-400 transition"
title={t("tip.delete")}
><IconTrash size={14} /></button>
</div>
</td>
)}
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{showImport && <TokenImportModal onClose={() => setShowImport(false)} />}
{showAdd && <TokenAddModal onClose={() => setShowAdd(false)} />}
{confirmModal.open && (
<ConfirmModal
message={confirmModal.message}
onConfirm={() => { confirmModal.onConfirm(); setConfirmModal((p) => ({ ...p, open: false })); }}
onCancel={() => setConfirmModal((p) => ({ ...p, open: false }))}
/>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
import { useState, useEffect } from "react";
import { useLocale } from "@/lib/i18n";
import type { Locale } from "@/lib/i18n";
import { api } from "@/api/client";
export function Header() {
const [locale, setLocale] = useLocale();
const [version, setVersion] = useState("");
useEffect(() => {
api.getVersion().then((r) => setVersion(r.version)).catch(() => {});
}, []);
return (
<header className="bg-dark-800 border-b border-dark-600 px-4 py-3 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="flex items-center gap-1.5 cursor-pointer" onClick={() => window.location.reload()}>
<svg viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg" className="w-7 h-7 shrink-0 fill-white">
<path d="M11.979 0C5.678 0 0.511 4.86 0.022 11.037l6.432 2.658c0.545 -0.371 1.203 -0.59 1.912 -0.59 0.063 0 0.125 0.004 0.188 0.006l2.861 -4.142V8.91c0 -2.495 2.028 -4.524 4.524 -4.524 2.494 0 4.524 2.031 4.524 4.527s-2.03 4.525 -4.524 4.525h-0.105l-4.076 2.911c0 0.052 0.004 0.105 0.004 0.159 0 1.875 -1.515 3.396 -3.39 3.396 -1.635 0 -3.016 -1.173 -3.331 -2.727L0.436 15.27C1.862 20.307 6.486 24 11.979 24c6.627 0 11.999 -5.373 11.999 -12S18.605 0 11.979 0zM7.54 18.21l-1.473 -0.61c0.262 0.543 0.714 0.999 1.314 1.25 1.297 0.539 2.793 -0.076 3.332 -1.375 0.263 -0.63 0.264 -1.319 0.005 -1.949s-0.75 -1.121 -1.377 -1.383c-0.624 -0.26 -1.29 -0.249 -1.878 -0.03l1.523 0.63c0.956 0.4 1.409 1.5 1.009 2.455 -0.397 0.957 -1.497 1.41 -2.454 1.012H7.54zm11.415 -9.303c0 -1.662 -1.353 -3.015 -3.015 -3.015 -1.665 0 -3.015 1.353 -3.015 3.015 0 1.665 1.35 3.015 3.015 3.015 1.663 0 3.015 -1.35 3.015 -3.015zm-5.273 -0.005c0 -1.252 1.013 -2.266 2.265 -2.266 1.249 0 2.266 1.014 2.266 2.266 0 1.251 -1.017 2.265 -2.266 2.265 -1.253 0 -2.265 -1.014 -2.265 -2.265z"/>
</svg>
<h1 className="text-lg font-bold text-white tracking-wide">SteamPanel</h1>
</div>
<span className="text-xs text-gray-500">{version && `v${version}`}</span>
<span className="text-xs text-gray-500">by{" "}
<a
href="https://t.me/lolzdm"
target="_blank"
rel="noopener noreferrer"
className="hover:text-accent transition"
>
@lolzdm
</a>
</span>
</div>
<div className="flex items-center gap-4">
<button
onClick={() => setLocale(locale === "ru" ? "en" : "ru" as Locale)}
className="flex items-center gap-1.5 text-xs text-gray-400 hover:text-gray-200 transition px-2 py-1 border border-dark-500 hover:border-gray-500 rounded"
>
<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" className="opacity-80">
<circle cx="12" cy="12" r="10"/>
<path d="M2 12h20M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
</svg>
{locale === "ru" ? "EN" : "RU"}
</button>
</div>
</header>
);
}
+38
View File
@@ -0,0 +1,38 @@
import { useUiStore } from "@/stores/uiStore";
import type { TabId } from "@/api/types";
import { IconUsers, IconFolderOpen, IconWrench, IconScrollText } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
type IconComponent = typeof IconUsers;
const TAB_KEYS: { id: TabId; labelKey: string; icon: IconComponent }[] = [
{ id: "accounts", labelKey: "tab.accounts", icon: IconUsers },
{ id: "mafiles", labelKey: "tab.mafiles", icon: IconFolderOpen },
{ id: "tools", labelKey: "tab.tools", icon: IconWrench },
{ id: "logs", labelKey: "tab.logs", icon: IconScrollText },
];
export function TabNav() {
const activeTab = useUiStore((s) => s.activeTab);
const setTab = useUiStore((s) => s.setTab);
const t = useT();
return (
<nav className="bg-dark-800 border-b border-dark-600 flex gap-1 px-4 overflow-x-auto">
{TAB_KEYS.map(({ id, labelKey, icon: Icon }) => (
<button
key={id}
onClick={() => setTab(id)}
className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium transition-colors border-b-2 -mb-px ${
activeTab === id
? "border-accent text-accent"
: "border-transparent text-gray-400 hover:text-gray-200"
}`}
>
<Icon size={14} />
{t(labelKey)}
</button>
))}
</nav>
);
}
+22
View File
@@ -0,0 +1,22 @@
import { useUiStore } from "@/stores/uiStore";
export function ToastContainer() {
const toasts = useUiStore((s) => s.toasts);
const removeToast = useUiStore((s) => s.removeToast);
if (toasts.length === 0) return null;
return (
<div className="toast-container">
{toasts.map((t) => (
<div
key={t.id}
className={`toast toast-${t.type}`}
onClick={() => removeToast(t.id)}
>
{t.message}
</div>
))}
</div>
);
}
@@ -0,0 +1,75 @@
import { useState, useEffect, useRef } from "react";
import type { LogEntry } from "@/api/types";
import { api } from "@/api/client";
import { useSSE } from "@/hooks/useSSE";
export function LogStream() {
const [logs, setLogs] = useState<LogEntry[]>([]);
const containerRef = useRef<HTMLDivElement>(null);
const autoScroll = useRef(true);
useEffect(() => {
api.getLogs().then(setLogs).catch(() => {});
}, []);
useSSE<LogEntry>("/api/logs/stream", (entry) => {
setLogs((prev) => {
const next = [...prev, entry];
if (next.length > 500) next.splice(0, next.length - 500);
return next;
});
});
useEffect(() => {
if (autoScroll.current && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [logs]);
const handleScroll = () => {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
autoScroll.current = scrollHeight - scrollTop - clientHeight < 40;
};
const levelColor = (level: string): string => {
switch (level) {
case "error":
case "critical": return "text-red-400";
case "warning": return "text-yellow-400";
case "success": return "text-green-400";
case "debug": return "text-gray-500";
default: return "text-gray-300";
}
};
return (
<div className="bg-dark-800 border border-dark-600 rounded-lg flex flex-col" style={{ maxHeight: 'calc(100vh - 200px)' }}>
<div className="px-3 py-2 border-b border-dark-600 flex items-center justify-between shrink-0">
<h3 className="font-semibold text-sm">📜 Лог</h3>
<button
onClick={() => setLogs([])}
className="text-xs text-gray-500 hover:text-gray-300 transition-colors"
>
Очистить
</button>
</div>
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto min-h-0 p-2 font-mono text-xs space-y-px"
>
{logs.map((entry, i) => (
<div key={i} className="flex gap-2">
<span className="text-gray-600 shrink-0">{entry.ts}</span>
<span className={`shrink-0 w-3 text-center ${levelColor(entry.level)}`}>{entry.icon}</span>
<span className={`${levelColor(entry.level)} break-all min-w-0`}>{entry.msg}</span>
</div>
))}
{logs.length === 0 && (
<p className="text-gray-600 text-center py-4">Нет логов</p>
)}
</div>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { LogStream } from "./LogStream";
export function LogsTab() {
return <LogStream />;
}
@@ -0,0 +1,72 @@
import { useTaskStore } from "@/stores/taskStore";
import { usePolling } from "@/hooks/usePolling";
import { useUiStore } from "@/stores/uiStore";
import { api } from "@/api/client";
export function TaskPanel() {
const tasks = useTaskStore((s) => s.tasks);
const hasRunning = useTaskStore((s) => s.hasRunning);
const loadTasks = useTaskStore((s) => s.loadTasks);
const addToast = useUiStore((s) => s.addToast);
// Bug #5: poll every 10s, pause when no running tasks
usePolling(loadTasks, hasRunning ? 5_000 : 30_000);
const cancelTask = async (id: string) => {
try {
await api.cancelTask(id);
addToast("info", "Задача отменена");
await loadTasks();
} catch (e: unknown) {
addToast("error", `Ошибка: ${e instanceof Error ? e.message : String(e)}`);
}
};
const statusColor = (status: string) => {
switch (status) {
case "running": return "text-blue-400";
case "completed": return "text-green-400";
case "failed": return "text-red-400";
case "pending": return "text-yellow-400";
default: return "text-gray-400";
}
};
return (
<div className="bg-dark-800 border border-dark-600 rounded-lg flex flex-col h-full min-h-[300px]">
<div className="px-3 py-2 border-b border-dark-600">
<h3 className="font-semibold text-sm"> Активные задачи</h3>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{tasks.length === 0 ? (
<p className="text-gray-600 text-center text-xs py-4">Нет задач</p>
) : (
tasks.map((t) => (
<div key={t.id} className="bg-dark-700 rounded p-2 text-xs">
<div className="flex items-center justify-between mb-1">
<span className="font-medium">{t.type}</span>
<span className={`text-xs ${statusColor(t.status)}`}>{t.status}</span>
</div>
{t.total > 0 && (
<div className="progress-bar mb-1">
<div
className="progress-fill"
style={{ width: `${Math.round((t.progress / t.total) * 100)}%` }}
/>
</div>
)}
<div className="flex items-center justify-between text-gray-500">
<span>{t.progress}/{t.total}</span>
{(t.status === "running" || t.status === "pending") && (
<button onClick={() => cancelTask(t.id)} className="text-red-400 hover:underline">Отмена</button>
)}
</div>
{t.result && <p className="text-green-400 mt-1">{t.result}</p>}
{t.error && <p className="text-red-400 mt-1">{t.error}</p>}
</div>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,44 @@
import { useRef } from "react";
import { IconUpload } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
interface Props {
onFiles: (files: File[]) => void;
}
export function DropZone({ onFiles }: Props) {
const t = useT();
const inputRef = useRef<HTMLInputElement>(null);
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
const files = Array.from(e.dataTransfer.files).filter((f) => f.name.endsWith(".mafile"));
if (files.length) onFiles(files);
};
return (
<>
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className="border-2 border-dashed border-dark-500 rounded-lg p-6 text-center cursor-pointer transition-colors hover:border-accent"
>
<IconUpload size={32} className="mx-auto mb-2 text-gray-500" />
<p className="text-sm text-gray-400">{t("import.dragMafile")}</p>
</div>
<input
ref={inputRef}
type="file"
accept=".mafile"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []);
if (files.length) onFiles(files);
e.target.value = "";
}}
/>
</>
);
}
@@ -0,0 +1,270 @@
import { useState } from "react";
import { useT } from "@/lib/i18n";
import { IconPackage, IconFileText, IconDownload, IconChevronRight } from "@/components/shared/Icons";
import type { MafileExportRequest } from "@/api/types";
const MAFILE_FIELDS = [
"shared_secret", "serial_number", "revocation_code", "uri",
"account_name", "token_gid", "identity_secret", "secret_1",
"device_id", "server_time", "fully_enrolled",
];
const SESSION_FIELDS = [
"SessionID", "AccessToken", "RefreshToken", "SteamID", "SteamLoginSecure",
];
const NAME_VARS = [
{ value: "{username}", label: "username" },
{ value: "{steamid}", label: "steamid" },
];
interface Props {
accountIds: number[];
onExport: (req: MafileExportRequest) => void;
}
export function ExportSettings({ accountIds, onExport }: Props) {
const t = useT();
const [fields, setFields] = useState<Set<string>>(new Set(MAFILE_FIELDS));
const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS));
const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder");
// Naming templates
const [folderNameTemplate, setFolderNameTemplate] = useState("{username}");
const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile");
const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt");
// .txt options
const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false);
const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false);
const [skipFolders, setSkipFolders] = useState(false);
const [txtFormat, setTxtFormat] = useState("{login}:{password}:{email}:{email_password}");
const toggleField = (set: Set<string>, key: string, setter: (s: Set<string>) => void) => {
const next = new Set(set);
if (next.has(key)) next.delete(key);
else next.add(key);
setter(next);
};
const insertVar = (setter: (fn: (prev: string) => string) => void, varName: string) => {
setter((prev) => prev + varName);
};
const handleExport = () => {
onExport({
fields: [...fields],
session_fields: [...sessionFields],
format,
account_ids: accountIds,
folder_name_template: folderNameTemplate,
mafile_name_template: mafileNameTemplate,
txt_name_template: txtNameTemplate,
include_txt_per_folder: includeTxtPerFolder,
include_global_txt: includeGlobalTxt,
skip_folders: skipFolders,
txt_format: txtFormat,
});
};
return (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4 space-y-4">
<h4 className="font-semibold text-sm"><IconPackage size={14} className="inline mr-1" />{t("mafile.exportSettings")}</h4>
{/* Format */}
<div>
<p className="text-xs text-gray-400 mb-1">{t("mafile.exportFormat")}</p>
<select
value={format}
onChange={(e) => {
const val = e.target.value as MafileExportRequest["format"];
setFormat(val);
if (val === "single_file") {
setIncludeGlobalTxt(false);
setSkipFolders(false);
}
}}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm"
>
<option value="flat_mafiles">{t("mafile.flatFiles")}</option>
<option value="per_account_folder">{t("mafile.perAccountFolder")}</option>
<option value="single_file">{t("mafile.singleFile")}</option>
</select>
</div>
{/* Naming templates */}
{format !== "single_file" && (
<div className="space-y-3 border border-dark-600 rounded-lg p-3">
<p className="text-xs text-gray-400 font-medium">
{t("mafile.variables")} <code className="text-accent">{"{username}"}</code>, <code className="text-accent">{"{steamid}"}</code>
</p>
{format === "per_account_folder" && (
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.folderName")}</label>
<div className="flex gap-1">
<input
value={folderNameTemplate}
onChange={(e) => setFolderNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setFolderNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
title={t("mafile.insert", { value: v.value })}
>
{v.label}
</button>
))}
</div>
</div>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.mafileName")}</label>
<div className="flex gap-1">
<input
value={mafileNameTemplate}
onChange={(e) => setMafileNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setMafileNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
title={t("mafile.insert", { value: v.value })}
>
{v.label}
</button>
))}
</div>
</div>
</div>
)}
{/* .txt options */}
<div className="space-y-2 border border-dark-600 rounded-lg p-3">
<p className="text-xs text-gray-400 font-medium"><IconFileText size={12} className="inline mr-1" />{t("mafile.txtSettings")}</p>
{format !== "single_file" && (
<>
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeGlobalTxt}
onChange={(e) => {
setIncludeGlobalTxt(e.target.checked);
if (!e.target.checked) setSkipFolders(false);
}}
/>
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code> {t("mafile.withAllAccounts")}
</label>
{format === "per_account_folder" && (
<label className="flex items-center gap-2 text-sm cursor-pointer">
<input
type="checkbox"
checked={includeTxtPerFolder}
onChange={(e) => setIncludeTxtPerFolder(e.target.checked)}
/>
{t("mafile.addTxtPerFolder")}
</label>
)}
<label className={`flex items-center gap-2 text-sm ${includeGlobalTxt ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}>
<input
type="checkbox"
checked={skipFolders}
disabled={!includeGlobalTxt}
onChange={(e) => setSkipFolders(e.target.checked)}
/>
{t("mafile.skipFolders")}
</label>
</>
)}
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
<>
{format === "per_account_folder" && includeTxtPerFolder && (
<div>
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtFolderName")}</label>
<div className="flex gap-1">
<input
value={txtNameTemplate}
onChange={(e) => setTxtNameTemplate(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
{NAME_VARS.map((v) => (
<button
key={v.value}
onClick={() => insertVar(setTxtNameTemplate, v.value)}
className="btn-secondary px-2 py-1 text-xs"
>
{v.label}
</button>
))}
</div>
</div>
)}
<div>
<label className="text-xs text-gray-400 block mb-1">
{t("mafile.txtLineFormat")}
</label>
<input
value={txtFormat}
onChange={(e) => setTxtFormat(e.target.value)}
className="w-full bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono"
/>
<p className="text-xs text-gray-500 mt-1">
{t("mafile.variables")} <code className="text-accent">{"{login}"}</code> <code className="text-accent">{"{password}"}</code> <code className="text-accent">{"{email}"}</code> <code className="text-accent">{"{email_password}"}</code> <code className="text-accent">{"{steam_id}"}</code> <code className="text-accent">{"{proxy}"}</code> <code className="text-accent">{"{mafile}"}</code>
</p>
</div>
</>
)}
</div>
{/* Field selection */}
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.mafileFields")} ({fields.size}/{MAFILE_FIELDS.length})
</summary>
<div className="flex flex-wrap gap-2 mt-2">
{MAFILE_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} />
{f}
</label>
))}
</div>
</details>
<details className="group">
<summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.sessionFields")} ({sessionFields.size}/{SESSION_FIELDS.length})
</summary>
<div className="flex flex-wrap gap-2 mt-2">
{SESSION_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1 text-xs cursor-pointer">
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} />
{f}
</label>
))}
</div>
</details>
<button
onClick={handleExport}
disabled={accountIds.length === 0}
className={accountIds.length === 0 ? "btn-primary opacity-50 cursor-not-allowed" : "btn-primary"}
>
<IconDownload size={14} className="inline mr-1" />{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
</button>
</div>
);
}
@@ -0,0 +1,67 @@
import { api } from "@/api/client";
import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore";
import type { MafileExportRequest } from "@/api/types";
import { ExportSettings } from "./ExportSettings";
import { useT } from "@/lib/i18n";
import { IconClipboard } from "@/components/shared/Icons";
export function MafileTab() {
const t = useT();
const accounts = useAccountStore((s) => s.accounts);
const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds);
const clearMafileManager = useAccountStore((s) => s.clearMafileManager);
const addToast = useUiStore((s) => s.addToast);
// Bug #3: only show explicitly sent accounts
const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id));
const handleExport = async (req: MafileExportRequest) => {
try {
const blob = await api.exportZip(req);
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "mafiles_export.zip";
a.click();
URL.revokeObjectURL(url);
addToast("success", t("toast.exportDone"));
} catch (e: unknown) {
addToast("error", t("misc.exportError", { error: e instanceof Error ? e.message : String(e) }));
}
};
return (
<div className="space-y-4 h-full overflow-y-auto pr-1">
{/* Sent accounts list — Bug #3: only sent accounts shown */}
{sentAccounts.length > 0 && (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="font-semibold">
<IconClipboard size={14} className="inline mr-1" />{t("mafile.sentAccounts")} ({sentAccounts.length})
</h3>
<button onClick={clearMafileManager} className="btn-danger-outline text-xs">{t("mafile.clearBtn")}</button>
</div>
<div className="max-h-48 overflow-y-auto space-y-1">
{sentAccounts.map((a) => (
<div key={a.id} className="flex items-center gap-2 text-xs text-gray-300 min-w-0">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />}
<span className="truncate">{a.login}</span>
<span className="text-gray-500 truncate">{a.steam_id || ""}</span>
<span className={a.mafile_path ? "text-green-400" : "text-gray-500"}>
{a.mafile_path ? "✓ mafile" : "—"}
</span>
</div>
))}
</div>
</div>
)}
{/* Export settings — Bug #6: flexible export */}
<ExportSettings
accountIds={[...mafileManagerIds]}
onExport={handleExport}
/>
</div>
);
}
+114
View File
@@ -0,0 +1,114 @@
import type { SVGProps } from "react";
type P = SVGProps<SVGSVGElement> & { size?: number };
const defaults = (size = 16): SVGProps<SVGSVGElement> => ({
width: size,
height: size,
viewBox: "0 0 24 24",
fill: "none",
stroke: "currentColor",
strokeWidth: 2,
strokeLinecap: "round" as const,
strokeLinejoin: "round" as const,
});
export const IconDownload = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="7 10 12 15 17 10" /><line x1="12" y1="15" x2="12" y2="3" /></svg>
);
export const IconUpload = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" /><polyline points="17 8 12 3 7 8" /><line x1="12" y1="3" x2="12" y2="15" /></svg>
);
export const IconPlus = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><line x1="12" y1="5" x2="12" y2="19" /><line x1="5" y1="12" x2="19" y2="12" /></svg>
);
export const IconCheck = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polyline points="20 6 9 17 4 12" /></svg>
);
export const IconX = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" /></svg>
);
export const IconAlertTriangle = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z" /><line x1="12" y1="9" x2="12" y2="13" /><line x1="12" y1="17" x2="12.01" y2="17" /></svg>
);
export const IconFolder = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></svg>
);
export const IconGlobe = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="2" y1="12" x2="22" y2="12" /><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" /></svg>
);
export const IconRefresh = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polyline points="23 4 23 10 17 10" /><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10" /></svg>
);
export const IconBan = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="4.93" y1="4.93" x2="19.07" y2="19.07" /></svg>
);
export const IconSearch = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><circle cx="11" cy="11" r="8" /><line x1="21" y1="21" x2="16.65" y2="16.65" /></svg>
);
export const IconEye = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle cx="12" cy="12" r="3" /></svg>
);
export const IconEyeOff = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" /><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" /><line x1="1" y1="1" x2="23" y2="23" /><path d="M14.12 14.12a3 3 0 1 1-4.24-4.24" /></svg>
);
export const IconKey = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.78 7.78 5.5 5.5 0 0 1 7.78-7.78zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4" /></svg>
);
export const IconZap = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" /></svg>
);
export const IconSettings = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z" /></svg>
);
export const IconEdit = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" /><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" /></svg>
);
export const IconTrash = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polyline points="3 6 5 6 21 6" /><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" /></svg>
);
export const IconLock = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 10 0v4" /></svg>
);
export const IconUnlock = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><rect x="3" y="11" width="18" height="11" rx="2" ry="2" /><path d="M7 11V7a5 5 0 0 1 9.9-1" /></svg>
);
export const IconPackage = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><line x1="16.5" y1="9.4" x2="7.5" y2="4.21" /><path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z" /><polyline points="3.27 6.96 12 12.01 20.73 6.96" /><line x1="12" y1="22.08" x2="12" y2="12" /></svg>
);
export const IconFileText = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" /><polyline points="14 2 14 8 20 8" /><line x1="16" y1="13" x2="8" y2="13" /><line x1="16" y1="17" x2="8" y2="17" /><polyline points="10 9 9 9 8 9" /></svg>
);
export const IconClipboard = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2" /><rect x="8" y="2" width="8" height="4" rx="1" ry="1" /></svg>
);
export const IconPlay = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polygon points="5 3 19 12 5 21 5 3" /></svg>
);
export const IconXCircle = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><circle cx="12" cy="12" r="10" /><line x1="15" y1="9" x2="9" y2="15" /><line x1="9" y1="9" x2="15" y2="15" /></svg>
);
export const IconCheckCircle = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" /><polyline points="22 4 12 14.01 9 11.01" /></svg>
);
export const IconLoader = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p} className={`animate-spin ${p.className ?? ""}`}><line x1="12" y1="2" x2="12" y2="6" /><line x1="12" y1="18" x2="12" y2="22" /><line x1="4.93" y1="4.93" x2="7.76" y2="7.76" /><line x1="16.24" y1="16.24" x2="19.07" y2="19.07" /><line x1="2" y1="12" x2="6" y2="12" /><line x1="18" y1="12" x2="22" y2="12" /><line x1="4.93" y1="19.07" x2="7.76" y2="16.24" /><line x1="16.24" y1="7.76" x2="19.07" y2="4.93" /></svg>
);
export const IconShieldOff = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M19.69 14a6.9 6.9 0 0 0 .31-2V5l-8-3-3.16 1.18" /><path d="M4.73 4.73 4 5v7c0 6 8 10 8 10a20.29 20.29 0 0 0 5.62-4.38" /><line x1="1" y1="1" x2="23" y2="23" /></svg>
);
export const IconUsers = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" /><path d="M22 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" /></svg>
);
export const IconFolderOpen = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="m6 14 1.5-2.9A2 2 0 0 1 9.24 10H20a2 2 0 0 1 1.94 2.5l-1.54 6a2 2 0 0 1-1.95 1.5H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h3.9a2 2 0 0 1 1.69.9l.81 1.2a2 2 0 0 0 1.67.9H18a2 2 0 0 1 2 2v2" /></svg>
);
export const IconWrench = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z" /></svg>
);
export const IconScrollText = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><path d="M15 12h-5" /><path d="M15 8h-5" /><path d="M19 17V5a2 2 0 0 0-2-2H4" /><path d="M8 21h12a2 2 0 0 0 2-2v-1a1 1 0 0 0-1-1H11a1 1 0 0 0-1 1v1a2 2 0 1 1-4 0V5a2 2 0 1 0-4 0v2" /></svg>
);
export const IconChevronRight = ({ size = 16, ...p }: P) => (
<svg {...defaults(size)} {...p}><polyline points="9 18 15 12 9 6" /></svg>
);
+58
View File
@@ -0,0 +1,58 @@
import { useState, useRef, useEffect } from "react";
import { useT } from "@/lib/i18n";
interface ConfirmModalProps {
message: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal({ message, onConfirm, onCancel }: ConfirmModalProps) {
const t = useT();
return (
<div className="confirm-overlay" onMouseDown={onCancel}>
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
<p className="text-sm text-gray-300 mb-4">{message}</p>
<div className="flex gap-2">
<button onClick={onConfirm} className="btn-primary flex-1">{t("confirm.yes")}</button>
<button onClick={onCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
);
}
interface InputModalProps {
title: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}
export function InputModal({ title, onSubmit, onCancel }: InputModalProps) {
const t = useT();
const [value, setValue] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
return (
<div className="confirm-overlay" onMouseDown={onCancel}>
<div className="confirm-box" onMouseDown={(e) => e.stopPropagation()}>
<p className="text-sm text-gray-300 mb-3">{title}</p>
<input
ref={inputRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && value && onSubmit(value)}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-3"
/>
<div className="flex gap-2">
<button onClick={() => value && onSubmit(value)} className="btn-primary flex-1">OK</button>
<button onClick={onCancel} className="btn-secondary flex-1">{t("btn.cancel")}</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,3 @@
export function DisplaySettingsCard() {
return null;
}
@@ -0,0 +1,377 @@
import { useState, useEffect, useRef } from "react";
import type { Proxy } from "@/api/types";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { IconUpload, IconGlobe } from "@/components/shared/Icons";
import { useT } from "@/lib/i18n";
type ProxyFormat =
| "login:pass@ip:port"
| "login:pass:ip:port"
| "ip:port@login:pass"
| "ip:port:login:pass"
| "custom";
interface ParsedProxy {
address: string;
protocol: string;
}
const BUILTIN_FORMATS: { value: ProxyFormat; label: string }[] = [
{ value: "login:pass@ip:port", label: "login:pass@ip:port" },
{ value: "login:pass:ip:port", label: "login:pass:ip:port" },
{ value: "ip:port@login:pass", label: "ip:port@login:pass" },
{ value: "ip:port:login:pass", label: "ip:port:login:pass" },
];
function parseProxyLine(raw: string, format: ProxyFormat, defaultProtocol: string, customParts?: string[]): ParsedProxy | null {
let line = raw.trim();
if (!line) return null;
// Strip protocol prefix if present, fall back to user-selected default
let protocol = defaultProtocol;
for (const proto of ["socks5://", "socks4://", "http://", "https://"]) {
if (line.toLowerCase().startsWith(proto)) {
protocol = proto.replace("://", "");
line = line.slice(proto.length);
break;
}
}
// If no auth separators detected, treat as plain ip:port
if (!line.includes("@") && line.split(":").length === 2) {
return { address: line, protocol };
}
let ip = "", port = "", login = "", pass = "";
if (format === "custom" && customParts && customParts.length >= 4) {
// Custom format: user defines order of [ip, port, login, pass] with separator between them
// customParts = [part0, sep0, part1, sep1, part2, sep2, part3]
// We rebuild a regex from it
const result = parseCustomFormat(line, customParts);
if (!result) return null;
({ ip, port, login, pass } = result);
} else if (format === "login:pass@ip:port") {
const atIdx = line.indexOf("@");
if (atIdx === -1) return null;
const auth = line.slice(0, atIdx);
const host = line.slice(atIdx + 1);
const authParts = auth.split(":");
const hostParts = host.split(":");
if (authParts.length < 2 || hostParts.length < 2) return null;
login = authParts[0]; pass = authParts.slice(1).join(":");
ip = hostParts[0]; port = hostParts[1];
} else if (format === "login:pass:ip:port") {
const parts = line.split(":");
if (parts.length < 4) return null;
login = parts[0]; pass = parts[1]; ip = parts[2]; port = parts[3];
} else if (format === "ip:port@login:pass") {
const atIdx = line.indexOf("@");
if (atIdx === -1) return null;
const host = line.slice(0, atIdx);
const auth = line.slice(atIdx + 1);
const hostParts = host.split(":");
const authParts = auth.split(":");
if (hostParts.length < 2 || authParts.length < 2) return null;
ip = hostParts[0]; port = hostParts[1];
login = authParts[0]; pass = authParts.slice(1).join(":");
} else if (format === "ip:port:login:pass") {
const parts = line.split(":");
if (parts.length < 4) return null;
ip = parts[0]; port = parts[1]; login = parts[2]; pass = parts[3];
}
if (!ip || !port) return null;
const address = login && pass ? `${login}:${pass}@${ip}:${port}` : `${ip}:${port}`;
return { address, protocol };
}
function parseCustomFormat(line: string, customParts: string[]): { ip: string; port: string; login: string; pass: string } | null {
// customParts = [field, sep, field, sep, field, sep, field]
// fields: "ip" | "port" | "login" | "pass"
// seps: any string like ":" or "@"
const fields: string[] = [];
const seps: string[] = [];
for (let i = 0; i < customParts.length; i++) {
if (i % 2 === 0) fields.push(customParts[i]);
else seps.push(customParts[i]);
}
if (fields.length !== 4 || seps.length !== 3) return null;
// Split line by separators sequentially
let remaining = line;
const values: string[] = [];
for (let i = 0; i < seps.length; i++) {
const idx = remaining.indexOf(seps[i]);
if (idx === -1) return null;
values.push(remaining.slice(0, idx));
remaining = remaining.slice(idx + seps[i].length);
}
values.push(remaining);
const result: Record<string, string> = {};
for (let i = 0; i < fields.length; i++) {
result[fields[i]] = values[i];
}
return { ip: result.ip || "", port: result.port || "", login: result.login || "", pass: result.pass || "" };
}
const CUSTOM_FIELDS = ["ip", "port", "login", "pass"];
const CUSTOM_SEPS = [":", "@", ";", "|", "-"];
export function ProxyManager() {
const [proxies, setProxies] = useState<Proxy[]>([]);
const [text, setText] = useState("");
const [checking, setChecking] = useState(false);
const [checkResult, setCheckResult] = useState<string>("");
const [format, setFormat] = useState<ProxyFormat>("login:pass@ip:port");
const [protocol, setProtocol] = useState<string>("http");
const [showCustom, setShowCustom] = useState(false);
const [customParts, setCustomParts] = useState<string[]>(["ip", ":", "port", "@", "login", ":", "pass"]);
const [dragging, setDragging] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const addToast = useUiStore((s) => s.addToast);
const t = useT();
const load = async () => {
try {
setProxies(await api.getProxies());
} catch { /* ignore */ }
};
useEffect(() => { load(); }, []);
const parseAndAdd = async (rawText: string) => {
const lines = rawText.split("\n").filter((l) => l.trim());
if (!lines.length) return;
const activeFormat = showCustom ? "custom" : format;
const parsed: ParsedProxy[] = [];
for (const line of lines) {
const p = parseProxyLine(line, activeFormat, protocol, showCustom ? customParts : undefined);
if (p) parsed.push(p);
}
if (!parsed.length) {
addToast("error", t("toast.proxyFormatError"));
return;
}
try {
const result = await api.bulkAddProxies(parsed);
addToast("success", t("toast.proxiesAdded", { count: result.added }));
setText("");
await load();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const addProxies = () => parseAndAdd(text);
const handleDrop = async (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
const file = e.dataTransfer.files[0];
if (!file) return;
const content = await file.text();
await parseAndAdd(content);
};
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const content = await file.text();
await parseAndAdd(content);
e.target.value = "";
};
const deleteProxy = async (id: number) => {
try {
await api.deleteProxy(id);
await load();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const deleteAll = async () => {
if (!proxies.length) return;
if (!window.confirm(t("proxy.confirmDeleteAll"))) return;
try {
const r = await api.deleteAllProxies();
addToast("success", t("toast.proxiesDeleted", { count: r.deleted }));
setCheckResult("");
await load();
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
const checkAll = async () => {
setChecking(true);
setCheckResult("");
try {
const r = await api.checkProxies();
const msg = t("proxy.checkResult", { total: r.total, alive: r.alive, dead: r.dead });
setCheckResult(msg);
await load();
} catch (e: unknown) {
addToast("error", t("misc.proxyErrorCheck", { error: e instanceof Error ? e.message : String(e) }));
} finally {
setChecking(false);
}
};
const updateCustomPart = (idx: number, value: string) => {
const next = [...customParts];
next[idx] = value;
setCustomParts(next);
};
const customPreview = customParts.join("");
return (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4">
<h3 className="font-semibold mb-3">
<IconGlobe size={14} className="inline mr-1" />{t("proxy.title")} <span className="text-xs text-gray-500 ml-2">{t("proxy.count", { count: proxies.length })}</span>
</h3>
{/* Format selector */}
<div className="mb-3 space-y-2">
<div className="flex items-center gap-3 flex-wrap">
<span className="text-xs text-gray-400">{t("proxy.protocol")}</span>
<select
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-xs"
>
<option value="http">HTTP</option>
<option value="socks5">SOCKS5</option>
</select>
<span className="text-xs text-gray-400">{t("proxy.format")}</span>
<select
value={showCustom ? "__custom__" : format}
onChange={(e) => {
if (e.target.value === "__custom__") {
setShowCustom(true);
} else {
setShowCustom(false);
setFormat(e.target.value as ProxyFormat);
}
}}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-xs"
>
{BUILTIN_FORMATS.map((f) => (
<option key={f.value} value={f.value}>{f.label}</option>
))}
<option value="__custom__">{t("proxy.customFormat")}</option>
</select>
</div>
{/* Custom format builder */}
{showCustom && (
<div className="bg-dark-700 border border-dark-600 rounded p-3 space-y-2">
<p className="text-xs text-gray-400">{t("proxy.formatConstructor")}</p>
<div className="flex items-center gap-1 flex-wrap">
{customParts.map((part, i) =>
i % 2 === 0 ? (
<select
key={i}
value={part}
onChange={(e) => updateCustomPart(i, e.target.value)}
className="bg-dark-600 border border-dark-500 rounded px-2 py-1 text-xs text-accent"
>
{CUSTOM_FIELDS.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
) : (
<select
key={i}
value={part}
onChange={(e) => updateCustomPart(i, e.target.value)}
className="bg-dark-600 border border-dark-500 rounded px-1 py-1 text-xs text-yellow-400 w-10 text-center"
>
{CUSTOM_SEPS.map((s) => (
<option key={s} value={s}>{s}</option>
))}
</select>
)
)}
</div>
<p className="text-xs text-gray-500">{t("proxy.preview")} <span className="text-gray-300">{customPreview}</span></p>
</div>
)}
</div>
{/* Drop zone */}
<div
onDragOver={(e) => { e.preventDefault(); setDragging(true); }}
onDragLeave={() => setDragging(false)}
onDrop={handleDrop}
onClick={() => inputRef.current?.click()}
className={`border-2 border-dashed rounded-lg p-4 mb-2 text-center cursor-pointer transition-colors ${
dragging ? "border-accent bg-accent/10" : "border-dark-500 hover:border-accent"
}`}
>
<IconUpload size={24} className="mx-auto mb-1 text-gray-500" />
<p className="text-xs text-gray-400">{t("proxy.dragFile")}</p>
</div>
<input
ref={inputRef}
type="file"
accept=".txt"
className="hidden"
onChange={handleFileSelect}
/>
{/* Textarea */}
<textarea
value={text}
onChange={(e) => setText(e.target.value)}
rows={4}
placeholder={t("proxy.perLine", { format: showCustom ? customPreview : format })}
className="w-full bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm mb-2 resize-y"
/>
<div className="flex gap-2 mb-3 flex-wrap">
<button onClick={addProxies} className="btn-primary">{t("proxy.addBtn")}</button>
<button onClick={checkAll} disabled={checking} className="btn-secondary">
{checking ? t("proxy.checking") : t("proxy.checkAll")}
</button>
{proxies.length > 0 && (
<button onClick={deleteAll} className="btn-danger text-xs">{t("proxy.deleteAllBtn")}</button>
)}
</div>
{checkResult && <p className="text-xs text-gray-300 mb-3">{checkResult}</p>}
{proxies.length > 0 && (() => {
const alive = proxies.filter(p => p.is_alive).length;
const dead = proxies.length - alive;
return (
<p className="text-xs text-gray-400 mb-3">
<span className="text-green-400"> {alive}</span>
<span className="mx-2">/</span>
<span className="text-red-400"> {dead}</span>
<span className="mx-2">/</span>
<span>{proxies.length}</span>
</p>
);
})()}
<div className="max-h-48 overflow-y-auto space-y-1">
{proxies.map((p) => (
<div key={p.id} className="flex items-center justify-between text-xs gap-2 group px-2 py-1 rounded hover:bg-dark-700">
<span className={`${p.is_alive ? "text-green-400" : "text-red-400"} min-w-0 truncate`}>
{p.protocol}://{p.address}
</span>
<button
onClick={() => deleteProxy(p.id)}
className="text-red-400 hover:bg-red-400/20 rounded px-2 py-0.5 text-sm font-medium shrink-0 transition"
title={t("proxy.deleteProxy")}
>
</button>
</div>
))}
</div>
</div>
);
}
@@ -0,0 +1,15 @@
import { TwoFAGenerator } from "./TwoFAGenerator";
import { ProxyManager } from "./ProxyManager";
import { ValidationSettings } from "./ValidationSettings";
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 />
</div>
<ProxyManager />
</div>
);
}
@@ -0,0 +1,138 @@
import { useState, useEffect, useRef } from "react";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useAccountStore } from "@/stores/accountStore";
import { copyText } from "@/lib/clipboard";
import { useT } from "@/lib/i18n";
import { IconKey } from "@/components/shared/Icons";
import type { Account } from "@/api/types";
type Mode = "secret" | "account";
export function TwoFAGenerator() {
const t = useT();
const [mode, setMode] = useState<Mode>("secret");
const [secret, setSecret] = useState("");
const [code, setCode] = useState("");
const [search, setSearch] = useState("");
const [selected, setSelected] = useState<Account | null>(null);
const [open, setOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
const addToast = useUiStore((s) => s.addToast);
const accounts = useAccountStore((s) => s.accounts);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
useEffect(() => {
if (mode === "account" && accounts.length === 0) loadAccounts();
}, [mode]);
useEffect(() => {
const onDown = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener("mousedown", onDown);
return () => document.removeEventListener("mousedown", onDown);
}, []);
const filtered = accounts
.filter((a) => {
const q = search.toLowerCase();
return !q || a.login.toLowerCase().includes(q) || (a.steam_id ?? "").includes(q);
})
.slice(0, 12);
const generate = async () => {
try {
if (mode === "secret") {
if (!secret.trim()) return;
const data = await api.generate2FA(secret.trim());
setCode(data.code);
} else {
if (!selected) return;
const data = await api.generate2FAByAccount(selected.id);
setCode(data.code);
}
} catch (e: unknown) {
setCode(t("tools.genErrorStr"));
addToast("error", t("misc.genError", { error: e instanceof Error ? e.message : String(e) }));
}
};
const handleCopy = async () => {
if (!code || code === t("tools.genErrorStr")) return;
const ok = await copyText(code);
addToast(ok ? "success" : "error", ok ? t("toast.copied") : t("toast.copyFailed"));
};
const tabCls = (m: Mode) =>
`flex-1 py-1.5 text-xs font-medium rounded transition-colors ${
mode === m ? "bg-dark-600 text-gray-100" : "text-gray-500 hover:text-gray-300"
}`;
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" />
<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>
{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>
)}
</div>
</div>
);
}
@@ -0,0 +1,149 @@
import { useState, useEffect, useRef } from "react";
import type { ValidationSettings as VS } from "@/api/types";
import { api } from "@/api/client";
import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n";
import { IconSettings } from "@/components/shared/Icons";
function Toggle({ checked, onChange }: { checked: boolean; onChange: (v: boolean) => void }) {
return (
<label className="toggle-switch">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)} />
<span className="toggle-track" />
</label>
);
}
function SettingRow({ label, desc, checked, onChange }: {
label: string;
desc?: string;
checked: boolean;
onChange: (v: boolean) => void;
}) {
return (
<div className="flex items-center justify-between gap-4 py-2.5">
<div className="min-w-0">
<p className="text-sm text-gray-200 leading-tight">{label}</p>
{desc && <p className="text-xs text-gray-500 mt-0.5">{desc}</p>}
</div>
<Toggle checked={checked} onChange={onChange} />
</div>
);
}
function ThreadStepper({ value, onChange }: { value: number; onChange: (v: number) => void }) {
const [editing, setEditing] = useState(false);
const [input, setInput] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const clamp = (n: number) => Math.max(1, n);
const commit = () => {
onChange(clamp(parseInt(input) || value));
setEditing(false);
};
const btnCls = "h-6 px-1.5 rounded bg-dark-700 border border-dark-600 text-gray-300 hover:bg-dark-600 text-xs flex items-center justify-center select-none leading-none";
return (
<div className="flex items-center gap-3 pt-3 mt-0.5">
<span className="text-sm text-gray-400 flex-1" title="Двойной клик на числе для ручного ввода">Max threads</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>
{editing ? (
<input
ref={inputRef}
autoFocus
value={input}
onChange={(e) => setInput(e.target.value)}
onBlur={commit}
onKeyDown={(e) => { if (e.key === "Enter") commit(); if (e.key === "Escape") setEditing(false); }}
className="w-10 text-center text-sm font-mono bg-dark-900 border border-accent rounded outline-none text-gray-100 py-0.5"
/>
) : (
<span
className="w-10 text-center text-sm font-mono text-gray-100 cursor-pointer select-none"
onDoubleClick={() => { setInput(String(value)); setEditing(true); }}
title="Двойной клик для ручного ввода"
>{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>
</div>
</div>
);
}
export function ValidationSettings() {
const t = useT();
const [settings, setSettings] = useState<VS>({
fetch_profile: true,
check_ban: true,
max_threads: 5,
auto_revalidate_browser: true,
});
const hidePasswords = useUiStore((s) => s.hidePasswords);
const setHidePasswords = useUiStore((s) => s.setHidePasswords);
const addToast = useUiStore((s) => s.addToast);
useEffect(() => {
api.getValidationSettings().then(setSettings).catch(() => {});
}, []);
const save = async () => {
try {
await api.updateValidationSettings(settings);
addToast("success", t("toast.settingsSaved"));
} catch (e: unknown) {
addToast("error", t("misc.error", { error: e instanceof Error ? e.message : String(e) }));
}
};
return (
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden flex flex-col">
<div className="px-5 py-3.5 border-b border-dark-600 flex items-center gap-2.5">
<IconSettings size={15} className="text-accent shrink-0" />
<span className="text-sm font-semibold text-gray-100">Settings</span>
</div>
<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("tools.validationSettings")}
</p>
<div className="divide-y divide-dark-600/60">
<SettingRow
label={t("tools.loadProfile")}
checked={settings.fetch_profile}
onChange={(v) => setSettings({ ...settings, fetch_profile: v })}
/>
<SettingRow
label={t("tools.checkBans")}
checked={settings.check_ban}
onChange={(v) => setSettings({ ...settings, check_ban: v })}
/>
<SettingRow
label={t("tools.autoRevalidateBrowser")}
checked={settings.auto_revalidate_browser}
onChange={(v) => setSettings({ ...settings, auto_revalidate_browser: v })}
/>
</div>
<ThreadStepper value={settings.max_threads} onChange={(v) => setSettings((s) => ({ ...s, max_threads: v }))} />
</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("settings.display")}
</p>
<SettingRow
label={t("settings.hidePasswords")}
checked={hidePasswords}
onChange={setHidePasswords}
/>
</div>
<div className="px-5 py-3 border-t border-dark-600 flex justify-end">
<button onClick={save} className="btn-primary">{t("btn.save")}</button>
</div>
</div>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { useEffect, useRef } from "react";
export function usePolling(
callback: () => void | Promise<void>,
intervalMs: number,
enabled = true,
) {
const cbRef = useRef(callback);
cbRef.current = callback;
useEffect(() => {
if (!enabled) return;
cbRef.current();
const id = setInterval(() => cbRef.current(), intervalMs);
return () => clearInterval(id);
}, [intervalMs, enabled]);
}
+26
View File
@@ -0,0 +1,26 @@
import { useEffect, useRef } from "react";
export function useSSE<T>(
url: string,
onMessage: (data: T) => void,
enabled = true,
) {
const cbRef = useRef(onMessage);
cbRef.current = onMessage;
useEffect(() => {
if (!enabled) return;
const es = new EventSource(url);
es.onmessage = (e) => {
try {
cbRef.current(JSON.parse(e.data));
} catch { /* ignore parse errors */ }
};
es.onerror = () => {
es.close();
};
return () => es.close();
}, [url, enabled]);
}
+262
View File
@@ -0,0 +1,262 @@
@import "tailwindcss";
@theme {
--color-dark-900: #0f0f0f;
--color-dark-800: #1a1a1a;
--color-dark-700: #252525;
--color-dark-600: #333333;
--color-accent: #4f8cff;
--color-accent-hover: #6ba0ff;
--color-accent-dim: #3a6fd8;
}
.avatar-sm {
width: 24px;
height: 24px;
border-radius: 3px;
object-fit: cover;
flex-shrink: 0;
}
/* Steam Level Badge */
.steam-lvl {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
border-radius: 10px;
border: solid #9b9b9b 1.5px;
height: 18px;
width: 18px;
color: #e5e5e5;
flex-shrink: 0;
font-weight: 600;
}
.steam-lvl.l10 { border-color: #c02942; }
.steam-lvl.l20 { border-color: #d95b43; }
.steam-lvl.l30 { border-color: #fecc23; }
.steam-lvl.l40 { border-color: #467a3c; }
.steam-lvl.l50 { border-color: #4e8ddb; }
.steam-lvl.l60 { border-color: #7652c9; }
.steam-lvl.l70 { border-color: #c252c9; }
.steam-lvl.l80 { border-color: #542437; }
.steam-lvl.l90 { border-color: #997c52; }
.steam-lvl.l100p {
border: none;
border-radius: 0;
background-repeat: no-repeat;
background-position: 0 0;
background-size: contain;
font-size: 9px;
height: 20px;
width: 20px;
text-shadow: 1px 1px #1a1a1a;
}
.steam-lvl.l100p.img-100 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_hexagons.png'); }
.steam-lvl.l100p.img-200 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_shields.png'); }
.steam-lvl.l100p.img-300 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_books.png'); }
.steam-lvl.l100p.img-400 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_chevrons.png'); }
.steam-lvl.l100p.img-500 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_circle2.png'); }
.steam-lvl.l100p.img-600 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_angle.png'); }
.steam-lvl.l100p.img-700 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_flag.png'); }
.steam-lvl.l100p.img-800 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_wings.png'); }
.steam-lvl.l100p.img-900 { background-image: url('https://community.fastly.steamstatic.com/public/shared/images/community/levels_arrows.png'); }
.btn-primary {
@apply bg-accent hover:bg-accent-hover text-white font-medium py-2 px-4 rounded text-sm transition-colors cursor-pointer;
}
.btn-secondary {
@apply bg-dark-700 hover:bg-dark-600 text-gray-300 font-medium py-2 px-4 rounded text-sm transition-colors border border-dark-600 cursor-pointer;
}
.btn-accent {
@apply bg-accent-dim hover:bg-accent text-white font-medium py-2 px-4 rounded text-sm transition-colors cursor-pointer;
}
.btn-danger {
@apply bg-red-600/20 hover:bg-red-600/30 text-red-400 font-medium py-2 px-4 rounded text-sm transition-colors border border-red-600/30 cursor-pointer;
}
.btn-danger-outline {
@apply bg-transparent hover:bg-red-600/10 text-red-400 font-medium py-2 px-4 rounded text-sm transition-colors border border-red-600/30 cursor-pointer;
}
.badge {
@apply inline-block px-2 py-0.5 rounded text-xs font-medium;
}
.badge-ok { @apply bg-green-500/20 text-green-400; }
.badge-error { @apply bg-red-500/20 text-red-400; }
.badge-running { @apply bg-blue-500/20 text-blue-400; }
.badge-unknown { @apply bg-gray-500/20 text-gray-500; }
.progress-bar {
@apply w-full h-1.5 bg-dark-600 rounded-full overflow-hidden;
}
.progress-fill {
@apply h-full bg-accent rounded-full transition-all duration-300;
}
.cell-copy {
@apply cursor-pointer hover:text-gray-200 transition-colors select-all;
}
.copy-tooltip {
position: fixed;
transform: translate(-50%, -100%);
background: #333;
color: #fff;
padding: 2px 8px;
border-radius: 4px;
font-size: 11px;
pointer-events: none;
z-index: 9999;
transition: opacity 0.2s;
}
[data-tooltip] {
position: relative;
}
button[disabled][data-tooltip] {
pointer-events: auto;
}
[data-tooltip]::after {
content: attr(data-tooltip);
position: absolute;
left: 50%;
bottom: calc(100% + 6px);
transform: translateX(-50%);
background: #0f0f1a;
border: 1px solid #6b6b8a;
color: #f0f0f8;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
padding: 5px 10px;
border-radius: 5px;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s;
z-index: 9999;
box-shadow: 0 4px 12px rgba(0,0,0,0.6);
}
[data-tooltip]:hover::after {
opacity: 1;
}
.popup-2fa {
position: fixed;
z-index: 100;
background: var(--color-dark-700);
border: 1px solid var(--color-dark-600);
border-radius: 8px;
padding: 8px 14px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
}
.popup-2fa-code {
font-size: 1.5rem;
font-family: monospace;
color: var(--color-accent);
cursor: pointer;
user-select: all;
}
.action-menu-dropdown {
@apply bg-dark-700 border border-dark-600 rounded-lg shadow-xl py-1 z-50 min-w-[180px];
}
.action-menu-item {
@apply block w-full text-left px-3 py-1.5 text-sm text-gray-300 hover:bg-dark-600 hover:text-white transition-colors cursor-pointer;
}
.confirm-overlay {
@apply fixed inset-0 bg-black/60 flex items-center justify-center z-50;
}
.confirm-box {
@apply bg-dark-800 border border-dark-600 rounded-lg p-6 w-full max-w-md shadow-xl;
overflow-wrap: break-word;
word-break: break-word;
}
.toast-container {
position: fixed;
bottom: 1rem;
left: 1rem;
display: flex;
flex-direction: column-reverse;
gap: 0.5rem;
z-index: 9999;
pointer-events: none;
}
.toast {
pointer-events: auto;
padding: 0.5rem 1rem;
border-radius: 0.375rem;
font-size: 0.8rem;
color: #fff;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
animation: toastIn 0.25s ease-out;
transition: opacity 0.3s;
}
.toast-info { background: #2563eb; }
.toast-success { background: #16a34a; }
.toast-error { background: #dc2626; }
.toast-warn { background: #d97706; }
@keyframes toastIn {
from { opacity: 0; transform: translateX(-30px); }
to { opacity: 1; transform: translateX(0); }
}
@keyframes shimmer {
0% { stroke-dashoffset: 80; }
100% { stroke-dashoffset: -80; }
}
.icon-shimmer {
stroke: rgba(255, 255, 255, 0.45);
stroke-dasharray: 8 72;
animation: shimmer 2.5s linear infinite;
}
.toggle-switch {
position: relative;
display: inline-flex;
align-items: center;
width: 38px;
height: 22px;
cursor: pointer;
flex-shrink: 0;
}
.toggle-switch input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.toggle-track {
position: absolute;
inset: 0;
background: #2a2a2a;
border: 1px solid #444;
border-radius: 999px;
transition: background 0.2s, border-color 0.2s;
}
.toggle-track::before {
content: "";
position: absolute;
width: 16px;
height: 16px;
border-radius: 50%;
background: #666;
top: 2px;
left: 2px;
transition: transform 0.2s, background 0.2s;
}
.toggle-switch input:checked ~ .toggle-track {
background: #4f8cff;
border-color: #4f8cff;
}
.toggle-switch input:checked ~ .toggle-track::before {
transform: translateX(16px);
background: #fff;
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: var(--color-dark-900); }
::-webkit-scrollbar-thumb { background: var(--color-dark-600); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: #555; }
+8
View File
@@ -0,0 +1,8 @@
export async function copyText(text: string): Promise<boolean> {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
+11
View File
@@ -0,0 +1,11 @@
const entityMap: Record<string, string> = {
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
};
export function escapeHtml(str: string): string {
return str.replace(/[&<>"']/g, (s) => entityMap[s] ?? s);
}
+598
View File
@@ -0,0 +1,598 @@
import { useSyncExternalStore } from "react";
export type Locale = "ru" | "en";
const STORAGE_KEY = "steampanel_locale";
let _locale: Locale = (localStorage.getItem(STORAGE_KEY) as Locale) || "ru";
const _listeners = new Set<() => void>();
function notify() { _listeners.forEach((l) => l()); }
export function getLocale(): Locale { return _locale; }
export function setLocale(l: Locale) {
_locale = l;
localStorage.setItem(STORAGE_KEY, l);
notify();
}
export function useLocale(): [Locale, (l: Locale) => void] {
const locale = useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
() => _locale,
);
return [locale, setLocale];
}
type Dict = Record<string, string>;
type Translations = { ru: Dict; en: Dict };
const translations: Translations = {
ru: {
// -- Layout --
"header.accounts": "Аккаунтов",
// -- Tabs --
"tab.accounts": "Аккаунты",
"tab.mafiles": "Mafile Management",
"tab.tools": "Настройки",
"tab.logs": "Логи",
// -- Section labels --
"section.dataType": "Тип данных",
// -- Column headers (shared) --
"col.browser": "Войти",
"col.profile": "Профиль",
"col.lastOnline": "Отлега",
"col.steamId": "Steam ID",
"col.login": "Логин",
"col.password": "Пароль",
"col.loginPass": "Login:Pass",
"col.email": "Email",
"col.emailPass": "Email Pass",
"col.emailLoginPass": "Email:Pass",
"col.phone": "Телефон",
"col.status": "Статус",
"col.ban": "Бан",
"col.twofa": "2FA",
"col.mafile": "Mafile",
"col.proxy": "Прокси",
"col.notes": "Заметки",
"col.actions": "Действия",
"col.prime": "Prime",
"col.trophy": "Trophy",
"col.behavior": "Behavior",
"col.license": "Лицензии",
"col.token": "Токен",
// -- Actions --
"action.selectAction": "— Действие",
"action.validate": "Валидация",
"action.changePassword": "Сменить пароль",
"action.randomPassword": "Случайный пароль",
"action.changeEmail": "Сменить email",
"action.changePhone": "Сменить телефон",
"action.removePhone": "Удалить телефон",
"action.removeGuard": "Удалить Guard",
"action.noRevocationCode": "Нет revocation_code в mafile",
"action.enableAutoAccept": "Вкл. автопринятие",
"action.disableAutoAccept": "Выкл. авто-принятие входа",
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
// -- Action prompts --
"prompt.newPassword": "Введите новый пароль",
"prompt.newEmail": "Введите новый email",
"prompt.newPhone": "Введите новый номер телефона",
"prompt.removePhone": "Введите номер телефона (на который переставится старый)",
"prompt.enterValue": "Введите значение...",
// -- Confirm dialogs --
"confirm.removeGuard": "Удалить Steam Guard для этого аккаунта?",
"confirm.deleteAccount": "Удалить аккаунт?",
"confirm.deleteSelected": "Удалить {count} выбранных аккаунтов?",
"confirm.deleteAll": "Удалить ВСЕ {count} аккаунтов? Это необратимо!",
"confirm.deleteLogpassSelected": "Удалить {count} аккаунт(ов)?",
"confirm.deleteLogpassAll": "Удалить все {count} аккаунтов?",
"confirm.deleteTokenSelected": "Удалить {count} токен(ов)?",
"confirm.deleteTokenAll": "Удалить все {count} токенов?",
"confirm.deleteToken": "Удалить токен {name}?",
"confirm.deleteLogpass": "Удалить {name}?",
"confirm.assignProxies": "Назначить прокси аккаунтам без прокси?",
"confirm.reassignProxies": "Переназначить прокси на все аккаунты?",
"confirm.reassignProxiesAll": "Переназначить прокси всем аккаунтам?",
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
"confirm.yes": "Да",
// -- Buttons --
"btn.import": "Импорт",
"btn.add": "Добавить",
"btn.save": "Сохранить",
"btn.cancel": "Отмена",
"btn.execute": "Выполнить",
"btn.deleteSelected": "Удалить выбранные",
"btn.deleteAll": "Удалить все",
"btn.assignProxies": "Назначить прокси",
"btn.reassign": "Переназначить",
"btn.clearProxies": "Очистить прокси",
"btn.validate": "Валидировать",
"btn.fullParse": "Полный парс",
"btn.generate": "Сгенерировать",
// -- Toast messages --
"toast.copied": "Скопировано",
"toast.copyFailed": "Не удалось скопировать",
"toast.noteSaved": "Заметка сохранена",
"toast.noteSaveError": "Ошибка сохранения",
"toast.accountDeleted": "Аккаунт удален",
"toast.accountAdded": "Аккаунт добавлен",
"toast.saved": "Сохранено",
"toast.done": "Готово",
"toast.error": "Ошибка",
"toast.selectAction": "Выберите действие",
"toast.selectAccounts": "Выберите аккаунты",
"toast.noAccounts": "Нет аккаунтов",
"toast.autoAcceptEnabled": "Автопринятие включено для {count} акк.",
"toast.autoAcceptOn": "Авто-принятие входа включено",
"toast.autoAcceptOff": "Авто-принятие входа выключено",
"toast.taskCancelled": "Задача отменена",
"toast.browserOpening": "Браузер открывается...",
"toast.cookiesExpiredRevalidating": "Куки устарели. Запущена повторная валидация...",
"toast.cookiesExpired": "Куки устарели. Провалидируйте аккаунт заново.",
"toast.proxyCleared": "Прокси убран у {name}",
"toast.proxiesReassigned": "Переназначено {used} прокси на {count} акк.",
"toast.proxiesAssigned": "Назначено {used} прокси на {count} аккаунтов",
"toast.proxiesAssignedShort": "Назначено {count} аккаунтам",
"toast.proxiesReassignedShort": "Переназначено {count} аккаунтам",
"toast.proxiesClearedCount": "Очищено прокси у {count} аккаунтов",
"toast.proxiesClearedShort": "Очищено у {count} аккаунтов",
"toast.deleted": "Удалено: {count}",
"toast.sentToMafile": "{count} акк. отправлено в Mafile Management",
"toast.settingsSaved": "Настройки сохранены",
"toast.proxyFormatError": "Не удалось распознать прокси. Проверьте формат.",
"toast.proxiesAdded": "Добавлено {count} прокси",
"toast.proxiesDeleted": "Удалено {count} прокси",
"toast.exportDone": "Экспорт завершён",
"toast.uploaded": "Загружено: {count}",
"toast.uploadedWithErrors": "Загружено: {uploaded}, ошибок: {errors}",
"toast.importResult": "Импортировано: {imported}, Пропущено: {skipped}",
"toast.fileEmpty": "Файл пуст",
// -- Task types --
"task.validate": "Валидация",
"task.changePassword": "Смена пароля",
"task.randomPassword": "Случ. пароль",
"task.changeEmail": "Смена email",
"task.changePhone": "Смена телефона",
"task.removePhone": "Удал. телефона",
"task.removeGuard": "Удал. Guard",
// -- Badges / statuses --
"status.unknown": "Неизвестен",
"status.valid": "Валидный",
"status.invalid": "Невалидный",
"status.locked": "Заблокирован",
"status.limited": "Ограничен",
"status.banned": "БАН",
"status.ok": "ОК",
// -- Tooltips --
"tip.openBrowser": "Открыть Steam в браузере",
"tip.copyPassword": "Копировать пароль",
"tip.copyLoginPass": "Копировать login:pass",
"tip.copyEmailPass": "Копировать пароль email",
"tip.copyEmailLoginPass": "Копировать email:pass",
"tip.show": "Показать",
"tip.hide": "Скрыть",
"tip.gen2fa": "Сгенерировать и скопировать 2FA код",
"tip.code": "Код",
"tip.addNote": "Нажмите чтобы добавить заметку",
"tip.edit": "Редактировать",
"tip.delete": "Удалить",
"tip.autoAcceptActive": "Авто-принятие входа",
"tip.validate": "Валидировать",
"tip.columnSettings": "Настройки столбцов",
"tip.deleteProxy": "Удалить прокси",
"tip.clickToCopy": "Клик для копирования",
"tip.proxyError": "Ошибка прокси: {error}",
// -- Placeholders --
"ph.search": "Поиск: логин / Steam ID / заметка / lvl>10",
"ph.searchLogpass": "Поиск: логин / Steam ID / заметка / игра / lvl>10",
"ph.login": "Логин",
"ph.password": "Пароль",
"ph.proxy": "Прокси",
"ph.proxyOptional": "Прокси (необязательно)",
"ph.notes": "Заметки",
"ph.notesOptional": "Заметки (необязательно)",
"ph.loginOptional": "Логин (необязательно)",
// -- Empty states --
"empty.accounts": "Нет аккаунтов. Импортируйте файл",
"empty.logpass": "Аккаунтов нет. Импортируйте файл.",
"empty.tokens": "Токенов нет. Импортируйте или добавьте токены.",
"empty.logs": "Нет логов",
"empty.tasks": "Нет задач",
// -- Modals --
"modal.addAccount": "Добавить аккаунт",
"modal.editAccount": "Редактировать аккаунт",
"modal.importAccounts": "Импорт аккаунтов",
"modal.addToken": "Добавить токен",
"modal.importTokens": "Импорт токенов",
// -- Import --
"import.formats": "Форматы",
"import.formatsColon": "Форматы:",
"import.delimiter": "разделитель",
"import.or": "или",
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
"import.selected": "Выбран: {name}",
"import.uploading": "Загрузка...",
"import.importBtn": "Импортировать",
"import.tokenFormat": "refresh_token (по одному на строку)",
// -- Pagination --
"paging.shown": "Показано {visible} из {total}…",
// -- Proxy labels --
"proxy.label": "Прокси {num}",
"proxy.title": "Прокси",
"proxy.count": "{count} шт.",
"proxy.protocol": "Протокол:",
"proxy.format": "Формат:",
"proxy.customFormat": "Свой формат...",
"proxy.formatConstructor": "Конструктор формата:",
"proxy.preview": "Предпросмотр:",
"proxy.dragFile": "Перетащите .txt файл с прокси или нажмите для выбора",
"proxy.addBtn": "Добавить",
"proxy.checking": "Проверка...",
"proxy.checkAll": "Проверить все",
"proxy.deleteAllBtn": "Удалить все прокси",
"proxy.confirmDeleteAll": "Удалить все прокси? Это действие нельзя отменить.",
"proxy.checkResult": "Всего: {total} | Живых: {alive} | Мёртвых: {dead}",
"proxy.removeProxy": "Убрать прокси",
"proxy.reassignProxy": "Переназначить прокси",
"proxy.perLine": "Прокси по одному на строку ({format})",
"proxy.deleteProxy": "Удалить прокси",
// -- Tools --
"tools.2faGenerator": "Генератор 2FA кода",
"tools.validationSettings": "Настройки валидации",
"tools.loadProfile": "Загружать профиль (никнейм, аватар)",
"tools.checkBans": "Проверять баны",
"tools.maxThreads": "Макс. потоков:",
"tools.autoRevalidateBrowser": "Автовалидация при мёртвых куках (браузер)",
"tools.clickCopy": "Клик для копирования",
"tools.genErrorStr": "Ошибка",
// -- Logs panel --
"logs.title": "Лог",
"logs.clear": "Очистить",
"logs.activeTasks": "Активные задачи",
// -- Mafile tab --
"mafile.upload": "Загрузка Mafile",
"mafile.exportSettings": "Настройки экспорта",
"mafile.exportFormat": "Формат:",
"mafile.flatFiles": "Плоские файлы (.mafile)",
"mafile.perAccountFolder": "Папка на аккаунт",
"mafile.singleFile": "Одним файлом",
"mafile.variables": "Переменные:",
"mafile.folderName": "Название папки:",
"mafile.mafileName": "Название .mafile:",
"mafile.txtSettings": "Настройки .txt",
"mafile.addGlobalTxt": "Добавить общий",
"mafile.withAllAccounts": "со всеми аккаунтами",
"mafile.addTxtPerFolder": "Добавить .txt в каждую папку",
"mafile.skipFolders": "Не создавать папку для каждого аккаунта",
"mafile.txtFolderName": "Название .txt в папке:",
"mafile.txtLineFormat": "Формат строки .txt:",
"mafile.mafileFields": "Поля Mafile",
"mafile.sessionFields": "Поля Session",
"mafile.export": "Экспорт",
"mafile.noAccounts": "нет аккаунтов",
"mafile.sentAccounts": "Отправленные аккаунты",
"mafile.clearBtn": "Очистить",
"mafile.insert": "Вставить {value}",
// -- Settings drawer --
"settings.display": "Отображение",
"settings.hidePasswords": "Скрывать пароли (спойлер)",
// -- Token disclaimer --
"token.disclaimerTitle": "Вкладка в разработке",
"token.disclaimerBody1": "Функциональность вкладки Token <strong>не завершена</strong> и находится в стадии разработки.",
"token.disclaimerBody2": "⚠ Валидация токенов может привести к их инвалидации (убить токены). Используйте на свой страх и риск.",
"token.disclaimerBody3": "Импорт, редактирование и любые действия с токенами могут работать некорректно.",
"token.acceptRisk": "Я понимаю риски и хочу продолжить",
// -- 2FA --
"twofa.copied": "2FA: {code} (скопировано)",
"twofa.error": "Ошибка 2FA: {error}",
// -- Misc --
"misc.task": "Задача {id}",
"misc.taskAction": "Задача {id} - {action} ({login})",
"misc.taskCount": "Задача {id} ({count} акк.)",
"misc.error": "Ошибка: {error}",
"misc.proxyErrorCheck": "Ошибка проверки: {error}",
"misc.exportError": "Ошибка экспорта: {error}",
"misc.genError": "Ошибка генерации: {error}",
"misc.errorStr": "Ошибка",
"misc.insert": "Вставить",
},
en: {
// -- Layout --
"header.accounts": "Accounts",
// -- Tabs --
"tab.accounts": "Accounts",
"tab.mafiles": "Mafile Management",
"tab.tools": "Settings",
"tab.logs": "Logs",
// -- Section labels --
"section.dataType": "Data Type",
// -- Column headers --
"col.browser": "Login",
"col.profile": "Profile",
"col.lastOnline": "Last Online",
"col.steamId": "Steam ID",
"col.login": "Login",
"col.password": "Password",
"col.loginPass": "Login:Pass",
"col.email": "Email",
"col.emailPass": "Email Pass",
"col.emailLoginPass": "Email:Pass",
"col.phone": "Phone",
"col.status": "Status",
"col.ban": "Ban",
"col.twofa": "2FA",
"col.mafile": "Mafile",
"col.proxy": "Proxy",
"col.notes": "Notes",
"col.actions": "Actions",
"col.prime": "Prime",
"col.trophy": "Trophy",
"col.behavior": "Behavior",
"col.license": "Licenses",
"col.token": "Token",
// -- Actions --
"action.selectAction": "— Action",
"action.validate": "Validate",
"action.changePassword": "Change password",
"action.randomPassword": "Random password",
"action.changeEmail": "Change email",
"action.changePhone": "Change phone",
"action.removePhone": "Remove phone",
"action.removeGuard": "Remove Guard",
"action.noRevocationCode": "No revocation_code in mafile",
"action.enableAutoAccept": "Enable auto-accept",
"action.disableAutoAccept": "Disable login auto-accept",
"action.enableAutoAcceptLogin": "Enable login auto-accept",
// -- Action prompts --
"prompt.newPassword": "Enter new password",
"prompt.newEmail": "Enter new email",
"prompt.newPhone": "Enter new phone number",
"prompt.removePhone": "Enter phone number (old number will be transferred to it)",
"prompt.enterValue": "Enter value...",
// -- Confirm dialogs --
"confirm.removeGuard": "Remove Steam Guard for this account?",
"confirm.deleteAccount": "Delete account?",
"confirm.deleteSelected": "Delete {count} selected accounts?",
"confirm.deleteAll": "Delete ALL {count} accounts? This is irreversible!",
"confirm.deleteLogpassSelected": "Delete {count} account(s)?",
"confirm.deleteLogpassAll": "Delete all {count} accounts?",
"confirm.deleteTokenSelected": "Delete {count} token(s)?",
"confirm.deleteTokenAll": "Delete all {count} tokens?",
"confirm.deleteToken": "Delete token {name}?",
"confirm.deleteLogpass": "Delete {name}?",
"confirm.assignProxies": "Assign proxies to accounts without proxies?",
"confirm.reassignProxies": "Reassign proxies to all accounts?",
"confirm.reassignProxiesAll": "Reassign proxies to all accounts?",
"confirm.clearProxies": "Clear proxies from all accounts?",
"confirm.executeBulk": "Execute «{action}» for {count} accounts?",
"confirm.yes": "Yes",
// -- Buttons --
"btn.import": "Import",
"btn.add": "Add",
"btn.save": "Save",
"btn.cancel": "Cancel",
"btn.execute": "Execute",
"btn.deleteSelected": "Delete selected",
"btn.deleteAll": "Delete all",
"btn.assignProxies": "Assign proxies",
"btn.reassign": "Reassign",
"btn.clearProxies": "Clear proxies",
"btn.validate": "Validate",
"btn.fullParse": "Full parse",
"btn.generate": "Generate",
// -- Toast messages --
"toast.copied": "Copied",
"toast.copyFailed": "Failed to copy",
"toast.noteSaved": "Note saved",
"toast.noteSaveError": "Error saving",
"toast.accountDeleted": "Account deleted",
"toast.accountAdded": "Account added",
"toast.saved": "Saved",
"toast.done": "Done",
"toast.error": "Error",
"toast.selectAction": "Select an action",
"toast.selectAccounts": "Select accounts",
"toast.noAccounts": "No accounts",
"toast.autoAcceptEnabled": "Auto-accept enabled for {count} accounts",
"toast.autoAcceptOn": "Login auto-accept enabled",
"toast.autoAcceptOff": "Login auto-accept disabled",
"toast.taskCancelled": "Task cancelled",
"toast.browserOpening": "Opening browser...",
"toast.cookiesExpiredRevalidating": "Cookies expired. Re-validating...",
"toast.cookiesExpired": "Cookies expired. Re-validate the account.",
"toast.proxyCleared": "Proxy removed from {name}",
"toast.proxiesReassigned": "Reassigned {used} proxies to {count} accounts",
"toast.proxiesAssigned": "Assigned {used} proxies to {count} accounts",
"toast.proxiesAssignedShort": "Assigned to {count} accounts",
"toast.proxiesReassignedShort": "Reassigned to {count} accounts",
"toast.proxiesClearedCount": "Cleared proxies from {count} accounts",
"toast.proxiesClearedShort": "Cleared from {count} accounts",
"toast.deleted": "Deleted: {count}",
"toast.sentToMafile": "{count} accounts sent to Mafile Management",
"toast.settingsSaved": "Settings saved",
"toast.proxyFormatError": "Failed to parse proxies. Check the format.",
"toast.proxiesAdded": "Added {count} proxies",
"toast.proxiesDeleted": "Deleted {count} proxies",
"toast.exportDone": "Export complete",
"toast.uploaded": "Uploaded: {count}",
"toast.uploadedWithErrors": "Uploaded: {uploaded}, errors: {errors}",
"toast.importResult": "Imported: {imported}, Skipped: {skipped}",
"toast.fileEmpty": "File is empty",
// -- Task types --
"task.validate": "Validation",
"task.changePassword": "Password change",
"task.randomPassword": "Random password",
"task.changeEmail": "Email change",
"task.changePhone": "Phone change",
"task.removePhone": "Phone removal",
"task.removeGuard": "Guard removal",
// -- Badges / statuses --
"status.unknown": "Unknown",
"status.valid": "Valid",
"status.invalid": "Invalid",
"status.locked": "Locked",
"status.limited": "Limited",
"status.banned": "BANNED",
"status.ok": "OK",
// -- Tooltips --
"tip.openBrowser": "Open Steam in browser",
"tip.copyPassword": "Copy password",
"tip.copyLoginPass": "Copy login:pass",
"tip.copyEmailPass": "Copy email password",
"tip.copyEmailLoginPass": "Copy email:pass",
"tip.show": "Show",
"tip.hide": "Hide",
"tip.gen2fa": "Generate and copy 2FA code",
"tip.code": "Code",
"tip.addNote": "Click to add a note",
"tip.edit": "Edit",
"tip.delete": "Delete",
"tip.autoAcceptActive": "Login auto-accept",
"tip.validate": "Validate",
"tip.columnSettings": "Column settings",
"tip.deleteProxy": "Delete proxy",
"tip.clickToCopy": "Click to copy",
"tip.proxyError": "Proxy error: {error}",
// -- Placeholders --
"ph.search": "Search: login / Steam ID / notes / lvl>10",
"ph.searchLogpass": "Search: login / Steam ID / notes / game / lvl>10",
"ph.login": "Login",
"ph.password": "Password",
"ph.proxy": "Proxy",
"ph.proxyOptional": "Proxy (optional)",
"ph.notes": "Notes",
"ph.notesOptional": "Notes (optional)",
"ph.loginOptional": "Login (optional)",
// -- Empty states --
"empty.accounts": "No accounts. Import a file",
"empty.logpass": "No accounts. Import a file.",
"empty.tokens": "No tokens. Import or add tokens.",
"empty.logs": "No logs",
"empty.tasks": "No tasks",
// -- Modals --
"modal.addAccount": "Add account",
"modal.editAccount": "Edit account",
"modal.importAccounts": "Import accounts",
"modal.addToken": "Add token",
"modal.importTokens": "Import tokens",
// -- Import --
"import.formats": "Formats",
"import.formatsColon": "Formats:",
"import.delimiter": "delimiter",
"import.or": "or",
"import.dragTxt": "Drag a .txt file or click to select",
"import.dragTxtCsv": "Drag a .txt / .csv file or click to select",
"import.dragMafile": "Drag .mafile files or click to select",
"import.selected": "Selected: {name}",
"import.uploading": "Uploading...",
"import.importBtn": "Import",
"import.tokenFormat": "refresh_token (one per line)",
// -- Pagination --
"paging.shown": "Showing {visible} of {total}…",
// -- Proxy labels --
"proxy.label": "Proxy {num}",
"proxy.title": "Proxies",
"proxy.count": "{count} total",
"proxy.protocol": "Protocol:",
"proxy.format": "Format:",
"proxy.customFormat": "Custom format...",
"proxy.formatConstructor": "Format constructor:",
"proxy.preview": "Preview:",
"proxy.dragFile": "Drag a .txt file with proxies or click to select",
"proxy.addBtn": "Add",
"proxy.checking": "Checking...",
"proxy.checkAll": "Check all",
"proxy.deleteAllBtn": "Delete all proxies",
"proxy.confirmDeleteAll": "Delete all proxies? This cannot be undone.",
"proxy.checkResult": "Total: {total} | Alive: {alive} | Dead: {dead}",
"proxy.removeProxy": "Remove proxy",
"proxy.reassignProxy": "Reassign proxy",
"proxy.perLine": "Proxies one per line ({format})",
"proxy.deleteProxy": "Delete proxy",
// -- Tools --
"tools.2faGenerator": "2FA Code Generator",
"tools.validationSettings": "Validation Settings",
"tools.loadProfile": "Load profile (nickname, avatar)",
"tools.checkBans": "Check bans",
"tools.maxThreads": "Max threads:",
"tools.autoRevalidateBrowser": "Auto-revalidate on dead cookies (browser)",
"tools.clickCopy": "Click to copy",
"tools.genErrorStr": "Error",
// -- Logs panel --
"logs.title": "Log",
"logs.clear": "Clear",
"logs.activeTasks": "Active Tasks",
// -- Mafile tab --
"mafile.upload": "Upload Mafile",
"mafile.exportSettings": "Export Settings",
"mafile.exportFormat": "Format:",
"mafile.flatFiles": "Flat files (.mafile)",
"mafile.perAccountFolder": "Folder per account",
"mafile.singleFile": "Single file",
"mafile.variables": "Variables:",
"mafile.folderName": "Folder name:",
"mafile.mafileName": ".mafile name:",
"mafile.txtSettings": ".txt Settings",
"mafile.addGlobalTxt": "Add global",
"mafile.withAllAccounts": "with all accounts",
"mafile.addTxtPerFolder": "Add .txt to each folder",
"mafile.skipFolders": "Don't create folder for each account",
"mafile.txtFolderName": ".txt name in folder:",
"mafile.txtLineFormat": ".txt line format:",
"mafile.mafileFields": "Mafile Fields",
"mafile.sessionFields": "Session Fields",
"mafile.export": "Export",
"mafile.noAccounts": "no accounts",
"mafile.sentAccounts": "Sent Accounts",
"mafile.clearBtn": "Clear",
"mafile.insert": "Insert {value}",
// -- Settings drawer --
"settings.display": "Display",
"settings.hidePasswords": "Hide passwords (spoiler)",
// -- Token disclaimer --
"token.disclaimerTitle": "Tab under development",
"token.disclaimerBody1": "Token tab functionality is <strong>not complete</strong> and is under development.",
"token.disclaimerBody2": "⚠ Token validation may invalidate (kill) tokens. Use at your own risk.",
"token.disclaimerBody3": "Import, editing and any actions with tokens may work incorrectly.",
"token.acceptRisk": "I understand the risks and want to continue",
// -- 2FA --
"twofa.copied": "2FA: {code} (copied)",
"twofa.error": "2FA Error: {error}",
// -- Misc --
"misc.task": "Task {id}",
"misc.taskAction": "Task {id} - {action} ({login})",
"misc.taskCount": "Task {id} ({count} accounts)",
"misc.error": "Error: {error}",
"misc.proxyErrorCheck": "Check error: {error}",
"misc.exportError": "Export error: {error}",
"misc.genError": "Generation error: {error}",
"misc.errorStr": "Error",
"misc.insert": "Insert",
},
};
export function t(key: string, params?: Record<string, string | number>): string {
const dict = translations[_locale] || translations.ru;
let text = dict[key] ?? translations.ru[key] ?? key;
if (params) {
for (const [k, v] of Object.entries(params)) {
text = text.replace(`{${k}}`, String(v));
}
}
return text;
}
export function useT() {
useSyncExternalStore(
(cb) => { _listeners.add(cb); return () => _listeners.delete(cb); },
() => _locale,
);
return t;
}
+40
View File
@@ -0,0 +1,40 @@
import { StrictMode, Component } from "react";
import type { ReactNode, ErrorInfo } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./index.css";
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
state = { error: null };
static getDerivedStateFromError(error: Error) { return { error }; }
componentDidCatch(error: Error, info: ErrorInfo) {
console.error("React crash:", error, info);
}
render() {
const { error } = this.state;
if (error) {
return (
<div style={{ padding: 32, fontFamily: "monospace", background: "#0f0f0f", color: "#f87171", minHeight: "100vh" }}>
<h2 style={{ fontSize: 18, marginBottom: 12 }}> SteamPanel failed to start</h2>
<pre style={{ whiteSpace: "pre-wrap", fontSize: 13, color: "#fca5a5" }}>
{(error as Error).message}
{"\n\n"}
{(error as Error).stack}
</pre>
<p style={{ marginTop: 16, color: "#9ca3af", fontSize: 12 }}>
Open browser console (F12) for more details, or report this error.
</p>
</div>
);
}
return this.props.children;
}
}
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
);

Some files were not shown because too many files have changed in this diff Show More