This commit is contained in:
Manchik
2026-04-25 04:17:13 +03:00
parent c82866b58a
commit 9b7001407f
23 changed files with 1225 additions and 258 deletions
+1
View File
@@ -34,3 +34,4 @@ Thumbs.db
# Misc # Misc
example/ example/
*.log *.log
*.mafile
+36 -13
View File
@@ -215,7 +215,9 @@ async def import_accounts(file: UploadFile = File(...)):
Supported formats (: or | separator): Supported formats (: or | separator):
login|password|{mafile_json} login|password|{mafile_json}
login|password|email|email_password|{mafile_json} login|password|email|email_password|{mafile_json}
login|password|email|email_password|{mafile_json}|notes
login|password|email|email_password login|password|email|email_password
login|password|email|email_password|notes (no mafile, with notes)
""" """
content = (await file.read()).decode("utf-8", errors="ignore") content = (await file.read()).decode("utf-8", errors="ignore")
result = BulkImportResult() result = BulkImportResult()
@@ -226,11 +228,27 @@ async def import_accounts(file: UploadFile = File(...)):
if not line: if not line:
continue continue
# Split off embedded mafile JSON (everything from first '{' onward) notes: str | None = None
# Split off embedded mafile JSON — find matching braces
json_start = line.find("{") json_start = line.find("{")
if json_start != -1: if json_start != -1:
mafile_str = line[json_start:] depth = 0
json_end = json_start
for i in range(json_start, len(line)):
if line[i] == "{":
depth += 1
elif line[i] == "}":
depth -= 1
if depth == 0:
json_end = i
break
mafile_str = line[json_start : json_end + 1]
prefix = line[:json_start].rstrip("|:") prefix = line[:json_start].rstrip("|:")
suffix = line[json_end + 1 :].lstrip("|:")
if suffix:
notes = suffix
else: else:
mafile_str = None mafile_str = None
prefix = line prefix = line
@@ -262,12 +280,15 @@ async def import_accounts(file: UploadFile = File(...)):
email = mafile_data.get("mail") or mafile_data.get("email") or None 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 email_password = mafile_data.get("mail_password") or mafile_data.get("email_password") or None
else: else:
# No mafile — must be exactly 4 fields # No mafile — 4 fields, or 5 fields with notes
if len(parts) != 4: if len(parts) == 5:
result.errors.append(f"Invalid line (need 4 fields): {line[:50]}") login, password, email, email_password, notes = parts
elif len(parts) == 4:
login, password, email, email_password = parts
else:
result.errors.append(f"Invalid line (need 4-5 fields): {line[:50]}")
result.skipped += 1 result.skipped += 1
continue continue
login, password, email, email_password = parts
mafile_data = None mafile_data = None
try: try:
@@ -281,9 +302,9 @@ async def import_accounts(file: UploadFile = File(...)):
continue continue
await db.execute( await db.execute(
"""INSERT INTO accounts (login, password, email, email_password) """INSERT INTO accounts (login, password, email, email_password, notes)
VALUES (?, ?, ?, ?)""", VALUES (?, ?, ?, ?, ?)""",
(login, password, email or None, email_password or None), (login, password, email or None, email_password or None, notes or None),
) )
result.imported += 1 result.imported += 1
except Exception as exc: except Exception as exc:
@@ -298,7 +319,8 @@ async def import_accounts(file: UploadFile = File(...)):
steam_id = (mafile_data.get("Session") or {}).get("SteamID") or None steam_id = (mafile_data.get("Session") or {}).get("SteamID") or None
settings.mafiles_dir.mkdir(parents=True, exist_ok=True) settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
dest = settings.mafiles_dir / f"{account_name}.mafile" safe_name = Path(account_name).name
dest = settings.mafiles_dir / f"{safe_name}.mafile"
dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8") dest.write_text(json.dumps(mafile_data, ensure_ascii=False), encoding="utf-8")
await db.execute( await db.execute(
@@ -325,12 +347,13 @@ async def upload_mafile(account_id: int, file: UploadFile = File(...)):
content = (await file.read()).decode("utf-8") content = (await file.read()).decode("utf-8")
try: try:
mafile = MafileData.model_validate_json(content) mafile = MafileData.model_validate_json(content)
except Exception: except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid mafile format") raise HTTPException(status_code=400, detail=f"Invalid mafile format: {exc}")
settings.mafiles_dir.mkdir(parents=True, exist_ok=True) settings.mafiles_dir.mkdir(parents=True, exist_ok=True)
steam_id = mafile.Session.SteamID or row["steam_id"] or account_id steam_id = mafile.Session.SteamID or row["steam_id"] or account_id
mafile_path = settings.mafiles_dir / f"{steam_id}.mafile" safe_name = Path(str(steam_id)).name
mafile_path = settings.mafiles_dir / f"{safe_name}.mafile"
mafile_path.write_text(content, encoding="utf-8") mafile_path.write_text(content, encoding="utf-8")
await db.execute( await db.execute(
+51
View File
@@ -84,3 +84,54 @@ async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
if not row or not row["shared_secret"]: if not row or not row["shared_secret"]:
raise HTTPException(status_code=404, detail="Account not found or has no shared_secret") raise HTTPException(status_code=404, detail="Account not found or has no shared_secret")
return {"code": gen_code(row["shared_secret"])} return {"code": gen_code(row["shared_secret"])}
class ConfirmationRespondRequest(BaseModel):
ids: list[str]
nonces: list[str]
accept: bool
@router.get("/confirmations/{account_id}")
async def get_confirmations(account_id: int):
"""Fetch pending Steam confirmations for an account."""
from app.services.steam_confirmations import fetch_confirmations
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
account = await cursor.fetchone()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
confirmations = await fetch_confirmations(dict(account))
return {"success": True, "confirmations": confirmations}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[confirmations] Error fetching for account {account_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/confirmations/{account_id}/respond")
async def respond_confirmations(account_id: int, request: ConfirmationRespondRequest):
"""Accept or deny confirmations for an account."""
from app.services.steam_confirmations import respond_to_confirmation
if len(request.ids) != len(request.nonces):
raise HTTPException(status_code=400, detail="ids and nonces must have same length")
db = await get_db()
cursor = await db.execute("SELECT * FROM accounts WHERE id = ?", (account_id,))
account = await cursor.fetchone()
if not account:
raise HTTPException(status_code=404, detail="Account not found")
try:
success = await respond_to_confirmation(dict(account), request.ids, request.nonces, request.accept)
return {"success": success}
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"[confirmations] Error responding for account {account_id}: {e}")
raise HTTPException(status_code=500, detail=str(e))
+6 -2
View File
@@ -53,19 +53,23 @@ async def upload_mafiles(files: list[UploadFile] = File(...)):
try: try:
mafile = MafileData.model_validate_json(content) mafile = MafileData.model_validate_json(content)
steam_id = mafile.Session.SteamID or mafile.account_name or file.filename steam_id = mafile.Session.SteamID or mafile.account_name or file.filename
dest = settings.mafiles_dir / f"{steam_id}.mafile" safe_name = Path(str(steam_id)).name
dest = settings.mafiles_dir / f"{safe_name}.mafile"
dest.write_text(content, encoding="utf-8") dest.write_text(content, encoding="utf-8")
uploaded += 1 uploaded += 1
if mafile.account_name: if mafile.account_name:
mafile_steam_id = str(mafile.Session.SteamID) if mafile.Session.SteamID else None
await db.execute( await db.execute(
"""UPDATE accounts """UPDATE accounts
SET mafile_path = ?, shared_secret = ?, identity_secret = ? SET mafile_path = ?, shared_secret = ?, identity_secret = ?,
steam_id = COALESCE(steam_id, ?)
WHERE login = ?""", WHERE login = ?""",
( (
str(dest), str(dest),
mafile.shared_secret or "", mafile.shared_secret or "",
mafile.identity_secret or "", mafile.identity_secret or "",
mafile_steam_id,
mafile.account_name, mafile.account_name,
), ),
) )
+9 -5
View File
@@ -2,7 +2,7 @@ import json
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
from pydantic import BaseModel, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, model_validator
class AccountCreate(BaseModel): class AccountCreate(BaseModel):
@@ -90,14 +90,18 @@ class ProxyOut(BaseModel):
class MafileSession(BaseModel): class MafileSession(BaseModel):
SessionID: str = "" model_config = ConfigDict(extra="ignore")
SessionID: str | None = ""
AccessToken: str = "" AccessToken: str = ""
RefreshToken: str = "" RefreshToken: str = ""
SteamID: int = 0 SteamID: int | None = 0
SteamLoginSecure: str = "" SteamLoginSecure: str | None = ""
class MafileData(BaseModel): class MafileData(BaseModel):
model_config = ConfigDict(extra="ignore")
shared_secret: str shared_secret: str
serial_number: str = "" serial_number: str = ""
revocation_code: str = "" revocation_code: str = ""
@@ -107,7 +111,7 @@ class MafileData(BaseModel):
identity_secret: str = "" identity_secret: str = ""
secret_1: str = "" secret_1: str = ""
device_id: str = "" device_id: str = ""
server_time: str = "" server_time: str | int = ""
fully_enrolled: bool = False fully_enrolled: bool = False
Session: MafileSession = Field(default_factory=MafileSession) Session: MafileSession = Field(default_factory=MafileSession)
+59 -2
View File
@@ -34,7 +34,8 @@ def read_mafile_data(account: dict) -> dict | None:
return None return None
try: try:
return json.loads(Path(mafile_path).read_text(encoding="utf-8")) return json.loads(Path(mafile_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError, ValueError): except (OSError, json.JSONDecodeError, ValueError) as exc:
logger.warning(f"Failed to read mafile for {account.get('login', '?')}: {exc}")
return None return None
@@ -80,7 +81,8 @@ async def create_steam_session(account: dict):
shared_secret = mafile.get("shared_secret", shared_secret) shared_secret = mafile.get("shared_secret", shared_secret)
identity_secret = mafile.get("identity_secret", identity_secret) identity_secret = mafile.get("identity_secret", identity_secret)
device_id = mafile.get("device_id") device_id = mafile.get("device_id")
steamid = int(mafile.get("Session", {}).get("SteamID", 0)) or None steamid_raw = (mafile.get("Session") or {}).get("SteamID") or 0
steamid = int(steamid_raw) or None
steam = Steam( steam = Steam(
login=account["login"], login=account["login"],
@@ -155,6 +157,61 @@ def extract_session_cookies(steam) -> str | None:
except Exception: except Exception:
return None return None
def update_mafile_tokens(steam, account: dict) -> bool:
"""Extract fresh tokens from a logged-in steam session and update the .mafile on disk."""
mafile_path = account.get("mafile_path")
if not mafile_path:
return False
try:
mafile = json.loads(Path(mafile_path).read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return False
_req = getattr(steam, "_requests", None)
_sess = getattr(_req, "_session", None) if _req else None
if not _sess or not hasattr(_sess, "cookie_jar"):
return False
access_token = None
session_id = None
steam_id = str(getattr(steam, "_steamid", "") or "")
for c in _sess.cookie_jar:
if c.key == "steamLoginSecure" and c.value:
raw = __import__("urllib.parse", fromlist=["unquote"]).unquote(c.value)
if "||" in raw:
parts = raw.split("||", 1)
if not steam_id:
steam_id = parts[0]
access_token = parts[1]
elif c.key == "sessionid" and c.value:
session_id = c.value
if not access_token:
return False
session = mafile.setdefault("Session", {})
session["AccessToken"] = access_token
if session_id:
session["SessionID"] = session_id
if steam_id:
session["SteamID"] = steam_id
session["SteamLoginSecure"] = f"{steam_id}%7C%7C{access_token}"
refresh_token = getattr(steam, "_refresh_token", None)
if refresh_token:
session["RefreshToken"] = refresh_token
try:
Path(mafile_path).write_text(json.dumps(mafile, indent=2, ensure_ascii=False), encoding="utf-8")
logger.debug(f"[auth] Updated mafile tokens: {mafile_path}")
return True
except OSError as e:
logger.warning(f"[auth] Failed to update mafile: {e}")
return False
async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool: async def check_cookies_alive(cookies_json: str, proxy: dict | None = None) -> bool:
"""Check if Steam session cookies are still valid. """Check if Steam session cookies are still valid.
+390
View File
@@ -0,0 +1,390 @@
"""Steam mobile confirmations — fetch, accept, deny trade/market confirmations."""
import base64
import hashlib
import hmac
import json
import struct
import time
from pathlib import Path
import aiohttp
from yarl import URL
from loguru import logger
COMMUNITY_BASE = "https://steamcommunity.com"
MOBILE_USER_AGENT = "Dalvik/2.1.0 (Linux; U; Android 9; Valve Steam App Version/3)"
CONF_TYPES = {
0: "Invalid",
1: "Test",
2: "Trade",
3: "MarketListing",
4: "FeatureOptOut",
5: "PhoneNumberChange",
6: "AccountRecovery",
}
def _decode_jwt_payload(token: str) -> dict:
parts = token.split(".")
payload_b64 = parts[1] + "=" * (4 - len(parts[1]) % 4)
return json.loads(base64.urlsafe_b64decode(payload_b64))
def _is_token_expired(token: str, margin: int = 60) -> bool:
"""Check if a JWT access token is expired (with safety margin in seconds)."""
try:
payload = _decode_jwt_payload(token)
return payload.get("exp", 0) < int(time.time()) + margin
except Exception:
return True
async def _resolve_proxy(account: dict):
"""Resolve proxy for an account (same logic as steam_auth)."""
from app.core.proxy_manager import proxy_manager
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 _refresh_access_token(
refresh_token: str, steam_id: str, connector: aiohttp.BaseConnector | None = None,
) -> str | None:
"""Use RefreshToken to get a fresh AccessToken via Steam protobuf API."""
try:
from pysteamauth.pb2.steammessages_auth.steamclient_pb2 import (
CAuthentication_AccessToken_GenerateForApp_Request,
CAuthentication_AccessToken_GenerateForApp_Response,
)
except ImportError:
logger.warning("[confirmations] protobuf classes not available for token refresh")
return None
msg = CAuthentication_AccessToken_GenerateForApp_Request()
msg.refresh_token = refresh_token
msg.steamid = int(steam_id)
encoded = base64.b64encode(msg.SerializeToString()).decode()
kw: dict = {"headers": {"User-Agent": MOBILE_USER_AGENT}, "timeout": aiohttp.ClientTimeout(total=15)}
if connector:
kw["connector"] = connector
try:
async with aiohttp.ClientSession(**kw) as http:
async with http.post(
"https://api.steampowered.com/IAuthenticationService/GenerateAccessTokenForApp/v1/",
data={"input_protobuf_encoded": encoded},
) as resp:
eresult = resp.headers.get("x-eresult", "?")
raw = await resp.read()
if raw:
resp_msg = CAuthentication_AccessToken_GenerateForApp_Response()
resp_msg.ParseFromString(raw)
if resp_msg.access_token:
logger.info(f"[confirmations] Refreshed AccessToken for SteamID {steam_id}")
return resp_msg.access_token
logger.warning(f"[confirmations] Token refresh failed for SteamID {steam_id}: eresult={eresult}")
except Exception as e:
logger.error(f"[confirmations] Token refresh error for SteamID {steam_id}: {e}")
return None
def _has_mobile_audience(token: str) -> bool:
"""Check if JWT token has 'mobile' in its audience claim."""
try:
payload = _decode_jwt_payload(token)
aud = payload.get("aud", [])
return "mobile" in aud
except Exception:
return False
async def _ensure_valid_access_token(account: dict, mafile: dict) -> str:
"""Return a valid AccessToken, refreshing if expired. Updates mafile on disk."""
session = mafile.get("Session", {})
access_token = session.get("AccessToken", "")
refresh_token = session.get("RefreshToken", "")
steam_id = str(session.get("SteamID", ""))
needs_refresh = (
not access_token
or _is_token_expired(access_token)
or not _has_mobile_audience(access_token)
)
if not needs_refresh:
return access_token
if not refresh_token:
raise ValueError("AccessToken expired and no RefreshToken available")
if _is_token_expired(refresh_token, margin=0):
raise ValueError("Both AccessToken and RefreshToken are expired — re-authentication required")
from app.core.proxy_manager import proxy_manager
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else None
new_token = await _refresh_access_token(refresh_token, steam_id, connector)
if not new_token:
raise ValueError(
"Failed to refresh AccessToken — session may be revoked or proxy IP mismatch"
)
# Update mafile on disk
mafile["Session"]["AccessToken"] = new_token
mafile["Session"]["SteamLoginSecure"] = f"{steam_id}%7C%7C{new_token}"
mafile_path = account.get("mafile_path")
if mafile_path:
try:
Path(mafile_path).write_text(json.dumps(mafile, indent=2), encoding="utf-8")
logger.debug(f"[confirmations] Updated mafile on disk: {mafile_path}")
except OSError as e:
logger.warning(f"[confirmations] Could not update mafile: {e}")
return new_token
async def _get_steam_server_time() -> int:
"""Query Steam server time for HMAC generation."""
try:
async with aiohttp.ClientSession() as session:
async with session.post(
"https://api.steampowered.com/ITwoFactorService/QueryTime/v0001",
data={"steamid": "0"},
headers={"User-Agent": MOBILE_USER_AGENT},
timeout=aiohttp.ClientTimeout(total=10),
) as resp:
if resp.status == 200:
data = await resp.json()
return int(data.get("response", {}).get("server_time", time.time()))
except Exception:
pass
return int(time.time())
def _generate_confirmation_hash(identity_secret: str, steam_time: int, tag: str) -> str:
"""HMAC-SHA1(identity_secret, time_bytes + tag_bytes) → Base64."""
key = base64.b64decode(identity_secret)
msg = struct.pack(">Q", steam_time)
tag_bytes = tag.encode("ascii")
msg += tag_bytes[:32]
mac = hmac.new(key, msg, hashlib.sha1).digest()
return base64.b64encode(mac).decode()
def _build_query_params(
device_id: str, steam_id: str, identity_secret: str, steam_time: int, tag: str
) -> dict:
"""Build confirmation query parameters."""
return {
"p": device_id,
"a": steam_id,
"k": _generate_confirmation_hash(identity_secret, steam_time, tag),
"t": str(steam_time),
"m": "react",
"tag": tag,
}
def _build_cookies(steam_id: str, access_token: str, session_id: str) -> dict:
"""Build mobile cookies for confirmation requests."""
steam_login_secure = f"{steam_id}%7C%7C{access_token}"
return {
"steamLoginSecure": steam_login_secure,
"sessionid": session_id,
"mobileClient": "android",
"mobileClientVersion": "777777 3.7.2",
"Steam_Language": "english",
}
async def fetch_confirmations(account: dict) -> list[dict]:
"""Fetch pending confirmations for an account.
Returns list of confirmation dicts with id, nonce, type, headline, summary etc.
"""
from app.services.steam_auth import read_mafile_data
from app.core.proxy_manager import proxy_manager
mafile = read_mafile_data(account)
if not mafile:
raise ValueError("No mafile data for this account")
identity_secret = mafile.get("identity_secret", "")
device_id = mafile.get("device_id", "")
session = mafile.get("Session", {})
steam_id = str(session.get("SteamID", ""))
session_id = session.get("SessionID", "")
if not identity_secret or not steam_id:
raise ValueError("Missing identity_secret or SteamID in mafile")
access_token = await _ensure_valid_access_token(account, mafile)
if not device_id:
device_id = f"android:{steam_id}"
if not session_id:
import secrets
session_id = secrets.token_hex(12)
steam_time = await _get_steam_server_time()
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, "conf")
cookies = _build_cookies(steam_id, access_token, session_id)
url = f"{COMMUNITY_BASE}/mobileconf/getlist"
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
async with aiohttp.ClientSession(
headers={"User-Agent": MOBILE_USER_AGENT},
cookie_jar=aiohttp.CookieJar(unsafe=True),
connector=connector,
) as http:
for domain in ["steamcommunity.com", "store.steampowered.com"]:
for k, v in cookies.items():
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
async with http.get(url, params=params) as resp:
if resp.status != 200:
raise ValueError(f"HTTP {resp.status} from Steam confirmations API")
data = await resp.json()
if not data.get("success"):
if data.get("needsauth"):
raise ValueError("Session expired — needs re-authentication")
detail = data.get("detail", "")
message = data.get("message", "")
login = account.get("login", "?")
logger.warning(f"[confirmations] {login}: {message or 'unknown error'}")
if "authenticator" in message.lower() or "authenticator" in detail.lower():
raise ValueError(
"Invalid authenticator — mafile secrets may be outdated. "
"Re-link Steam Guard or re-import a fresh mafile."
)
if detail:
raise ValueError(f"Steam: {detail}")
if message:
raise ValueError(f"Steam: {message}")
raise ValueError("Steam rejected the request — likely expired session or invalid mafile tokens")
confirmations = []
for conf in data.get("conf", []):
confirmations.append({
"id": str(conf.get("id", "")),
"nonce": str(conf.get("nonce", "")),
"type": conf.get("type", 0),
"type_name": CONF_TYPES.get(conf.get("type", 0), "Unknown"),
"creator_id": str(conf.get("creator_id", "")),
"headline": conf.get("headline", ""),
"summary": [conf.get("summary", "")] if isinstance(conf.get("summary"), str) else conf.get("summary", []),
"accept": conf.get("accept", "Accept"),
"cancel": conf.get("cancel", "Cancel"),
"icon": conf.get("icon", ""),
})
logger.info(f"[confirmations] {account.get('login', '?')}: fetched {len(confirmations)} confirmations")
return confirmations
async def respond_to_confirmation(
account: dict, confirmation_ids: list[str], confirmation_nonces: list[str], accept: bool
) -> bool:
"""Accept or deny one or more confirmations.
Uses multiajaxop for batch operations, ajaxop for single.
"""
from app.services.steam_auth import read_mafile_data
from app.core.proxy_manager import proxy_manager
mafile = read_mafile_data(account)
if not mafile:
raise ValueError("No mafile data")
identity_secret = mafile.get("identity_secret", "")
device_id = mafile.get("device_id", "")
session = mafile.get("Session", {})
steam_id = str(session.get("SteamID", ""))
session_id = session.get("SessionID", "")
if not identity_secret or not steam_id:
raise ValueError("Missing secrets in mafile")
access_token = await _ensure_valid_access_token(account, mafile)
if not device_id:
device_id = f"android:{steam_id}"
if not session_id:
import secrets
session_id = secrets.token_hex(12)
op = "allow" if accept else "cancel"
tag = "accept" if accept else "reject"
steam_time = await _get_steam_server_time()
params = _build_query_params(device_id, steam_id, identity_secret, steam_time, tag)
cookies = _build_cookies(steam_id, access_token, session_id)
proxy = await _resolve_proxy(account)
connector = proxy_manager.get_connector(proxy) if proxy else aiohttp.TCPConnector()
async with aiohttp.ClientSession(
headers={"User-Agent": MOBILE_USER_AGENT},
cookie_jar=aiohttp.CookieJar(unsafe=True),
connector=connector,
) as http:
for domain in ["steamcommunity.com", "store.steampowered.com"]:
for k, v in cookies.items():
http.cookie_jar.update_cookies({k: v}, URL(f"https://{domain}"))
if len(confirmation_ids) == 1:
url = f"{COMMUNITY_BASE}/mobileconf/ajaxop"
query = {
"op": op,
**params,
"cid": confirmation_ids[0],
"ck": confirmation_nonces[0],
}
async with http.get(url, params=query) as resp:
data = await resp.json()
else:
url = f"{COMMUNITY_BASE}/mobileconf/multiajaxop"
form_data = {"op": op}
form_data.update(params)
for cid in confirmation_ids:
form_data[f"cid[]"] = cid
for ck in confirmation_nonces:
form_data[f"ck[]"] = ck
payload = aiohttp.FormData()
payload.add_field("op", op)
for k, v in params.items():
payload.add_field(k, str(v))
for cid in confirmation_ids:
payload.add_field("cid[]", cid)
for ck in confirmation_nonces:
payload.add_field("ck[]", ck)
async with http.post(url, data=payload) as resp:
data = await resp.json()
success = data.get("success", False)
action_word = "accepted" if accept else "denied"
logger.info(f"[confirmations] {account.get('login', '?')}: {action_word} {len(confirmation_ids)} confirmations — success={success}")
return success
+4
View File
@@ -165,6 +165,10 @@ async def validate_account(account: dict, params: dict, task_id: str = "") -> No
except Exception as cookie_err: except Exception as cookie_err:
logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}") logger.warning(f"[validate] {login}: failed to save cookies — {cookie_err}")
from app.services.steam_auth import update_mafile_tokens
if update_mafile_tokens(steam, account):
logger.info(f"[validate] {login}: updated mafile tokens")
except Exception as exc: except Exception as exc:
status = "invalid" status = "invalid"
error_msg = str(exc) error_msg = str(exc)
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -5,8 +5,8 @@
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>SteamPanel</title> <title>SteamPanel</title>
<script type="module" crossorigin src="/assets/index-Bxw--tTv.js"></script> <script type="module" crossorigin src="/assets/index-D5Q9Ekc6.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Cm89176E.css"> <link rel="stylesheet" crossorigin href="/assets/index-aSRiX3qP.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+7
View File
@@ -60,6 +60,13 @@ export const api = {
request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }), request<{ code: string }>("/actions/generate-2fa", { method: "POST", body: JSON.stringify({ shared_secret }) }),
generate2FAByAccount: (account_id: number) => generate2FAByAccount: (account_id: number) =>
request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }), request<{ code: string }>("/actions/generate-2fa-by-account", { method: "POST", body: JSON.stringify({ account_id }) }),
getConfirmations: (account_id: number) =>
request<{ success: boolean; confirmations: import("./types").SteamConfirmation[] }>(`/actions/confirmations/${account_id}`),
respondConfirmations: (account_id: number, ids: string[], nonces: string[], accept: boolean) =>
request<{ success: boolean }>(`/actions/confirmations/${account_id}/respond`, {
method: "POST",
body: JSON.stringify({ ids, nonces, accept }),
}),
// Proxies // Proxies
getProxies: () => request<Proxy[]>("/proxies"), getProxies: () => request<Proxy[]>("/proxies"),
+13
View File
@@ -258,3 +258,16 @@ export interface Toast {
type: ToastType; type: ToastType;
message: string; message: string;
} }
export interface SteamConfirmation {
id: string;
nonce: string;
type: number;
type_name: string;
creator_id: string;
headline: string;
summary: string[];
accept: string;
cancel: string;
icon: string;
}
@@ -5,7 +5,9 @@ import { api } from "@/api/client";
import { useAccountStore } from "@/stores/accountStore"; import { useAccountStore } from "@/stores/accountStore";
import { useUiStore } from "@/stores/uiStore"; import { useUiStore } from "@/stores/uiStore";
import { IconZap } from "@/components/shared/Icons"; import { IconZap } from "@/components/shared/Icons";
import { copyText } from "@/lib/clipboard";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
import { ConfirmationsModal } from "./ConfirmationsModal";
const ROW_ACTION_KEYS = [ const ROW_ACTION_KEYS = [
{ action: "validate", labelKey: "action.validate" }, { action: "validate", labelKey: "action.validate" },
@@ -43,6 +45,7 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null); const [paramPrompt, setParamPrompt] = useState<{ action: string; title: string } | null>(null);
const [paramValue, setParamValue] = useState(""); const [paramValue, setParamValue] = useState("");
const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null); const [confirmPrompt, setConfirmPrompt] = useState<{ action: string; title: string } | null>(null);
const [showConfirmations, setShowConfirmations] = useState(false);
const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null); const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>(null);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
const btnRef = useRef<HTMLButtonElement>(null); const btnRef = useRef<HTMLButtonElement>(null);
@@ -107,6 +110,26 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin"); const aaLabel = account.auto_accept ? t("action.disableAutoAccept") : t("action.enableAutoAcceptLogin");
const handleGenerate2FA = async () => {
setOpen(false);
if (!account.shared_secret) {
addToast("error", t("twofa.error", { error: "No shared_secret" }));
return;
}
try {
const { code } = await api.generate2FA(account.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) }));
}
};
const handleOpenConfirmations = () => {
setOpen(false);
setShowConfirmations(true);
};
const handleRemoveProxy = async () => { const handleRemoveProxy = async () => {
setOpen(false); setOpen(false);
try { try {
@@ -159,6 +182,21 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
); );
})} })}
<hr className="border-dark-600 my-1" /> <hr className="border-dark-600 my-1" />
<button
onClick={handleGenerate2FA}
disabled={!account.shared_secret}
className={`action-menu-item ${!account.shared_secret ? "opacity-40 cursor-not-allowed" : ""}`}
>
{t("action.generate2fa")}
</button>
<button
onClick={handleOpenConfirmations}
disabled={!account.identity_secret}
className={`action-menu-item ${!account.identity_secret ? "opacity-40 cursor-not-allowed" : ""}`}
>
{t("action.confirmations")}
</button>
<hr className="border-dark-600 my-1" />
<button onClick={handleRemoveProxy} className="action-menu-item"> <button onClick={handleRemoveProxy} className="action-menu-item">
{t("proxy.removeProxy")} {t("proxy.removeProxy")}
</button> </button>
@@ -214,6 +252,10 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
</div> </div>
</div> </div>
)} )}
{showConfirmations && (
<ConfirmationsModal account={account} onClose={() => setShowConfirmations(false)} />
)}
</> </>
); );
} }
+87 -6
View File
@@ -1,5 +1,9 @@
import { useState } from "react"; import { useState, useRef } from "react";
import type { AccountCreate } from "@/api/types"; import type { AccountCreate } from "@/api/types";
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"; import { useT } from "@/lib/i18n";
interface Props { interface Props {
@@ -9,19 +13,52 @@ interface Props {
export function AddModal({ onSave, onClose }: Props) { export function AddModal({ onSave, onClose }: Props) {
const t = useT(); const t = useT();
const addToast = useUiStore((s) => s.addToast);
const loadAccounts = useAccountStore((s) => s.loadAccounts);
const [login, setLogin] = useState(""); const [login, setLogin] = useState("");
const [password, setPassword] = useState(""); const [password, setPassword] = useState("");
const [email, setEmail] = useState(""); const [email, setEmail] = useState("");
const [emailPassword, setEmailPassword] = useState("");
const [proxy, setProxy] = useState(""); const [proxy, setProxy] = useState("");
const [notes, setNotes] = useState("");
const [mafile, setMafile] = useState<File | null>(null);
const [saving, setSaving] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const handleSave = () => { const parseLoginPass = (value: string) => {
const sep = value.includes("|") ? "|" : ":";
const parts = value.split(sep);
if (parts.length >= 2) {
setLogin(parts[0]);
setPassword(parts[1]);
if (parts.length >= 3) setEmail(parts[2]);
if (parts.length >= 4) setEmailPassword(parts[3]);
} else {
setLogin(value);
}
};
const handleSave = async () => {
if (!login || !password) return; if (!login || !password) return;
setSaving(true);
try {
onSave({ onSave({
login, login,
password, password,
email: email || undefined, email: email || undefined,
email_password: emailPassword || undefined,
proxy: proxy || undefined, proxy: proxy || undefined,
notes: notes || undefined,
}); });
if (mafile) {
await api.uploadMafiles([mafile]);
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
} finally {
setSaving(false);
}
}; };
return ( return (
@@ -29,11 +66,55 @@ export function AddModal({ onSave, onClose }: Props) {
<div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}> <div className="confirm-box max-w-md" onMouseDown={(e) => e.stopPropagation()}>
<h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3> <h3 className="text-lg font-semibold mb-4">{t("modal.addAccount")}</h3>
<div className="space-y-3"> <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
autoFocus
value={login}
onChange={(e) => setLogin(e.target.value)}
onPaste={(e) => {
const text = e.clipboardData.getData("text").trim();
if (text.includes(":") || text.includes("|")) {
e.preventDefault();
parseLoginPass(text);
}
}}
placeholder={t("ph.login") + " (login:pass)"}
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={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" /> <div className="grid grid-cols-2 gap-2">
<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={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email" className="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> <input value={emailPassword} onChange={(e) => setEmailPassword(e.target.value)} placeholder={t("ph.emailPassword")} className="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
</div>
<div className="grid grid-cols-2 gap-2">
<input value={proxy} onChange={(e) => setProxy(e.target.value)} placeholder={t("ph.proxyOptional")} className="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="bg-dark-700 border border-dark-600 rounded px-3 py-2 text-sm" />
</div>
<div
onClick={() => fileRef.current?.click()}
className="border border-dashed border-dark-500 rounded p-3 text-center cursor-pointer transition-colors hover:border-accent flex items-center justify-center gap-2"
>
<IconUpload size={14} className="text-gray-500" />
<span className="text-xs text-gray-400">
{mafile ? mafile.name : t("import.attachMafile")}
</span>
{mafile && (
<button
onClick={(e) => { e.stopPropagation(); setMafile(null); }}
className="text-xs text-red-400 hover:text-red-300 ml-1"
>
</button>
)}
</div>
<input ref={fileRef} type="file" accept=".mafile" className="hidden" onChange={(e) => {
const f = e.target.files?.[0];
if (f) setMafile(f);
}} />
<button onClick={handleSave} disabled={!login || !password || saving} className="btn-primary w-full">
{saving ? t("import.uploading") : t("btn.add")}
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -0,0 +1,191 @@
import { useState, useEffect, useCallback } from "react";
import { api } from "@/api/client";
import type { Account, SteamConfirmation } from "@/api/types";
import { useUiStore } from "@/stores/uiStore";
import { useT } from "@/lib/i18n";
import { IconRefresh, IconCheck, IconX } from "@/components/shared/Icons";
const TYPE_ICONS: Record<number, string> = {
0: "❓",
1: "🧪",
2: "🔄",
3: "🏪",
4: "⚙️",
5: "📱",
6: "🔑",
};
interface Props {
account: Account;
onClose: () => void;
}
export function ConfirmationsModal({ account, onClose }: Props) {
const t = useT();
const addToast = useUiStore((s) => s.addToast);
const [confirmations, setConfirmations] = useState<SteamConfirmation[]>([]);
const [loading, setLoading] = useState(true);
const [responding, setResponding] = useState<Set<string>>(new Set());
const [error, setError] = useState<string | null>(null);
const fetchConfs = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await api.getConfirmations(account.id);
setConfirmations(data.confirmations);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, [account.id]);
useEffect(() => {
fetchConfs();
}, [fetchConfs]);
const handleRespond = async (ids: string[], nonces: string[], accept: boolean) => {
const newSet = new Set(responding);
ids.forEach((id) => newSet.add(id));
setResponding(newSet);
try {
const { success } = await api.respondConfirmations(account.id, ids, nonces, accept);
if (success) {
const action = accept ? t("confirm.accepted") : t("confirm.denied");
addToast("success", `${action} (${ids.length})`);
setConfirmations((prev) => prev.filter((c) => !ids.includes(c.id)));
} else {
addToast("error", t("confirm.respondFailed"));
}
} catch (e: unknown) {
addToast("error", e instanceof Error ? e.message : String(e));
} finally {
setResponding((prev) => {
const s = new Set(prev);
ids.forEach((id) => s.delete(id));
return s;
});
}
};
const handleAcceptAll = () => {
if (confirmations.length === 0) return;
handleRespond(
confirmations.map((c) => c.id),
confirmations.map((c) => c.nonce),
true,
);
};
const handleDenyAll = () => {
if (confirmations.length === 0) return;
handleRespond(
confirmations.map((c) => c.id),
confirmations.map((c) => c.nonce),
false,
);
};
return (
<div className="confirm-overlay" onMouseDown={onClose}>
<div
className="bg-dark-800 border border-dark-600 rounded-lg shadow-xl p-5 w-[560px] max-h-[80vh] flex flex-col"
onMouseDown={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-base font-semibold text-gray-200">
{t("confirm.title")} {account.login}
</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-300 transition-colors">
<IconX />
</button>
</div>
{loading && (
<div className="flex items-center justify-center py-8 text-gray-400 text-sm">
<IconRefresh className="animate-spin mr-2 w-4 h-4" />
{t("confirm.loading")}
</div>
)}
{error && (
<div className="bg-red-900/30 border border-red-700 rounded p-3 text-sm text-red-300 mb-3">
{error}
</div>
)}
{!loading && !error && confirmations.length === 0 && (
<p className="text-gray-400 text-sm text-center py-8">{t("confirm.empty")}</p>
)}
{!loading && confirmations.length > 0 && (
<>
<div className="flex gap-2 mb-3">
<button onClick={handleAcceptAll} className="btn-primary text-xs flex items-center gap-1">
<IconCheck className="w-3 h-3" />
{t("confirm.acceptAll")} ({confirmations.length})
</button>
<button onClick={handleDenyAll} className="btn-secondary text-xs flex items-center gap-1">
<IconX className="w-3 h-3" />
{t("confirm.denyAll")}
</button>
<button onClick={fetchConfs} className="btn-secondary text-xs flex items-center gap-1 ml-auto">
<IconRefresh className="w-3 h-3" />
{t("confirm.refresh")}
</button>
</div>
<div className="overflow-y-auto space-y-2 flex-1 min-h-0">
{confirmations.map((conf) => {
const busy = responding.has(conf.id);
return (
<div
key={conf.id}
className={`bg-dark-700 border border-dark-600 rounded p-3 flex items-start gap-3 ${busy ? "opacity-50" : ""}`}
>
{conf.icon ? (
<img src={conf.icon} alt="" className="w-8 h-8 rounded flex-shrink-0 mt-0.5" />
) : (
<span className="text-xl flex-shrink-0 mt-0.5">{TYPE_ICONS[conf.type] ?? "❓"}</span>
)}
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs px-1.5 py-0.5 rounded bg-dark-600 text-gray-400">
{conf.type_name}
</span>
<span className="text-sm text-gray-200 truncate">{conf.headline}</span>
</div>
{conf.summary.length > 0 && (
<p className="text-xs text-gray-400 truncate">{conf.summary.join(" • ")}</p>
)}
</div>
<div className="flex gap-1 flex-shrink-0">
<button
disabled={busy}
onClick={() => handleRespond([conf.id], [conf.nonce], true)}
className="p-1.5 rounded bg-green-700/60 hover:bg-green-600/80 text-green-200 transition-colors"
title={conf.accept}
>
<IconCheck className="w-3.5 h-3.5" />
</button>
<button
disabled={busy}
onClick={() => handleRespond([conf.id], [conf.nonce], false)}
className="p-1.5 rounded bg-red-700/60 hover:bg-red-600/80 text-red-200 transition-colors"
title={conf.cancel}
>
<IconX className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
})}
</div>
</>
)}
</div>
</div>
);
}
@@ -47,7 +47,9 @@ export function ImportModal({ onClose }: Props) {
<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-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:{"{"}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:{"{"}mafile{"}"}</p>
<p className="font-mono text-xs">login:pass:email:email_pass:{"{"}mafile{"}"}:notes</p>
<p className="font-mono text-xs">login:pass:email:email_pass</p> <p className="font-mono text-xs">login:pass:email:email_pass</p>
<p className="font-mono text-xs">login:pass:email:email_pass:notes</p>
</div> </div>
<div <div
onDragOver={(e) => e.preventDefault()} onDragOver={(e) => e.preventDefault()}
+179 -156
View File
@@ -18,6 +18,12 @@ const NAME_VARS = [
{ value: "{steamid}", label: "steamid" }, { value: "{steamid}", label: "steamid" },
]; ];
const FORMAT_OPTIONS: { value: MafileExportRequest["format"]; icon: string }[] = [
{ value: "flat_mafiles", icon: "📄" },
{ value: "per_account_folder", icon: "📁" },
{ value: "single_file", icon: "📦" },
];
interface Props { interface Props {
accountIds: number[]; accountIds: number[];
onExport: (req: MafileExportRequest) => void; onExport: (req: MafileExportRequest) => void;
@@ -29,12 +35,10 @@ export function ExportSettings({ accountIds, onExport }: Props) {
const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS)); const [sessionFields, setSessionFields] = useState<Set<string>>(new Set(SESSION_FIELDS));
const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder"); const [format, setFormat] = useState<MafileExportRequest["format"]>("per_account_folder");
// Naming templates
const [folderNameTemplate, setFolderNameTemplate] = useState("{username}"); const [folderNameTemplate, setFolderNameTemplate] = useState("{username}");
const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile"); const [mafileNameTemplate, setMafileNameTemplate] = useState("{steamid}.mafile");
const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt"); const [txtNameTemplate, setTxtNameTemplate] = useState("{username}.txt");
// .txt options
const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false); const [includeTxtPerFolder, setIncludeTxtPerFolder] = useState(false);
const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false); const [includeGlobalTxt, setIncludeGlobalTxt] = useState(false);
const [skipFolders, setSkipFolders] = useState(false); const [skipFolders, setSkipFolders] = useState(false);
@@ -68,203 +72,222 @@ export function ExportSettings({ accountIds, onExport }: Props) {
}; };
return ( return (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4 space-y-4"> <div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
<h4 className="font-semibold text-sm"><IconPackage size={14} className="inline mr-1" />{t("mafile.exportSettings")}</h4> {/* Header */}
<div className="px-4 py-3 border-b border-dark-600 flex items-center gap-2">
<IconPackage size={14} className="text-accent" />
<h4 className="font-semibold text-sm">{t("mafile.exportSettings")}</h4>
</div>
{/* Format */} <div className="p-4 space-y-4">
{/* Format selector — card-style buttons */}
<div> <div>
<p className="text-xs text-gray-400 mb-1">{t("mafile.exportFormat")}</p> <p className="text-xs text-gray-400 mb-2">{t("mafile.exportFormat")}</p>
<select <div className="grid grid-cols-3 gap-2">
value={format} {FORMAT_OPTIONS.map((opt) => (
onChange={(e) => { <button
const val = e.target.value as MafileExportRequest["format"]; key={opt.value}
setFormat(val); onClick={() => {
if (val === "single_file") { setFormat(opt.value);
if (opt.value === "single_file") {
setIncludeGlobalTxt(false); setIncludeGlobalTxt(false);
setSkipFolders(false); setSkipFolders(false);
} }
}} }}
className="bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm" className={`flex flex-col items-center gap-1 p-3 rounded-lg border text-xs transition-all cursor-pointer ${
format === opt.value
? "border-accent bg-accent/10 text-white"
: "border-dark-600 bg-dark-700 text-gray-400 hover:border-dark-500 hover:text-gray-300"
}`}
> >
<option value="flat_mafiles">{t("mafile.flatFiles")}</option> <span className="text-base">{opt.icon}</span>
<option value="per_account_folder">{t("mafile.perAccountFolder")}</option> <span>{t(`mafile.${opt.value}` as any)}</span>
<option value="single_file">{t("mafile.singleFile")}</option> </button>
</select> ))}
</div>
</div> </div>
{/* Naming templates */} {/* Naming templates */}
{format !== "single_file" && ( {format !== "single_file" && (
<div className="space-y-3 border border-dark-600 rounded-lg p-3"> <details className="group" open>
<p className="text-xs text-gray-400 font-medium"> <summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
{t("mafile.variables")} <code className="text-accent">{"{username}"}</code>, <code className="text-accent">{"{steamid}"}</code> <IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
</p> {t("mafile.namingSection")}
<span className="text-gray-500 ml-1">
<code className="text-accent/70">{"{username}"}</code> <code className="text-accent/70">{"{steamid}"}</code>
</span>
</summary>
<div className="mt-3 space-y-3 pl-4 border-l border-dark-600">
{format === "per_account_folder" && ( {format === "per_account_folder" && (
<div> <TemplateInput
<label className="text-xs text-gray-400 block mb-1">{t("mafile.folderName")}</label> label={t("mafile.folderName")}
<div className="flex gap-1">
<input
value={folderNameTemplate} value={folderNameTemplate}
onChange={(e) => setFolderNameTemplate(e.target.value)} onChange={setFolderNameTemplate}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono" vars={NAME_VARS}
insertVar={insertVar}
/> />
{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>
)} )}
<TemplateInput
<div> label={t("mafile.mafileName")}
<label className="text-xs text-gray-400 block mb-1">{t("mafile.mafileName")}</label>
<div className="flex gap-1">
<input
value={mafileNameTemplate} value={mafileNameTemplate}
onChange={(e) => setMafileNameTemplate(e.target.value)} onChange={setMafileNameTemplate}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono" vars={NAME_VARS}
insertVar={insertVar}
/> />
{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> </div>
</details>
)} )}
{/* .txt options */} {/* .txt options */}
<div className="space-y-2 border border-dark-600 rounded-lg p-3"> <details className="group">
<p className="text-xs text-gray-400 font-medium"><IconFileText size={12} className="inline mr-1" />{t("mafile.txtSettings")}</p> <summary className="text-xs text-gray-400 cursor-pointer hover:text-gray-300 transition-colors flex items-center gap-1.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
<IconFileText size={12} className="inline" />
{t("mafile.txtSettings")}
</summary>
<div className="mt-3 space-y-2.5 pl-4 border-l border-dark-600">
{format !== "single_file" && ( {format !== "single_file" && (
<> <>
<label className="flex items-center gap-2 text-sm cursor-pointer"> <ToggleOption checked={includeGlobalTxt} onChange={(v) => { setIncludeGlobalTxt(v); if (!v) setSkipFolders(false); }}>
<input {t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code>
type="checkbox" </ToggleOption>
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" && ( {format === "per_account_folder" && (
<label className="flex items-center gap-2 text-sm cursor-pointer"> <ToggleOption checked={includeTxtPerFolder} onChange={setIncludeTxtPerFolder}>
<input
type="checkbox"
checked={includeTxtPerFolder}
onChange={(e) => setIncludeTxtPerFolder(e.target.checked)}
/>
{t("mafile.addTxtPerFolder")} {t("mafile.addTxtPerFolder")}
</label> </ToggleOption>
)} )}
<label className={`flex items-center gap-2 text-sm ${includeGlobalTxt ? "cursor-pointer" : "cursor-not-allowed opacity-40"}`}> <ToggleOption checked={skipFolders} onChange={setSkipFolders} disabled={!includeGlobalTxt}>
<input
type="checkbox"
checked={skipFolders}
disabled={!includeGlobalTxt}
onChange={(e) => setSkipFolders(e.target.checked)}
/>
{t("mafile.skipFolders")} {t("mafile.skipFolders")}
</label> </ToggleOption>
</> </>
)} )}
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && ( {(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
<> <div className="space-y-3 pt-1">
{format === "per_account_folder" && includeTxtPerFolder && ( {format === "per_account_folder" && includeTxtPerFolder && (
<div> <TemplateInput
<label className="text-xs text-gray-400 block mb-1">{t("mafile.txtFolderName")}</label> label={t("mafile.txtFolderName")}
<div className="flex gap-1">
<input
value={txtNameTemplate} value={txtNameTemplate}
onChange={(e) => setTxtNameTemplate(e.target.value)} onChange={setTxtNameTemplate}
className="flex-1 bg-dark-700 border border-dark-600 rounded px-2 py-1 text-sm font-mono" vars={NAME_VARS}
insertVar={insertVar}
/> />
{NAME_VARS.map((v) => ( )}
<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-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
/>
<p className="text-[11px] text-gray-500 mt-1.5 leading-relaxed">
{t("mafile.variables")} <code className="text-accent/70">{"{login}"}</code> <code className="text-accent/70">{"{password}"}</code> <code className="text-accent/70">{"{email}"}</code> <code className="text-accent/70">{"{email_password}"}</code> <code className="text-accent/70">{"{steam_id}"}</code> <code className="text-accent/70">{"{proxy}"}</code> <code className="text-accent/70">{"{mafile}"}</code>
</p>
</div>
</div>
)}
</div>
</details>
{/* 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.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.mafileFields")}
<span className="text-gray-500 text-[11px]">{fields.size}/{MAFILE_FIELDS.length}</span>
</summary>
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
{MAFILE_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
<input type="checkbox" checked={fields.has(f)} onChange={() => toggleField(fields, f, setFields)} className="accent-accent" />
<span className="font-mono text-[11px]">{f}</span>
</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.5 select-none">
<IconChevronRight size={12} className="transition-transform group-open:rotate-90" />
{t("mafile.sessionFields")}
<span className="text-gray-500 text-[11px]">{sessionFields.size}/{SESSION_FIELDS.length}</span>
</summary>
<div className="flex flex-wrap gap-x-3 gap-y-1.5 mt-2 pl-4">
{SESSION_FIELDS.map((f) => (
<label key={f} className="flex items-center gap-1.5 text-xs cursor-pointer hover:text-gray-200 transition-colors">
<input type="checkbox" checked={sessionFields.has(f)} onChange={() => toggleField(sessionFields, f, setSessionFields)} className="accent-accent" />
<span className="font-mono text-[11px]">{f}</span>
</label>
))}
</div>
</details>
{/* Export button */}
<button
onClick={handleExport}
disabled={accountIds.length === 0}
className={`w-full flex items-center justify-center gap-2 py-2.5 rounded-lg font-medium text-sm transition-all ${
accountIds.length === 0
? "bg-dark-700 text-gray-500 cursor-not-allowed"
: "bg-accent hover:bg-accent-hover text-white cursor-pointer"
}`}
>
<IconDownload size={14} />
{t("mafile.export")} ({accountIds.length > 0 ? accountIds.length : t("mafile.noAccounts")})
</button>
</div>
</div>
);
}
function TemplateInput({ label, value, onChange, vars, insertVar }: {
label: string;
value: string;
onChange: (v: string) => void;
vars: { value: string; label: string }[];
insertVar: (setter: (fn: (prev: string) => string) => void, varName: string) => void;
}) {
return (
<div>
<label className="text-xs text-gray-400 block mb-1">{label}</label>
<div className="flex gap-1.5">
<input
value={value}
onChange={(e) => onChange(e.target.value)}
className="flex-1 bg-dark-700 border border-dark-600 rounded-lg px-3 py-1.5 text-sm font-mono focus:border-accent/50 focus:outline-none transition-colors"
/>
{vars.map((v) => (
<button <button
key={v.value} key={v.value}
onClick={() => insertVar(setTxtNameTemplate, v.value)} onClick={() => insertVar((fn) => onChange(fn(value)), v.value)}
className="btn-secondary px-2 py-1 text-xs" className="px-2 py-1 text-[11px] rounded bg-dark-700 border border-dark-600 text-gray-400 hover:text-accent hover:border-accent/30 transition-colors cursor-pointer"
> >
{v.label} {v.label}
</button> </button>
))} ))}
</div> </div>
</div> </div>
)} );
}
<div>
<label className="text-xs text-gray-400 block mb-1"> function ToggleOption({ checked, onChange, disabled, children }: {
{t("mafile.txtLineFormat")} checked: boolean;
</label> onChange: (v: boolean) => void;
<input disabled?: boolean;
value={txtFormat} children: React.ReactNode;
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" return (
/> <label className={`flex items-center gap-2.5 text-sm cursor-pointer select-none ${disabled ? "opacity-40 cursor-not-allowed" : "hover:text-gray-200"} transition-colors`}>
<p className="text-xs text-gray-500 mt-1"> <input
{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> type="checkbox"
</p> checked={checked}
</div> disabled={disabled}
</> onChange={(e) => onChange(e.target.checked)}
)} className="accent-accent"
</div> />
<span className="text-xs">{children}</span>
{/* Field selection */} </label>
<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>
); );
} }
+46 -19
View File
@@ -5,7 +5,7 @@ import type { MafileExportRequest } from "@/api/types";
import { ExportSettings } from "./ExportSettings"; import { ExportSettings } from "./ExportSettings";
import { useT } from "@/lib/i18n"; import { useT } from "@/lib/i18n";
import { IconClipboard } from "@/components/shared/Icons"; import { IconClipboard, IconPackage } from "@/components/shared/Icons";
export function MafileTab() { export function MafileTab() {
const t = useT(); const t = useT();
@@ -13,7 +13,6 @@ export function MafileTab() {
const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds); const mafileManagerIds = useAccountStore((s) => s.mafileManagerIds);
const clearMafileManager = useAccountStore((s) => s.clearMafileManager); const clearMafileManager = useAccountStore((s) => s.clearMafileManager);
const addToast = useUiStore((s) => s.addToast); const addToast = useUiStore((s) => s.addToast);
// Bug #3: only show explicitly sent accounts
const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id)); const sentAccounts = accounts.filter((a) => mafileManagerIds.has(a.id));
const handleExport = async (req: MafileExportRequest) => { const handleExport = async (req: MafileExportRequest) => {
@@ -31,25 +30,50 @@ export function MafileTab() {
} }
}; };
const withMafile = sentAccounts.filter((a) => a.mafile_path).length;
return ( return (
<div className="space-y-4 h-full overflow-y-auto pr-1"> <div className="h-full overflow-y-auto pr-1">
{/* Sent accounts list — Bug #3: only sent accounts shown */} <div className="max-w-2xl mx-auto space-y-5 py-2">
{sentAccounts.length > 0 && (
<div className="bg-dark-800 border border-dark-600 rounded-lg p-4"> {/* Empty state */}
<div className="flex items-center justify-between mb-3"> {sentAccounts.length === 0 && (
<h3 className="font-semibold"> <div className="flex flex-col items-center justify-center py-16 text-center">
<IconClipboard size={14} className="inline mr-1" />{t("mafile.sentAccounts")} ({sentAccounts.length}) <div className="w-14 h-14 rounded-2xl bg-dark-700 flex items-center justify-center mb-4">
</h3> <IconPackage size={24} className="text-gray-500" />
<button onClick={clearMafileManager} className="btn-danger-outline text-xs">{t("mafile.clearBtn")}</button>
</div> </div>
<div className="max-h-48 overflow-y-auto space-y-1"> <p className="text-gray-400 text-sm mb-1">{t("mafile.emptyTitle")}</p>
<p className="text-gray-500 text-xs max-w-xs">{t("mafile.emptyHint")}</p>
</div>
)}
{/* Account list */}
{sentAccounts.length > 0 && (
<div className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
<div className="flex items-center justify-between px-4 py-3 border-b border-dark-600">
<div className="flex items-center gap-2">
<IconClipboard size={14} className="text-accent" />
<h3 className="font-semibold text-sm">{t("mafile.sentAccounts")}</h3>
<span className="text-xs text-gray-500">
{sentAccounts.length} {t("mafile.accountsCount")} · {withMafile} {t("mafile.withMafile")}
</span>
</div>
<button onClick={clearMafileManager} className="text-xs text-red-400 hover:text-red-300 transition-colors cursor-pointer">
{t("mafile.clearBtn")}
</button>
</div>
<div className="max-h-56 overflow-y-auto divide-y divide-dark-700">
{sentAccounts.map((a) => ( {sentAccounts.map((a) => (
<div key={a.id} className="flex items-center gap-2 text-xs text-gray-300 min-w-0"> <div key={a.id} className="flex items-center gap-3 px-4 py-2 hover:bg-dark-700/50 transition-colors">
{a.avatar_url && <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />} {a.avatar_url ? (
<span className="truncate">{a.login}</span> <img src={a.avatar_url} className="avatar-sm shrink-0" alt="" />
<span className="text-gray-500 truncate">{a.steam_id || ""}</span> ) : (
<span className={a.mafile_path ? "text-green-400" : "text-gray-500"}> <div className="w-6 h-6 rounded bg-dark-600 shrink-0" />
{a.mafile_path ? "✓ mafile" : "—"} )}
<span className="text-sm truncate flex-1 min-w-0">{a.login}</span>
{a.steam_id && <span className="text-xs text-gray-500 font-mono hidden sm:block">{a.steam_id}</span>}
<span className={`text-xs px-1.5 py-0.5 rounded ${a.mafile_path ? "bg-green-500/10 text-green-400" : "bg-dark-600 text-gray-500"}`}>
{a.mafile_path ? "mafile" : "—"}
</span> </span>
</div> </div>
))} ))}
@@ -57,11 +81,14 @@ export function MafileTab() {
</div> </div>
)} )}
{/* Export settings — Bug #6: flexible export */} {/* Export settings */}
{sentAccounts.length > 0 && (
<ExportSettings <ExportSettings
accountIds={[...mafileManagerIds]} accountIds={[...mafileManagerIds]}
onExport={handleExport} onExport={handleExport}
/> />
)}
</div>
</div> </div>
); );
} }
+42
View File
@@ -76,6 +76,8 @@ const translations: Translations = {
"action.enableAutoAccept": "Вкл. автопринятие", "action.enableAutoAccept": "Вкл. автопринятие",
"action.disableAutoAccept": "Выкл. авто-принятие входа", "action.disableAutoAccept": "Выкл. авто-принятие входа",
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа", "action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
"action.generate2fa": "Сгенерировать 2FA код",
"action.confirmations": "Подтверждения",
// -- Action prompts -- // -- Action prompts --
"prompt.newPassword": "Введите новый пароль", "prompt.newPassword": "Введите новый пароль",
"prompt.newEmail": "Введите новый email", "prompt.newEmail": "Введите новый email",
@@ -99,6 +101,15 @@ const translations: Translations = {
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?", "confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?", "confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
"confirm.yes": "Да", "confirm.yes": "Да",
"confirm.title": "Подтверждения",
"confirm.loading": "Загрузка подтверждений...",
"confirm.empty": "Нет ожидающих подтверждений",
"confirm.acceptAll": "Принять все",
"confirm.denyAll": "Отклонить все",
"confirm.refresh": "Обновить",
"confirm.accepted": "Принято",
"confirm.denied": "Отклонено",
"confirm.respondFailed": "Не удалось обработать подтверждение",
// -- Buttons -- // -- Buttons --
"btn.import": "Импорт", "btn.import": "Импорт",
"btn.add": "Добавить", "btn.add": "Добавить",
@@ -196,6 +207,7 @@ const translations: Translations = {
"ph.notes": "Заметки", "ph.notes": "Заметки",
"ph.notesOptional": "Заметки (необязательно)", "ph.notesOptional": "Заметки (необязательно)",
"ph.loginOptional": "Логин (необязательно)", "ph.loginOptional": "Логин (необязательно)",
"ph.emailPassword": "Пароль от email",
// -- Empty states -- // -- Empty states --
"empty.accounts": "Нет аккаунтов. Импортируйте файл", "empty.accounts": "Нет аккаунтов. Импортируйте файл",
"empty.logpass": "Аккаунтов нет. Импортируйте файл.", "empty.logpass": "Аккаунтов нет. Импортируйте файл.",
@@ -216,6 +228,7 @@ const translations: Translations = {
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора", "import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора", "import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора", "import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
"import.attachMafile": "Прикрепить .mafile (необязательно)",
"import.selected": "Выбран: {name}", "import.selected": "Выбран: {name}",
"import.uploading": "Загрузка...", "import.uploading": "Загрузка...",
"import.importBtn": "Импортировать", "import.importBtn": "Импортировать",
@@ -279,6 +292,14 @@ const translations: Translations = {
"mafile.sentAccounts": "Отправленные аккаунты", "mafile.sentAccounts": "Отправленные аккаунты",
"mafile.clearBtn": "Очистить", "mafile.clearBtn": "Очистить",
"mafile.insert": "Вставить {value}", "mafile.insert": "Вставить {value}",
"mafile.emptyTitle": "Нет выбранных аккаунтов",
"mafile.emptyHint": "Выберите аккаунты в таблице и отправьте сюда для экспорта mafile",
"mafile.accountsCount": "акк.",
"mafile.withMafile": "с mafile",
"mafile.namingSection": "Шаблоны имён",
"mafile.flat_mafiles": "Файлы",
"mafile.per_account_folder": "Папки",
"mafile.single_file": "Один файл",
// -- Settings drawer -- // -- Settings drawer --
"settings.display": "Отображение", "settings.display": "Отображение",
"settings.hidePasswords": "Скрывать пароли (спойлер)", "settings.hidePasswords": "Скрывать пароли (спойлер)",
@@ -350,6 +371,8 @@ const translations: Translations = {
"action.enableAutoAccept": "Enable auto-accept", "action.enableAutoAccept": "Enable auto-accept",
"action.disableAutoAccept": "Disable login auto-accept", "action.disableAutoAccept": "Disable login auto-accept",
"action.enableAutoAcceptLogin": "Enable login auto-accept", "action.enableAutoAcceptLogin": "Enable login auto-accept",
"action.generate2fa": "Generate 2FA code",
"action.confirmations": "Confirmations",
// -- Action prompts -- // -- Action prompts --
"prompt.newPassword": "Enter new password", "prompt.newPassword": "Enter new password",
"prompt.newEmail": "Enter new email", "prompt.newEmail": "Enter new email",
@@ -373,6 +396,15 @@ const translations: Translations = {
"confirm.clearProxies": "Clear proxies from all accounts?", "confirm.clearProxies": "Clear proxies from all accounts?",
"confirm.executeBulk": "Execute «{action}» for {count} accounts?", "confirm.executeBulk": "Execute «{action}» for {count} accounts?",
"confirm.yes": "Yes", "confirm.yes": "Yes",
"confirm.title": "Confirmations",
"confirm.loading": "Loading confirmations...",
"confirm.empty": "No pending confirmations",
"confirm.acceptAll": "Accept all",
"confirm.denyAll": "Deny all",
"confirm.refresh": "Refresh",
"confirm.accepted": "Accepted",
"confirm.denied": "Denied",
"confirm.respondFailed": "Failed to process confirmation",
// -- Buttons -- // -- Buttons --
"btn.import": "Import", "btn.import": "Import",
"btn.add": "Add", "btn.add": "Add",
@@ -470,6 +502,7 @@ const translations: Translations = {
"ph.notes": "Notes", "ph.notes": "Notes",
"ph.notesOptional": "Notes (optional)", "ph.notesOptional": "Notes (optional)",
"ph.loginOptional": "Login (optional)", "ph.loginOptional": "Login (optional)",
"ph.emailPassword": "Email password",
// -- Empty states -- // -- Empty states --
"empty.accounts": "No accounts. Import a file", "empty.accounts": "No accounts. Import a file",
"empty.logpass": "No accounts. Import a file.", "empty.logpass": "No accounts. Import a file.",
@@ -490,6 +523,7 @@ const translations: Translations = {
"import.dragTxt": "Drag a .txt file or click to select", "import.dragTxt": "Drag a .txt file or click to select",
"import.dragTxtCsv": "Drag a .txt / .csv 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.dragMafile": "Drag .mafile files or click to select",
"import.attachMafile": "Attach .mafile (optional)",
"import.selected": "Selected: {name}", "import.selected": "Selected: {name}",
"import.uploading": "Uploading...", "import.uploading": "Uploading...",
"import.importBtn": "Import", "import.importBtn": "Import",
@@ -553,6 +587,14 @@ const translations: Translations = {
"mafile.sentAccounts": "Sent Accounts", "mafile.sentAccounts": "Sent Accounts",
"mafile.clearBtn": "Clear", "mafile.clearBtn": "Clear",
"mafile.insert": "Insert {value}", "mafile.insert": "Insert {value}",
"mafile.emptyTitle": "No accounts selected",
"mafile.emptyHint": "Select accounts in the table and send them here for mafile export",
"mafile.accountsCount": "accs",
"mafile.withMafile": "with mafile",
"mafile.namingSection": "Naming templates",
"mafile.flat_mafiles": "Files",
"mafile.per_account_folder": "Folders",
"mafile.single_file": "Single file",
// -- Settings drawer -- // -- Settings drawer --
"settings.display": "Display", "settings.display": "Display",
"settings.hidePasswords": "Hide passwords (spoiler)", "settings.hidePasswords": "Hide passwords (spoiler)",
+1 -1
View File
@@ -11,7 +11,7 @@ mimetypes.add_type("text/css", ".css")
mimetypes.add_type("image/svg+xml", ".svg") mimetypes.add_type("image/svg+xml", ".svg")
from loguru import logger from loguru import logger
VERSION = "0.3.2" VERSION = "0.3.4"
from app.config import settings from app.config import settings
from app.core.logging import setup_logging from app.core.logging import setup_logging
+9 -4
View File
@@ -62,6 +62,7 @@ class Steam:
self._device_id = device_id self._device_id = device_id
self._requests = request_strategy if request_strategy is not None else BaseRequestStrategy() self._requests = request_strategy if request_strategy is not None else BaseRequestStrategy()
self._storage = cookie_storage if cookie_storage is not None else BaseCookieStorage() self._storage = cookie_storage if cookie_storage is not None else BaseCookieStorage()
self._refresh_token: Optional[str] = None
@property @property
def steamid(self) -> int: def steamid(self) -> int:
@@ -156,10 +157,10 @@ class Steam:
encrypted_password=encrypted_password, encrypted_password=encrypted_password,
encryption_timestamp=rsa_timestamp, encryption_timestamp=rsa_timestamp,
remember_login=True, remember_login=True,
platform_type=EAuthTokenPlatformType.k_EAuthTokenPlatformType_WebBrowser, platform_type=EAuthTokenPlatformType.k_EAuthTokenPlatformType_MobileApp,
website_id='Community', website_id='Mobile',
persistence=ESessionPersistence.k_ESessionPersistence_Persistent, persistence=ESessionPersistence.k_ESessionPersistence_Persistent,
device_friendly_name='Mozilla/5.0 (X11; Linux x86_64; rv:1.9.5.20) Gecko/2812-12-10 04:56:28 Firefox/3.8', device_friendly_name=self._device_id or 'android:unknown',
) )
response = await self._requests.bytes( response = await self._requests.bytes(
method='POST', method='POST',
@@ -328,7 +329,10 @@ class Steam:
) )
def _is_twofactor_required(self, confirmation: CAuthentication_AllowedConfirmation) -> bool: def _is_twofactor_required(self, confirmation: CAuthentication_AllowedConfirmation) -> bool:
return confirmation.confirmation_type == EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceCode return confirmation.confirmation_type in (
EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceCode,
EAuthSessionGuardType.k_EAuthSessionGuardType_DeviceConfirmation,
)
async def login_to_steam(self) -> None: async def login_to_steam(self) -> None:
if await self.is_authorized(): if await self.is_authorized():
@@ -358,6 +362,7 @@ class Steam:
client_id=auth_session.client_id, client_id=auth_session.client_id,
request_id=auth_session.request_id, request_id=auth_session.request_id,
) )
self._refresh_token = session.refresh_token
tokens = await self._finalize_login( tokens = await self._finalize_login(
refresh_token=session.refresh_token, refresh_token=session.refresh_token,
sessionid=self._requests.cookies()['sessionid'], sessionid=self._requests.cookies()['sessionid'],