forked from FOSS/Steam-Panel
v 3.0.3
This commit is contained in:
+36
@@ -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
|
||||
@@ -0,0 +1,97 @@
|
||||

|
||||
|
||||
# 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)**
|
||||
@@ -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)}
|
||||
@@ -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"])}
|
||||
@@ -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}
|
||||
@@ -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..."}
|
||||
@@ -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..."}
|
||||
@@ -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")
|
||||
@@ -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"},
|
||||
)
|
||||
@@ -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}
|
||||
@@ -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"]
|
||||
@@ -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")
|
||||
@@ -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}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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']}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 ""
|
||||
@@ -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)
|
||||
@@ -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}")
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
|
||||
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
@@ -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 |
@@ -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>
|
||||
@@ -0,0 +1,97 @@
|
||||

|
||||
|
||||
# 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}`
|
||||
- “Don’t 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 (1–50)
|
||||
- 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 |
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -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>
|
||||
Generated
+3619
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export default {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {},
|
||||
},
|
||||
}
|
||||
@@ -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 |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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) }),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -0,0 +1,8 @@
|
||||
export async function copyText(text: string): Promise<boolean> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
const entityMap: Record<string, string> = {
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
};
|
||||
|
||||
export function escapeHtml(str: string): string {
|
||||
return str.replace(/[&<>"']/g, (s) => entityMap[s] ?? s);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user