mirror of
https://github.com/daimyomizukagebay61/SteamPanel.git
synced 2026-07-25 03:34:30 +00:00
v3.0.4
This commit is contained in:
+2
-1
@@ -33,4 +33,5 @@ Thumbs.db
|
||||
|
||||
# Misc
|
||||
example/
|
||||
*.log
|
||||
*.log
|
||||
*.mafile
|
||||
+36
-13
@@ -215,7 +215,9 @@ async def import_accounts(file: UploadFile = File(...)):
|
||||
Supported formats (: or | separator):
|
||||
login|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|notes (no mafile, with notes)
|
||||
"""
|
||||
content = (await file.read()).decode("utf-8", errors="ignore")
|
||||
result = BulkImportResult()
|
||||
@@ -226,11 +228,27 @@ async def import_accounts(file: UploadFile = File(...)):
|
||||
if not line:
|
||||
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("{")
|
||||
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("|:")
|
||||
suffix = line[json_end + 1 :].lstrip("|:")
|
||||
if suffix:
|
||||
notes = suffix
|
||||
else:
|
||||
mafile_str = None
|
||||
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_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]}")
|
||||
# No mafile — 4 fields, or 5 fields with notes
|
||||
if len(parts) == 5:
|
||||
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
|
||||
continue
|
||||
login, password, email, email_password = parts
|
||||
mafile_data = None
|
||||
|
||||
try:
|
||||
@@ -281,9 +302,9 @@ async def import_accounts(file: UploadFile = File(...)):
|
||||
continue
|
||||
|
||||
await db.execute(
|
||||
"""INSERT INTO accounts (login, password, email, email_password)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(login, password, email or None, email_password or None),
|
||||
"""INSERT INTO accounts (login, password, email, email_password, notes)
|
||||
VALUES (?, ?, ?, ?, ?)""",
|
||||
(login, password, email or None, email_password or None, notes or None),
|
||||
)
|
||||
result.imported += 1
|
||||
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
|
||||
|
||||
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")
|
||||
|
||||
await db.execute(
|
||||
@@ -325,12 +347,13 @@ async def upload_mafile(account_id: int, file: UploadFile = File(...)):
|
||||
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")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"Invalid mafile format: {exc}")
|
||||
|
||||
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"
|
||||
safe_name = Path(str(steam_id)).name
|
||||
mafile_path = settings.mafiles_dir / f"{safe_name}.mafile"
|
||||
mafile_path.write_text(content, encoding="utf-8")
|
||||
|
||||
await db.execute(
|
||||
|
||||
@@ -84,3 +84,54 @@ async def generate_2fa_by_account(request: Generate2FAByAccountRequest):
|
||||
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"])}
|
||||
|
||||
|
||||
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))
|
||||
|
||||
@@ -53,19 +53,23 @@ async def upload_mafiles(files: list[UploadFile] = File(...)):
|
||||
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"
|
||||
safe_name = Path(str(steam_id)).name
|
||||
dest = settings.mafiles_dir / f"{safe_name}.mafile"
|
||||
dest.write_text(content, encoding="utf-8")
|
||||
uploaded += 1
|
||||
|
||||
if mafile.account_name:
|
||||
mafile_steam_id = str(mafile.Session.SteamID) if mafile.Session.SteamID else None
|
||||
await db.execute(
|
||||
"""UPDATE accounts
|
||||
SET mafile_path = ?, shared_secret = ?, identity_secret = ?
|
||||
SET mafile_path = ?, shared_secret = ?, identity_secret = ?,
|
||||
steam_id = COALESCE(steam_id, ?)
|
||||
WHERE login = ?""",
|
||||
(
|
||||
str(dest),
|
||||
mafile.shared_secret or "",
|
||||
mafile.identity_secret or "",
|
||||
mafile_steam_id,
|
||||
mafile.account_name,
|
||||
),
|
||||
)
|
||||
|
||||
+9
-5
@@ -2,7 +2,7 @@ import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
@@ -90,14 +90,18 @@ class ProxyOut(BaseModel):
|
||||
|
||||
|
||||
class MafileSession(BaseModel):
|
||||
SessionID: str = ""
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
SessionID: str | None = ""
|
||||
AccessToken: str = ""
|
||||
RefreshToken: str = ""
|
||||
SteamID: int = 0
|
||||
SteamLoginSecure: str = ""
|
||||
SteamID: int | None = 0
|
||||
SteamLoginSecure: str | None = ""
|
||||
|
||||
|
||||
class MafileData(BaseModel):
|
||||
model_config = ConfigDict(extra="ignore")
|
||||
|
||||
shared_secret: str
|
||||
serial_number: str = ""
|
||||
revocation_code: str = ""
|
||||
@@ -107,7 +111,7 @@ class MafileData(BaseModel):
|
||||
identity_secret: str = ""
|
||||
secret_1: str = ""
|
||||
device_id: str = ""
|
||||
server_time: str = ""
|
||||
server_time: str | int = ""
|
||||
fully_enrolled: bool = False
|
||||
Session: MafileSession = Field(default_factory=MafileSession)
|
||||
|
||||
|
||||
@@ -34,7 +34,8 @@ def read_mafile_data(account: dict) -> dict | None:
|
||||
return None
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
@@ -80,7 +81,8 @@ async def create_steam_session(account: dict):
|
||||
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
|
||||
steamid_raw = (mafile.get("Session") or {}).get("SteamID") or 0
|
||||
steamid = int(steamid_raw) or None
|
||||
|
||||
steam = Steam(
|
||||
login=account["login"],
|
||||
@@ -155,6 +157,61 @@ def extract_session_cookies(steam) -> str | None:
|
||||
except Exception:
|
||||
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:
|
||||
"""Check if Steam session cookies are still valid.
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -165,6 +165,10 @@ async def validate_account(account: dict, params: dict, task_id: str = "") -> No
|
||||
except Exception as 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:
|
||||
status = "invalid"
|
||||
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
@@ -5,8 +5,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">
|
||||
<script type="module" crossorigin src="/assets/index-D5Q9Ekc6.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-aSRiX3qP.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -60,6 +60,13 @@ export const api = {
|
||||
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 }) }),
|
||||
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
|
||||
getProxies: () => request<Proxy[]>("/proxies"),
|
||||
|
||||
@@ -258,3 +258,16 @@ export interface Toast {
|
||||
type: ToastType;
|
||||
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 { useUiStore } from "@/stores/uiStore";
|
||||
import { IconZap } from "@/components/shared/Icons";
|
||||
import { copyText } from "@/lib/clipboard";
|
||||
import { useT } from "@/lib/i18n";
|
||||
import { ConfirmationsModal } from "./ConfirmationsModal";
|
||||
|
||||
const ROW_ACTION_KEYS = [
|
||||
{ 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 [paramValue, setParamValue] = useState("");
|
||||
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 menuRef = useRef<HTMLDivElement>(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 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 () => {
|
||||
setOpen(false);
|
||||
try {
|
||||
@@ -159,6 +182,21 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
);
|
||||
})}
|
||||
<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">
|
||||
{t("proxy.removeProxy")}
|
||||
</button>
|
||||
@@ -214,6 +252,10 @@ export function ActionMenu({ account, onAction, onToggleAutoAccept }: Props) {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConfirmations && (
|
||||
<ConfirmationsModal account={account} onClose={() => setShowConfirmations(false)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { useState } from "react";
|
||||
import { useState, useRef } from "react";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
@@ -9,19 +13,52 @@ interface Props {
|
||||
|
||||
export function AddModal({ onSave, onClose }: Props) {
|
||||
const t = useT();
|
||||
const addToast = useUiStore((s) => s.addToast);
|
||||
const loadAccounts = useAccountStore((s) => s.loadAccounts);
|
||||
const [login, setLogin] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [email, setEmail] = useState("");
|
||||
const [emailPassword, setEmailPassword] = 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;
|
||||
onSave({
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
proxy: proxy || undefined,
|
||||
});
|
||||
setSaving(true);
|
||||
try {
|
||||
onSave({
|
||||
login,
|
||||
password,
|
||||
email: email || undefined,
|
||||
email_password: emailPassword || 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 (
|
||||
@@ -29,11 +66,55 @@ export function AddModal({ onSave, onClose }: Props) {
|
||||
<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
|
||||
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={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 className="grid grid-cols-2 gap-2">
|
||||
<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" />
|
||||
<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>
|
||||
|
||||
@@ -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-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{"}"}: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:notes</p>
|
||||
</div>
|
||||
<div
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
|
||||
@@ -18,6 +18,12 @@ const NAME_VARS = [
|
||||
{ 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 {
|
||||
accountIds: number[];
|
||||
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 [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);
|
||||
@@ -68,203 +72,222 @@ export function ExportSettings({ accountIds, onExport }: Props) {
|
||||
};
|
||||
|
||||
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 className="bg-dark-800 border border-dark-600 rounded-xl overflow-hidden">
|
||||
{/* 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>
|
||||
|
||||
{/* 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 className="p-4 space-y-4">
|
||||
{/* Format selector — card-style buttons */}
|
||||
<div>
|
||||
<p className="text-xs text-gray-400 mb-2">{t("mafile.exportFormat")}</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FORMAT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.value}
|
||||
onClick={() => {
|
||||
setFormat(opt.value);
|
||||
if (opt.value === "single_file") {
|
||||
setIncludeGlobalTxt(false);
|
||||
setSkipFolders(false);
|
||||
}
|
||||
}}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<span className="text-base">{opt.icon}</span>
|
||||
<span>{t(`mafile.${opt.value}` as any)}</span>
|
||||
</button>
|
||||
))}
|
||||
</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>
|
||||
|
||||
{/* Naming templates */}
|
||||
{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)}
|
||||
<details className="group" open>
|
||||
<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.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" && (
|
||||
<TemplateInput
|
||||
label={t("mafile.folderName")}
|
||||
value={folderNameTemplate}
|
||||
onChange={setFolderNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{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)}
|
||||
)}
|
||||
<TemplateInput
|
||||
label={t("mafile.mafileName")}
|
||||
value={mafileNameTemplate}
|
||||
onChange={setMafileNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{t("mafile.skipFolders")}
|
||||
</label>
|
||||
</>
|
||||
</div>
|
||||
</details>
|
||||
)}
|
||||
|
||||
{(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
|
||||
{/* .txt options */}
|
||||
<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" />
|
||||
<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" && (
|
||||
<>
|
||||
<ToggleOption checked={includeGlobalTxt} onChange={(v) => { setIncludeGlobalTxt(v); if (!v) setSkipFolders(false); }}>
|
||||
{t("mafile.addGlobalTxt")} <code className="text-accent text-xs">accounts.txt</code>
|
||||
</ToggleOption>
|
||||
|
||||
{format === "per_account_folder" && (
|
||||
<ToggleOption checked={includeTxtPerFolder} onChange={setIncludeTxtPerFolder}>
|
||||
{t("mafile.addTxtPerFolder")}
|
||||
</ToggleOption>
|
||||
)}
|
||||
|
||||
<ToggleOption checked={skipFolders} onChange={setSkipFolders} disabled={!includeGlobalTxt}>
|
||||
{t("mafile.skipFolders")}
|
||||
</ToggleOption>
|
||||
</>
|
||||
)}
|
||||
|
||||
{(includeGlobalTxt || includeTxtPerFolder || format === "single_file") && (
|
||||
<div className="space-y-3 pt-1">
|
||||
{format === "per_account_folder" && includeTxtPerFolder && (
|
||||
<TemplateInput
|
||||
label={t("mafile.txtFolderName")}
|
||||
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"
|
||||
onChange={setTxtNameTemplate}
|
||||
vars={NAME_VARS}
|
||||
insertVar={insertVar}
|
||||
/>
|
||||
{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>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<label className="text-xs text-gray-400 block mb-1">
|
||||
{t("mafile.txtLineFormat")}
|
||||
{/* 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>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
key={v.value}
|
||||
onClick={() => insertVar((fn) => onChange(fn(value)), v.value)}
|
||||
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}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToggleOption({ checked, onChange, disabled, children }: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
disabled?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
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`}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={checked}
|
||||
disabled={disabled}
|
||||
onChange={(e) => onChange(e.target.checked)}
|
||||
className="accent-accent"
|
||||
/>
|
||||
<span className="text-xs">{children}</span>
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import type { MafileExportRequest } from "@/api/types";
|
||||
import { ExportSettings } from "./ExportSettings";
|
||||
import { useT } from "@/lib/i18n";
|
||||
|
||||
import { IconClipboard } from "@/components/shared/Icons";
|
||||
import { IconClipboard, IconPackage } from "@/components/shared/Icons";
|
||||
|
||||
export function MafileTab() {
|
||||
const t = useT();
|
||||
@@ -13,7 +13,6 @@ export function MafileTab() {
|
||||
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) => {
|
||||
@@ -31,37 +30,65 @@ export function MafileTab() {
|
||||
}
|
||||
};
|
||||
|
||||
const withMafile = sentAccounts.filter((a) => a.mafile_path).length;
|
||||
|
||||
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 className="h-full overflow-y-auto pr-1">
|
||||
<div className="max-w-2xl mx-auto space-y-5 py-2">
|
||||
|
||||
{/* Empty state */}
|
||||
{sentAccounts.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-16 text-center">
|
||||
<div className="w-14 h-14 rounded-2xl bg-dark-700 flex items-center justify-center mb-4">
|
||||
<IconPackage size={24} className="text-gray-500" />
|
||||
</div>
|
||||
<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>
|
||||
<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" : "—"}
|
||||
)}
|
||||
|
||||
{/* 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) => (
|
||||
<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="" />
|
||||
) : (
|
||||
<div className="w-6 h-6 rounded bg-dark-600 shrink-0" />
|
||||
)}
|
||||
<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>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
|
||||
{/* Export settings — Bug #6: flexible export */}
|
||||
<ExportSettings
|
||||
accountIds={[...mafileManagerIds]}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
{/* Export settings */}
|
||||
{sentAccounts.length > 0 && (
|
||||
<ExportSettings
|
||||
accountIds={[...mafileManagerIds]}
|
||||
onExport={handleExport}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,8 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Вкл. автопринятие",
|
||||
"action.disableAutoAccept": "Выкл. авто-принятие входа",
|
||||
"action.enableAutoAcceptLogin": "Вкл. авто-принятие входа",
|
||||
"action.generate2fa": "Сгенерировать 2FA код",
|
||||
"action.confirmations": "Подтверждения",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Введите новый пароль",
|
||||
"prompt.newEmail": "Введите новый email",
|
||||
@@ -99,6 +101,15 @@ const translations: Translations = {
|
||||
"confirm.clearProxies": "Очистить прокси у всех аккаунтов?",
|
||||
"confirm.executeBulk": "Выполнить «{action}» для {count} аккаунтов?",
|
||||
"confirm.yes": "Да",
|
||||
"confirm.title": "Подтверждения",
|
||||
"confirm.loading": "Загрузка подтверждений...",
|
||||
"confirm.empty": "Нет ожидающих подтверждений",
|
||||
"confirm.acceptAll": "Принять все",
|
||||
"confirm.denyAll": "Отклонить все",
|
||||
"confirm.refresh": "Обновить",
|
||||
"confirm.accepted": "Принято",
|
||||
"confirm.denied": "Отклонено",
|
||||
"confirm.respondFailed": "Не удалось обработать подтверждение",
|
||||
// -- Buttons --
|
||||
"btn.import": "Импорт",
|
||||
"btn.add": "Добавить",
|
||||
@@ -196,6 +207,7 @@ const translations: Translations = {
|
||||
"ph.notes": "Заметки",
|
||||
"ph.notesOptional": "Заметки (необязательно)",
|
||||
"ph.loginOptional": "Логин (необязательно)",
|
||||
"ph.emailPassword": "Пароль от email",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "Нет аккаунтов. Импортируйте файл",
|
||||
"empty.logpass": "Аккаунтов нет. Импортируйте файл.",
|
||||
@@ -216,6 +228,7 @@ const translations: Translations = {
|
||||
"import.dragTxt": "Перетащите .txt файл или нажмите для выбора",
|
||||
"import.dragTxtCsv": "Перетащите .txt / .csv файл или нажмите для выбора",
|
||||
"import.dragMafile": "Перетащите .mafile файлы или нажмите для выбора",
|
||||
"import.attachMafile": "Прикрепить .mafile (необязательно)",
|
||||
"import.selected": "Выбран: {name}",
|
||||
"import.uploading": "Загрузка...",
|
||||
"import.importBtn": "Импортировать",
|
||||
@@ -279,6 +292,14 @@ const translations: Translations = {
|
||||
"mafile.sentAccounts": "Отправленные аккаунты",
|
||||
"mafile.clearBtn": "Очистить",
|
||||
"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.display": "Отображение",
|
||||
"settings.hidePasswords": "Скрывать пароли (спойлер)",
|
||||
@@ -350,6 +371,8 @@ const translations: Translations = {
|
||||
"action.enableAutoAccept": "Enable auto-accept",
|
||||
"action.disableAutoAccept": "Disable login auto-accept",
|
||||
"action.enableAutoAcceptLogin": "Enable login auto-accept",
|
||||
"action.generate2fa": "Generate 2FA code",
|
||||
"action.confirmations": "Confirmations",
|
||||
// -- Action prompts --
|
||||
"prompt.newPassword": "Enter new password",
|
||||
"prompt.newEmail": "Enter new email",
|
||||
@@ -373,6 +396,15 @@ const translations: Translations = {
|
||||
"confirm.clearProxies": "Clear proxies from all accounts?",
|
||||
"confirm.executeBulk": "Execute «{action}» for {count} accounts?",
|
||||
"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 --
|
||||
"btn.import": "Import",
|
||||
"btn.add": "Add",
|
||||
@@ -470,6 +502,7 @@ const translations: Translations = {
|
||||
"ph.notes": "Notes",
|
||||
"ph.notesOptional": "Notes (optional)",
|
||||
"ph.loginOptional": "Login (optional)",
|
||||
"ph.emailPassword": "Email password",
|
||||
// -- Empty states --
|
||||
"empty.accounts": "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.dragTxtCsv": "Drag a .txt / .csv file or click to select",
|
||||
"import.dragMafile": "Drag .mafile files or click to select",
|
||||
"import.attachMafile": "Attach .mafile (optional)",
|
||||
"import.selected": "Selected: {name}",
|
||||
"import.uploading": "Uploading...",
|
||||
"import.importBtn": "Import",
|
||||
@@ -553,6 +587,14 @@ const translations: Translations = {
|
||||
"mafile.sentAccounts": "Sent Accounts",
|
||||
"mafile.clearBtn": "Clear",
|
||||
"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.display": "Display",
|
||||
"settings.hidePasswords": "Hide passwords (spoiler)",
|
||||
|
||||
@@ -11,7 +11,7 @@ mimetypes.add_type("text/css", ".css")
|
||||
mimetypes.add_type("image/svg+xml", ".svg")
|
||||
from loguru import logger
|
||||
|
||||
VERSION = "0.3.2"
|
||||
VERSION = "0.3.4"
|
||||
|
||||
from app.config import settings
|
||||
from app.core.logging import setup_logging
|
||||
|
||||
@@ -62,6 +62,7 @@ class Steam:
|
||||
self._device_id = device_id
|
||||
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._refresh_token: Optional[str] = None
|
||||
|
||||
@property
|
||||
def steamid(self) -> int:
|
||||
@@ -156,10 +157,10 @@ class Steam:
|
||||
encrypted_password=encrypted_password,
|
||||
encryption_timestamp=rsa_timestamp,
|
||||
remember_login=True,
|
||||
platform_type=EAuthTokenPlatformType.k_EAuthTokenPlatformType_WebBrowser,
|
||||
website_id='Community',
|
||||
platform_type=EAuthTokenPlatformType.k_EAuthTokenPlatformType_MobileApp,
|
||||
website_id='Mobile',
|
||||
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(
|
||||
method='POST',
|
||||
@@ -328,7 +329,10 @@ class Steam:
|
||||
)
|
||||
|
||||
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:
|
||||
if await self.is_authorized():
|
||||
@@ -358,6 +362,7 @@ class Steam:
|
||||
client_id=auth_session.client_id,
|
||||
request_id=auth_session.request_id,
|
||||
)
|
||||
self._refresh_token = session.refresh_token
|
||||
tokens = await self._finalize_login(
|
||||
refresh_token=session.refresh_token,
|
||||
sessionid=self._requests.cookies()['sessionid'],
|
||||
|
||||
Reference in New Issue
Block a user