diff --git a/bot/app/web/admin_api.py b/bot/app/web/admin_api.py new file mode 100644 index 0000000..a2501f6 --- /dev/null +++ b/bot/app/web/admin_api.py @@ -0,0 +1,1267 @@ +"""HTTP API powering the admin section of the subscription Mini App. + +All routes require an authenticated webapp session (cookie or Bearer +token) AND the resolved Telegram user id must appear in +``settings.ADMIN_IDS``. Authorization is enforced via the +``_require_admin_user_id`` helper, never trusted from the client. +""" + +from __future__ import annotations + +import csv +import io +import json +import logging +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any, Dict, List, Optional + +from aiogram import Bot +from aiohttp import web +from pydantic import ValidationError +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.app.web.admin_settings_manifest import ( + SETTINGS_MANIFEST, + coerce_value, + get_field_by_key, + manifest_payload, +) +from bot.services.settings_override_service import ( + current_value, + update_overrides, +) +from bot.utils import MessageContent, send_message_via_queue +from bot.utils.message_queue import get_queue_manager +from config.settings import Settings +from config.tariffs_config import TariffsConfig +from db.dal import ( + ad_dal, + app_settings_dal, + message_log_dal, + panel_sync_dal, + payment_dal, + promo_code_dal, + subscription_dal, + user_dal, +) +from db.models import ( + AdCampaign, + MessageLog, + Payment, + PromoCode, + Subscription, + User, + UserTelegramAvatar, +) + +logger = logging.getLogger(__name__) + + +# ─── Auth ────────────────────────────────────────────────────────── + + +def _require_admin_user_id(request: web.Request) -> int: + """Return the authenticated user id, or raise 401/403 for non-admins.""" + + from bot.app.web.subscription_webapp import _extract_authenticated_user_id + + settings: Settings = request.app["settings"] + user_id = _extract_authenticated_user_id(request) + if not user_id: + raise web.HTTPUnauthorized( + text=json.dumps({"ok": False, "error": "unauthorized"}), + content_type="application/json", + ) + + admin_ids = settings.ADMIN_IDS or [] + db_user_telegram_id = request.get("admin_telegram_id") + if db_user_telegram_id is None: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "forbidden"}), + content_type="application/json", + ) + + if int(db_user_telegram_id) not in {int(x) for x in admin_ids}: + raise web.HTTPForbidden( + text=json.dumps({"ok": False, "error": "forbidden"}), + content_type="application/json", + ) + return int(user_id) + + +@web.middleware +async def admin_auth_middleware(request: web.Request, handler): + """Resolve the Telegram id of the current user and stash it on the request. + + Doing this once per request lets every admin route call + ``_require_admin_user_id`` without re-querying the DB. + """ + + if not request.path.startswith("/api/admin"): + return await handler(request) + + from bot.app.web.subscription_webapp import _extract_authenticated_user_id + + user_id = _extract_authenticated_user_id(request) + if user_id: + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + db_user = await user_dal.get_user_by_id(session, user_id) + if db_user and db_user.telegram_id: + request["admin_telegram_id"] = int(db_user.telegram_id) + elif db_user: + # No telegram_id yet (email-only user) — can't be an admin + request["admin_telegram_id"] = None + + return await handler(request) + + +# ─── Helpers ─────────────────────────────────────────────────────── + + +def _ok(payload: Dict[str, Any], **extra) -> web.Response: + body = {"ok": True, **payload, **extra} + return web.json_response(body) + + +def _error(status: int, code: str, message: str = "") -> web.Response: + return web.json_response( + {"ok": False, "error": code, "message": message or code}, + status=status, + ) + + +async def _read_json(request: web.Request) -> Dict[str, Any]: + try: + data = await request.json() + return data if isinstance(data, dict) else {} + except Exception: + return {} + + +def _serialize_user(user: User) -> Dict[str, Any]: + return { + "user_id": int(user.user_id), + "telegram_id": int(user.telegram_id) if user.telegram_id else None, + "telegram_photo_url": user.telegram_photo_url, + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "email": user.email, + "language_code": user.language_code, + "is_banned": bool(user.is_banned), + "registration_date": user.registration_date.isoformat() if user.registration_date else None, + "panel_user_uuid": user.panel_user_uuid, + "referral_code": user.referral_code, + "referred_by_id": int(user.referred_by_id) if user.referred_by_id else None, + } + + +def _serialize_subscription(sub: Subscription) -> Dict[str, Any]: + return { + "subscription_id": int(sub.subscription_id), + "panel_user_uuid": sub.panel_user_uuid, + "panel_subscription_uuid": sub.panel_subscription_uuid, + "start_date": sub.start_date.isoformat() if sub.start_date else None, + "end_date": sub.end_date.isoformat() if sub.end_date else None, + "duration_months": sub.duration_months, + "is_active": bool(sub.is_active), + "status_from_panel": sub.status_from_panel, + "traffic_limit_bytes": sub.traffic_limit_bytes, + "traffic_used_bytes": sub.traffic_used_bytes, + "tariff_key": sub.tariff_key, + "auto_renew_enabled": bool(sub.auto_renew_enabled), + "provider": sub.provider, + } + + +def _serialize_payment(payment: Payment) -> Dict[str, Any]: + user_label = None + if payment.user: + user_label = payment.user.username or payment.user.first_name or str(payment.user_id) + return { + "payment_id": int(payment.payment_id), + "user_id": int(payment.user_id), + "user_label": user_label, + "provider": payment.provider, + "provider_payment_id": payment.provider_payment_id, + "amount": float(payment.amount), + "currency": payment.currency, + "status": payment.status, + "description": payment.description, + "subscription_duration_months": payment.subscription_duration_months, + "sale_mode": payment.sale_mode, + "tariff_key": payment.tariff_key, + "purchased_gb": payment.purchased_gb, + "purchased_hwid_devices": payment.purchased_hwid_devices, + "created_at": payment.created_at.isoformat() if payment.created_at else None, + } + + +def _serialize_promo(promo: PromoCode) -> Dict[str, Any]: + return { + "id": int(promo.promo_code_id), + "code": promo.code, + "bonus_days": int(promo.bonus_days), + "max_activations": int(promo.max_activations), + "current_activations": int(promo.current_activations or 0), + "is_active": bool(promo.is_active), + "valid_until": promo.valid_until.isoformat() if promo.valid_until else None, + "created_at": promo.created_at.isoformat() if promo.created_at else None, + "created_by_admin_id": int(promo.created_by_admin_id) if promo.created_by_admin_id else None, + } + + +def _serialize_ad(campaign: AdCampaign, totals: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return { + "id": int(campaign.ad_campaign_id), + "source": campaign.source, + "start_param": campaign.start_param, + "cost": float(campaign.cost or 0), + "is_active": bool(campaign.is_active), + "created_at": campaign.created_at.isoformat() if campaign.created_at else None, + "stats": totals or {}, + } + + +def _serialize_log(entry: MessageLog) -> Dict[str, Any]: + return { + "log_id": int(entry.log_id), + "user_id": int(entry.user_id) if entry.user_id else None, + "telegram_username": entry.telegram_username, + "telegram_first_name": entry.telegram_first_name, + "event_type": entry.event_type, + "content": entry.content, + "is_admin_event": bool(entry.is_admin_event), + "target_user_id": int(entry.target_user_id) if entry.target_user_id else None, + "timestamp": entry.timestamp.isoformat() if entry.timestamp else None, + } + + +def _tariffs_config_path(settings: Settings) -> Path: + return Path(settings.TARIFFS_CONFIG_PATH).expanduser() + + +def _tariffs_config_payload(config: TariffsConfig) -> Dict[str, Any]: + return config.model_dump(mode="json", exclude_none=True) + + +def _write_tariffs_config_file(path: Path, config: TariffsConfig) -> None: + data = _tariffs_config_payload(config) + path.parent.mkdir(parents=True, exist_ok=True) + tmp_path = path.with_suffix(f"{path.suffix}.tmp") + tmp_path.write_text( + json.dumps(data, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + tmp_path.replace(path) + + +# ─── Routes ──────────────────────────────────────────────────────── + + +async def admin_me_route(request: web.Request) -> web.Response: + user_id = _require_admin_user_id(request) + settings: Settings = request.app["settings"] + return _ok({}, user_id=user_id, admin_ids=list(settings.ADMIN_IDS or [])) + + +async def admin_stats_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + user_stats = await user_dal.get_enhanced_user_statistics(session) + financial_stats = await payment_dal.get_financial_statistics(session) + sync_status = await panel_sync_dal.get_panel_sync_status(session) + recent_payments = await payment_dal.get_recent_payment_logs_with_user(session, limit=10) + + payload = { + "users": user_stats, + "financial": financial_stats, + "panel_sync": { + "status": sync_status.status if sync_status else "never_run", + "last_sync_time": sync_status.last_sync_time.isoformat() if sync_status and sync_status.last_sync_time else None, + "details": sync_status.details if sync_status else None, + "users_processed": sync_status.users_processed_from_panel if sync_status else 0, + "subscriptions_synced": sync_status.subscriptions_synced if sync_status else 0, + }, + "recent_payments": [_serialize_payment(p) for p in recent_payments], + } + + panel_service = request.app.get("panel_service") + if panel_service is not None: + try: + system = await panel_service.get_system_stats() + bandwidth = await panel_service.get_bandwidth_stats() + payload["panel"] = {"system": system or {}, "bandwidth": bandwidth or {}} + except Exception as exc: + logger.debug("Panel stats unavailable: %s", exc) + payload["panel"] = {"error": "unavailable"} + + queue_manager = get_queue_manager() + if queue_manager: + try: + payload["queue"] = queue_manager.get_queue_stats() + except Exception: # pragma: no cover - defensive + payload["queue"] = None + + payload["currency_symbol"] = settings.DEFAULT_CURRENCY_SYMBOL or "RUB" + return _ok(payload) + + +# ─── Users ───────────────────────────────────────────────────────── + + +async def admin_users_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + query = (request.query.get("q") or "").strip() + only_banned = (request.query.get("filter") or "").lower() == "banned" + + async with async_session_factory() as session: + if query: + users = await _search_users(session, query, page=page, page_size=page_size) + total = len(users) # cheap upper bound; UI shows hasMore via len==page_size + elif only_banned: + banned = await user_dal.get_banned_users(session) + total = len(banned) + start = page * page_size + users = banned[start : start + page_size] + else: + users = await user_dal.get_all_users_paginated(session, page=page, page_size=page_size) + total = await user_dal.count_all_users(session) + + statuses = await _bulk_user_statuses(session, [u.user_id for u in users]) + cached_avatar_ids = await _bulk_user_avatar_keys(session, [u.user_id for u in users]) + + serialized = [] + for user in users: + payload = _serialize_user(user) + payload["panel_status"] = statuses.get(user.user_id) + payload["avatar_url"] = ( + f"/api/admin/users/{user.user_id}/avatar?v={cached_avatar_ids[user.user_id]}" + if user.user_id in cached_avatar_ids + else None + ) + serialized.append(payload) + + return _ok( + { + "users": serialized, + "page": page, + "page_size": page_size, + "total": total, + } + ) + + +async def _bulk_user_statuses( + session: AsyncSession, user_ids: List[int] +) -> Dict[int, Optional[str]]: + """Return active subscription status for a batch of users. + + Returns the panel status (active/expired/limited/disabled) when an active + subscription exists, otherwise ``"bot_only"`` when the user is in the bot + but has no panel subscription. + """ + + if not user_ids: + return {} + stmt = ( + select(Subscription.user_id, Subscription.status_from_panel, Subscription.is_active, Subscription.end_date) + .where(Subscription.user_id.in_(user_ids)) + .order_by(Subscription.is_active.desc(), Subscription.end_date.desc().nullslast()) + ) + rows = (await session.execute(stmt)).all() + out: Dict[int, str] = {} + for uid, panel_status, is_active, _end in rows: + if uid in out: + continue + if is_active: + status = (panel_status or "active").lower() + else: + status = (panel_status or "expired").lower() + out[uid] = status + for uid in user_ids: + if uid not in out: + out[uid] = "bot_only" + return out + + +async def _bulk_user_avatar_keys( + session: AsyncSession, user_ids: List[int] +) -> Dict[int, str]: + """Return ``{user_id: cache_key}`` for users with a cached Telegram avatar. + + The cache key is the row's ``updated_at`` timestamp — used as a + cache-buster query param so the browser refetches when the avatar + changes. + """ + + if not user_ids: + return {} + stmt = select(UserTelegramAvatar.user_id, UserTelegramAvatar.updated_at).where( + UserTelegramAvatar.user_id.in_(user_ids) + ) + rows = (await session.execute(stmt)).all() + return { + int(uid): (updated_at.isoformat() if updated_at else "") + for uid, updated_at in rows + } + + +async def admin_user_avatar_route(request: web.Request) -> web.Response: + """Serve the cached Telegram avatar for any user (admin-only). + + Mirrors ``/api/account/avatar`` but takes a ``user_id`` from the URL + and uses admin auth. Only the cached blob from + ``user_telegram_avatars`` is served — refreshing from Telegram is the + job of the user-facing endpoint, so the admin list never blocks on a + Telegram round-trip. + """ + + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + avatar = await session.get(UserTelegramAvatar, target_id) + + if not avatar: + raise web.HTTPNotFound(text="avatar_not_cached") + + etag = ( + f'W/"avatar-{target_id}-{int(avatar.updated_at.timestamp())}"' + if avatar.updated_at + else None + ) + if etag and request.headers.get("If-None-Match") == etag: + return web.Response(status=304, headers={"ETag": etag}) + + response = web.Response( + body=bytes(avatar.image_bytes), + content_type=avatar.content_type or "image/jpeg", + ) + response.headers["Cache-Control"] = "private, max-age=3600" + if etag: + response.headers["ETag"] = etag + return response + + +async def _search_users( + session: AsyncSession, query: str, *, page: int, page_size: int +) -> List[User]: + """Best-effort search that tries telegram id, username, email.""" + + candidates: List[User] = [] + seen: set = set() + + if query.isdigit(): + user = await user_dal.get_user_by_id(session, int(query)) + if user: + candidates.append(user) + seen.add(user.user_id) + user = await user_dal.get_user_by_telegram_id(session, int(query)) + if user and user.user_id not in seen: + candidates.append(user) + seen.add(user.user_id) + + if "@" in query and not query.startswith("@"): + user = await user_dal.get_user_by_email(session, query) + if user and user.user_id not in seen: + candidates.append(user) + seen.add(user.user_id) + + raw = query.lstrip("@") + user = await user_dal.get_user_by_username(session, raw) + if user and user.user_id not in seen: + candidates.append(user) + seen.add(user.user_id) + + if not candidates: + like = f"%{raw}%" + stmt = ( + select(User) + .where( + or_( + User.username.ilike(like), + User.first_name.ilike(like), + User.last_name.ilike(like), + User.email.ilike(like), + ) + ) + .order_by(User.registration_date.desc()) + .offset(page * page_size) + .limit(page_size) + ) + rows = (await session.execute(stmt)).scalars().all() + for row in rows: + if row.user_id not in seen: + candidates.append(row) + seen.add(row.user_id) + + return candidates + + +async def admin_user_detail_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found", "User not found") + + active_sub = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + latest_subs_stmt = ( + select(Subscription) + .where(Subscription.user_id == target_id) + .order_by(Subscription.start_date.desc().nullslast()) + .limit(20) + ) + latest_subs = (await session.execute(latest_subs_stmt)).scalars().all() + total_paid = await payment_dal.get_user_total_paid(session, target_id) + recent_payments_stmt = ( + select(Payment) + .where(Payment.user_id == target_id) + .order_by(Payment.created_at.desc()) + .limit(20) + ) + recent_payments = (await session.execute(recent_payments_stmt)).scalars().all() + log_count = await message_log_dal.count_user_message_logs(session, target_id) + avatar_keys = await _bulk_user_avatar_keys(session, [target_id]) + + serialized_user = _serialize_user(user) + serialized_user["avatar_url"] = ( + f"/api/admin/users/{target_id}/avatar?v={avatar_keys[target_id]}" + if target_id in avatar_keys + else None + ) + + return _ok( + { + "user": serialized_user, + "active_subscription": _serialize_subscription(active_sub) if active_sub else None, + "subscriptions": [_serialize_subscription(s) for s in (latest_subs or [])], + "total_paid": float(total_paid), + "recent_payments": [_serialize_payment(p) for p in recent_payments], + "log_count": int(log_count or 0), + } + ) + + +async def admin_user_ban_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + desired = bool(payload.get("banned")) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found") + user.is_banned = bool(desired) + await session.commit() + await session.refresh(user) + return _ok({"user": _serialize_user(user)}) + + +async def admin_user_message_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + text = str(payload.get("text") or "").strip() + if not text: + return _error(400, "empty_text") + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + target_user = await user_dal.get_user_by_id(session, target_id) + if not target_user or not target_user.telegram_id: + return _error(404, "no_telegram_account") + + try: + await send_message_via_queue( + queue_manager, + int(target_user.telegram_id), + MessageContent(content_type="text", text=text), + parse_mode="HTML", + disable_web_page_preview=True, + ) + except Exception as exc: + logger.warning("Admin direct message failed: %s", exc) + return _error(502, "send_failed", str(exc)) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_direct_message_webapp", + "content": text[:4000], + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + + return _ok({}) + + +async def admin_user_delete_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await user_dal.delete_user_and_relations(session, target_id) + if not ok: + await session.rollback() + return _error(404, "not_found") + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_delete_user_webapp", + "content": f"Deleted user_id={target_id}", + "is_admin_event": True, + }, + ) + await session.commit() + return _ok({}) + + +async def admin_user_reset_trial_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + panel_service = request.app.get("panel_service") + subscription_service = request.app.get("subscription_service") + if panel_service is None or subscription_service is None: + return _error(503, "service_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + user = await user_dal.get_user_by_id(session, target_id) + if not user: + return _error(404, "not_found") + + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if active: + await session.delete(active) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_reset_trial_webapp", + "content": f"Reset trial for user_id={target_id}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + return _ok({}) + + +async def admin_user_extend_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + target_id = int(request.match_info["user_id"]) + payload = await _read_json(request) + try: + days = int(payload.get("days") or 0) + except (TypeError, ValueError): + return _error(400, "invalid_days") + if days <= 0: + return _error(400, "invalid_days") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + active = await subscription_dal.get_active_subscription_by_user_id(session, target_id) + if not active: + return _error(404, "no_active_subscription") + + new_end = (active.end_date or datetime.now(timezone.utc)) + timedelta(days=days) + active.end_date = new_end + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_extend_subscription_webapp", + "content": f"+{days}d -> {new_end.isoformat()}", + "is_admin_event": True, + "target_user_id": target_id, + }, + ) + await session.commit() + await session.refresh(active) + return _ok({"subscription": _serialize_subscription(active)}) + + +# ─── Payments ────────────────────────────────────────────────────── + + +async def admin_payments_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + + async with async_session_factory() as session: + from sqlalchemy.orm import selectinload + + stmt = ( + select(Payment) + .options(selectinload(Payment.user)) + .order_by(Payment.created_at.desc()) + .offset(page * page_size) + .limit(page_size) + ) + rows = (await session.execute(stmt)).scalars().all() + total = await payment_dal.get_payments_count(session) + + return _ok( + { + "payments": [_serialize_payment(p) for p in rows], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) + + +async def admin_payments_export_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + from sqlalchemy.orm import selectinload + + stmt = ( + select(Payment) + .options(selectinload(Payment.user)) + .order_by(Payment.created_at.desc()) + .limit(10000) + ) + rows = (await session.execute(stmt)).scalars().all() + + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow( + [ + "payment_id", + "user_id", + "user_label", + "provider", + "provider_payment_id", + "amount", + "currency", + "status", + "description", + "duration_months", + "sale_mode", + "tariff_key", + "created_at", + ] + ) + for p in rows: + label = "" + if p.user: + label = p.user.username or p.user.first_name or "" + writer.writerow( + [ + p.payment_id, + p.user_id, + label, + p.provider, + p.provider_payment_id or "", + p.amount, + p.currency, + p.status, + p.description or "", + p.subscription_duration_months or "", + p.sale_mode or "", + p.tariff_key or "", + p.created_at.isoformat() if p.created_at else "", + ] + ) + + response = web.Response( + body=buffer.getvalue().encode("utf-8-sig"), + content_type="text/csv", + charset="utf-8", + ) + response.headers["Content-Disposition"] = 'attachment; filename="payments.csv"' + return response + + +# ─── Promo codes ─────────────────────────────────────────────────── + + +async def admin_promos_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(100, max(1, int(request.query.get("page_size", 25) or 25))) + async with async_session_factory() as session: + promos = await promo_code_dal.get_all_promo_codes_with_details( + session, limit=page_size, offset=page * page_size + ) + total = await promo_code_dal.get_promo_codes_count(session) + return _ok( + { + "promos": [_serialize_promo(p) for p in promos], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) + + +async def admin_promo_create_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + payload = await _read_json(request) + code = str(payload.get("code") or "").strip().upper() + bonus_days = int(payload.get("bonus_days") or 0) + max_activations = int(payload.get("max_activations") or 0) + valid_days = payload.get("valid_days") + if not code or bonus_days <= 0 or max_activations <= 0: + return _error(400, "invalid_payload") + + valid_until = None + if valid_days: + try: + valid_until = datetime.now(timezone.utc) + timedelta(days=int(valid_days)) + except (TypeError, ValueError): + return _error(400, "invalid_valid_days") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + existing = await promo_code_dal.get_promo_code_by_code(session, code) + if existing: + return _error(409, "duplicate_code") + promo = await promo_code_dal.create_promo_code( + session, + { + "code": code, + "bonus_days": bonus_days, + "max_activations": max_activations, + "valid_until": valid_until, + "created_by_admin_id": actor_id, + "is_active": True, + }, + ) + await session.commit() + return _ok({"promo": _serialize_promo(promo)}) + + +async def admin_promo_update_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + promo_id = int(request.match_info["promo_id"]) + payload = await _read_json(request) + update_data: Dict[str, Any] = {} + if "is_active" in payload: + update_data["is_active"] = bool(payload["is_active"]) + if "bonus_days" in payload and payload["bonus_days"] is not None: + update_data["bonus_days"] = int(payload["bonus_days"]) + if "max_activations" in payload and payload["max_activations"] is not None: + update_data["max_activations"] = int(payload["max_activations"]) + + if not update_data: + return _error(400, "no_changes") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + promo = await promo_code_dal.update_promo_code(session, promo_id, update_data) + if not promo: + return _error(404, "not_found") + await session.commit() + await session.refresh(promo) + return _ok({"promo": _serialize_promo(promo)}) + + +async def admin_promo_delete_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + promo_id = int(request.match_info["promo_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + promo = await promo_code_dal.delete_promo_code(session, promo_id) + if not promo: + return _error(404, "not_found") + await session.commit() + return _ok({}) + + +# ─── Logs ────────────────────────────────────────────────────────── + + +async def admin_logs_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + + page = max(0, int(request.query.get("page", 0) or 0)) + page_size = min(200, max(1, int(request.query.get("page_size", 50) or 50))) + user_filter = request.query.get("user_id") + + async with async_session_factory() as session: + if user_filter: + try: + user_id = int(user_filter) + except (TypeError, ValueError): + return _error(400, "invalid_user_id") + entries = await message_log_dal.get_user_message_logs( + session, user_id, page_size, page * page_size + ) + total = await message_log_dal.count_user_message_logs(session, user_id) + else: + entries = await message_log_dal.get_all_message_logs( + session, page_size, page * page_size + ) + total = await message_log_dal.count_all_message_logs(session) + + return _ok( + { + "logs": [_serialize_log(l) for l in entries], + "page": page, + "page_size": page_size, + "total": int(total or 0), + } + ) + + +# ─── Broadcast ───────────────────────────────────────────────────── + + +async def admin_broadcast_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + payload = await _read_json(request) + text = str(payload.get("text") or "").strip() + target = str(payload.get("target") or "all").strip().lower() + if not text: + return _error(400, "empty_text") + if target not in {"all", "active", "inactive"}: + target = "all" + + queue_manager = get_queue_manager() + if not queue_manager: + return _error(503, "queue_unavailable") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + if target == "active": + user_ids = await user_dal.get_user_ids_with_active_subscription(session) + elif target == "inactive": + user_ids = await user_dal.get_user_ids_without_active_subscription(session) + else: + user_ids = await user_dal.get_all_active_user_ids_for_broadcast(session) + + sent = 0 + failed = 0 + for uid in user_ids: + try: + await send_message_via_queue( + queue_manager, + int(uid), + MessageContent(content_type="text", text=text), + parse_mode="HTML", + disable_web_page_preview=True, + ) + sent += 1 + except Exception as exc: + failed += 1 + logger.debug("Broadcast queue failed for %s: %s", uid, exc) + + await message_log_dal.create_message_log( + session, + { + "user_id": actor_id, + "event_type": "admin_broadcast_webapp", + "content": f"target={target} sent={sent} failed={failed} text={text[:120]}", + "is_admin_event": True, + }, + ) + + return _ok({"queued": sent, "failed": failed, "target": target}) + + +# ─── Sync ────────────────────────────────────────────────────────── + + +async def admin_sync_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + panel_service = request.app.get("panel_service") + if panel_service is None: + return _error(503, "panel_unavailable") + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + i18n = request.app.get("i18n") + + from bot.handlers.admin.sync_admin import perform_sync + + async with async_session_factory() as session: + result = await perform_sync( + panel_service=panel_service, + session=session, + settings=settings, + i18n_instance=i18n, + ) + return _ok({"result": result or {}}) + + +# ─── Ad campaigns ────────────────────────────────────────────────── + + +async def admin_ads_list_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + campaigns = await ad_dal.list_campaigns(session) + totals = await ad_dal.get_totals(session) + results = [] + for campaign in campaigns: + try: + stats = await ad_dal.get_campaign_stats(session, campaign.ad_campaign_id) + except Exception: + stats = {} + results.append(_serialize_ad(campaign, stats)) + return _ok({"campaigns": results, "totals": totals}) + + +async def admin_ad_create_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + payload = await _read_json(request) + source = str(payload.get("source") or "").strip() + start_param = str(payload.get("start_param") or "").strip() + cost = float(payload.get("cost") or 0.0) + if not source or not start_param: + return _error(400, "invalid_payload") + + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + existing = await ad_dal.get_campaign_by_start_param(session, start_param) + if existing: + return _error(409, "duplicate_start_param") + campaign = await ad_dal.create_campaign( + session, + source=source, + start_param=start_param, + cost=cost, + ) + await session.commit() + await session.refresh(campaign) + return _ok({"campaign": _serialize_ad(campaign)}) + + +async def admin_ad_toggle_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + campaign_id = int(request.match_info["campaign_id"]) + payload = await _read_json(request) + is_active = bool(payload.get("is_active", True)) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await ad_dal.toggle_campaign_active(session, campaign_id, is_active) + if not ok: + return _error(404, "not_found") + await session.commit() + return _ok({}) + + +async def admin_ad_delete_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + campaign_id = int(request.match_info["campaign_id"]) + async_session_factory: sessionmaker = request.app["async_session_factory"] + async with async_session_factory() as session: + ok = await ad_dal.delete_campaign(session, campaign_id) + if not ok: + return _error(404, "not_found") + await session.commit() + return _ok({}) + + +# ─── Settings (manifest + overrides) ─────────────────────────────── + + +async def admin_settings_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + + async with async_session_factory() as session: + overrides = await app_settings_dal.get_overrides_with_meta(session) + + overrides_by_key = {entry["key"]: entry for entry in overrides} + + fields = manifest_payload() + sections: Dict[str, Dict[str, Any]] = {} + for field in fields: + key = field["key"] + section_id = field["section"] + if section_id not in sections: + sections[section_id] = { + "id": section_id, + "order": field["section_order"], + "fields": [], + } + override = overrides_by_key.get(key) + sections[section_id]["fields"].append( + { + **field, + "value": current_value(settings, key), + "overridden": bool(override), + "updated_at": override.get("updated_at") if override else None, + } + ) + + ordered_sections = sorted(sections.values(), key=lambda s: s["order"]) + return _ok({"sections": ordered_sections}) + + +async def admin_settings_patch_route(request: web.Request) -> web.Response: + actor_id = _require_admin_user_id(request) + settings: Settings = request.app["settings"] + async_session_factory: sessionmaker = request.app["async_session_factory"] + payload = await _read_json(request) + updates = payload.get("updates") or {} + deletes = payload.get("deletes") or [] + if not isinstance(updates, dict): + return _error(400, "invalid_updates") + if not isinstance(deletes, list): + return _error(400, "invalid_deletes") + + result = await update_overrides( + settings, + async_session_factory, + updates=updates, + deletes=deletes, + actor_id=actor_id, + ) + if not result.get("ok"): + return web.json_response( + {"ok": False, "error": "validation_failed", "errors": result.get("errors", {})}, + status=400, + ) + + # Bust the public webapp settings cache so users see new values immediately. + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok({"applied": result.get("applied", 0), "reverted": result.get("reverted", 0)}) + + +# ─── Tariffs catalog ──────────────────────────────────────────────── + + +async def admin_tariffs_get_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + path = _tariffs_config_path(settings) + + try: + config = settings.tariffs_config + except Exception as exc: + logger.warning("Invalid tariffs config requested from admin UI: %s", exc) + return _error(400, "invalid_tariffs_config", str(exc)) + + if config is None: + return _ok( + { + "exists": path.exists(), + "path": str(path), + "catalog": { + "default_tariff": "", + "topup_packages_default": {"rub": [], "stars": []}, + "tariffs": [], + }, + } + ) + + return _ok( + { + "exists": True, + "path": str(path), + "catalog": _tariffs_config_payload(config), + } + ) + + +async def admin_tariffs_save_route(request: web.Request) -> web.Response: + _require_admin_user_id(request) + settings: Settings = request.app["settings"] + payload = await _read_json(request) + catalog = payload.get("catalog") if "catalog" in payload else payload + if not isinstance(catalog, dict): + return _error(400, "invalid_payload", "catalog must be an object") + + try: + config = TariffsConfig.model_validate(catalog) + except (ValidationError, ValueError) as exc: + return _error(400, "invalid_tariffs_config", str(exc)) + + path = _tariffs_config_path(settings) + try: + _write_tariffs_config_file(path, config) + except OSError as exc: + logger.exception("Failed to write tariffs config to %s", path) + return _error(500, "write_failed", str(exc)) + + cache = request.app.get("webapp_settings_cache") + if isinstance(cache, dict): + cache["ts"] = 0.0 + cache["data"] = {} + + return _ok({"exists": True, "path": str(path), "catalog": _tariffs_config_payload(config)}) + + +# ─── Router setup ────────────────────────────────────────────────── + + +def setup_admin_routes(app: web.Application) -> None: + router = app.router + router.add_get("/api/admin/me", admin_me_route) + router.add_get("/api/admin/stats", admin_stats_route) + + router.add_get("/api/admin/users", admin_users_list_route) + router.add_get("/api/admin/users/{user_id:\\d+}", admin_user_detail_route) + router.add_get("/api/admin/users/{user_id:\\d+}/avatar", admin_user_avatar_route) + router.add_post("/api/admin/users/{user_id:\\d+}/ban", admin_user_ban_route) + router.add_post("/api/admin/users/{user_id:\\d+}/message", admin_user_message_route) + router.add_post("/api/admin/users/{user_id:\\d+}/reset-trial", admin_user_reset_trial_route) + router.add_post("/api/admin/users/{user_id:\\d+}/extend", admin_user_extend_route) + router.add_delete("/api/admin/users/{user_id:\\d+}", admin_user_delete_route) + + router.add_get("/api/admin/payments", admin_payments_list_route) + router.add_get("/api/admin/payments/export.csv", admin_payments_export_route) + + router.add_get("/api/admin/promos", admin_promos_list_route) + router.add_post("/api/admin/promos", admin_promo_create_route) + router.add_patch("/api/admin/promos/{promo_id:\\d+}", admin_promo_update_route) + router.add_delete("/api/admin/promos/{promo_id:\\d+}", admin_promo_delete_route) + + router.add_get("/api/admin/logs", admin_logs_route) + + router.add_post("/api/admin/broadcast", admin_broadcast_route) + router.add_post("/api/admin/sync", admin_sync_route) + + router.add_get("/api/admin/ads", admin_ads_list_route) + router.add_post("/api/admin/ads", admin_ad_create_route) + router.add_post("/api/admin/ads/{campaign_id:\\d+}/toggle", admin_ad_toggle_route) + router.add_delete("/api/admin/ads/{campaign_id:\\d+}", admin_ad_delete_route) + + router.add_get("/api/admin/settings", admin_settings_get_route) + router.add_patch("/api/admin/settings", admin_settings_patch_route) + + router.add_get("/api/admin/tariffs", admin_tariffs_get_route) + router.add_put("/api/admin/tariffs", admin_tariffs_save_route) diff --git a/bot/app/web/admin_settings_manifest.py b/bot/app/web/admin_settings_manifest.py new file mode 100644 index 0000000..700382a --- /dev/null +++ b/bot/app/web/admin_settings_manifest.py @@ -0,0 +1,261 @@ +"""Manifest of settings editable from the admin web app. + +Each entry describes a single overridable attribute on the global +``Settings`` instance. The manifest is the only contract between the +admin UI and the backend: keys not listed here cannot be changed via +the API, even by an admin. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, Callable, List, Optional, Tuple + + +@dataclass(frozen=True) +class SettingField: + key: str + type: str # "string" | "int" | "float" | "bool" | "text" | "url" | "color" | "secret" + section: str + label: str + description: str = "" + placeholder: str = "" + optional: bool = True + secret: bool = False + min: Optional[float] = None + max: Optional[float] = None + choices: Optional[Tuple[Tuple[str, str], ...]] = None + subsection: Optional[str] = None # group label inside a section + + +SETTINGS_MANIFEST: List[SettingField] = [ + # ─── General ──────────────────────────────────────────────────── + SettingField("DEFAULT_LANGUAGE", "string", "general", "Язык по умолчанию", "Используется для приветственных сообщений и публичных страниц."), + SettingField("DEFAULT_CURRENCY_SYMBOL", "string", "general", "Валюта", "Например, RUB, USD, EUR.", placeholder="RUB"), + SettingField("SUPPORT_LINK", "url", "general", "Ссылка поддержки", "Куда вести пользователей за помощью."), + SettingField("SERVER_STATUS_URL", "url", "general", "Ссылка на статус серверов"), + SettingField("TERMS_OF_SERVICE_URL", "url", "general", "Условия использования"), + SettingField("PRIVACY_POLICY_URL", "url", "general", "Политика конфиденциальности"), + SettingField("USER_AGREEMENT_URL", "url", "general", "Пользовательское соглашение"), + SettingField("DISABLE_WELCOME_MESSAGE", "bool", "general", "Скрыть приветствие /start"), + SettingField("START_COMMAND_DESCRIPTION", "string", "general", "Описание /start", placeholder=""), + SettingField("REQUIRED_CHANNEL_ID", "int", "general", "ID обязательного канала", "Telegram ID канала, в котором нужно состоять."), + SettingField("REQUIRED_CHANNEL_LINK", "string", "general", "Ссылка на канал", "Имя пользователя или invite-link."), + # ─── Web app appearance ──────────────────────────────────────── + SettingField("WEBAPP_TITLE", "string", "appearance", "Название Web App", placeholder="Моя подписка"), + SettingField("WEBAPP_PRIMARY_COLOR", "color", "appearance", "Основной цвет", placeholder="#00fe7a"), + SettingField("WEBAPP_LOGO_URL", "url", "appearance", "URL логотипа"), + SettingField("WEBAPP_LOGO_EMOJI", "string", "appearance", "Эмоджи-логотип", placeholder="🫥"), + SettingField("WEBAPP_ENABLED", "bool", "appearance", "Web App включён"), + # ─── Subscription periods & pricing ──────────────────────────── + SettingField("MONTH_1_ENABLED", "bool", "pricing", "Тариф 1 месяц"), + SettingField("MONTH_3_ENABLED", "bool", "pricing", "Тариф 3 месяца"), + SettingField("MONTH_6_ENABLED", "bool", "pricing", "Тариф 6 месяцев"), + SettingField("MONTH_12_ENABLED", "bool", "pricing", "Тариф 12 месяцев"), + SettingField("RUB_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (RUB)"), + SettingField("RUB_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (RUB)"), + SettingField("RUB_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (RUB)"), + SettingField("RUB_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (RUB)"), + SettingField("STARS_PRICE_1_MONTH", "int", "pricing", "Цена 1 мес. (Stars)"), + SettingField("STARS_PRICE_3_MONTHS", "int", "pricing", "Цена 3 мес. (Stars)"), + SettingField("STARS_PRICE_6_MONTHS", "int", "pricing", "Цена 6 мес. (Stars)"), + SettingField("STARS_PRICE_12_MONTHS", "int", "pricing", "Цена 12 мес. (Stars)"), + SettingField("TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика", "Формат: 10:199,50:799 (ГБ:цена)"), + SettingField("STARS_TRAFFIC_PACKAGES", "string", "pricing", "Пакеты трафика (Stars)"), + SettingField("PAYMENT_METHODS_ORDER", "string", "pricing", "Порядок методов оплаты", "Через запятую, например: severpay,freekassa,yookassa"), + # ─── Payment providers (toggles) ─────────────────────────────── + # Common + SettingField("STARS_ENABLED", "bool", "payments", "Telegram Stars", subsection="Общие"), + SettingField("PAYMENT_METHODS_ORDER", "string", "payments", "Порядок методов оплаты", + "Через запятую: severpay,freekassa,yookassa,platega,stars,cryptopay", + subsection="Общие"), + + # YooKassa + SettingField("YOOKASSA_ENABLED", "bool", "payments", "Включена", subsection="YooKassa"), + SettingField("YOOKASSA_SHOP_ID", "string", "payments", "Shop ID", subsection="YooKassa"), + SettingField("YOOKASSA_SECRET_KEY", "string", "payments", "Secret key", subsection="YooKassa", secret=True), + SettingField("YOOKASSA_RETURN_URL", "url", "payments", "Return URL", subsection="YooKassa"), + SettingField("YOOKASSA_DEFAULT_RECEIPT_EMAIL", "string", "payments", "Email для чека по умолчанию", + subsection="YooKassa"), + SettingField("YOOKASSA_VAT_CODE", "int", "payments", "VAT code", "1..6 в зависимости от системы налогообложения", + subsection="YooKassa", min=1, max=6), + SettingField("YOOKASSA_AUTOPAYMENTS_ENABLED", "bool", "payments", "Автоплатежи (recurring)", + subsection="YooKassa"), + SettingField("YOOKASSA_AUTOPAYMENTS_REQUIRE_CARD_BINDING", "bool", "payments", + "Принудительная привязка карты", subsection="YooKassa"), + + # FreeKassa + SettingField("FREEKASSA_ENABLED", "bool", "payments", "Включена", subsection="FreeKassa"), + SettingField("FREEKASSA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="FreeKassa"), + SettingField("FREEKASSA_FIRST_SECRET", "string", "payments", "First secret", + subsection="FreeKassa", secret=True), + SettingField("FREEKASSA_SECOND_SECRET", "string", "payments", "Second secret", + "Используется для проверки подписи входящих уведомлений", + subsection="FreeKassa", secret=True), + SettingField("FREEKASSA_API_KEY", "string", "payments", "API key", + subsection="FreeKassa", secret=True), + SettingField("FREEKASSA_PAYMENT_URL", "url", "payments", "Payment URL", + placeholder="https://pay.freekassa.ru/", subsection="FreeKassa"), + SettingField("FREEKASSA_PAYMENT_METHOD_ID", "int", "payments", "Метод оплаты по умолчанию", + subsection="FreeKassa"), + SettingField("FREEKASSA_PAYMENT_IP", "string", "payments", "IP сервера", + "Передаётся в подпись запроса при создании платежа", + subsection="FreeKassa"), + SettingField("FREEKASSA_TRUSTED_IPS", "string", "payments", "Доверенные IP", + "Через запятую — IP-адреса, с которых принимаются нотификации", + subsection="FreeKassa"), + + # Platega + SettingField("PLATEGA_ENABLED", "bool", "payments", "Включена", subsection="Platega"), + SettingField("PLATEGA_BASE_URL", "url", "payments", "Base URL", + placeholder="https://app.platega.io", subsection="Platega"), + SettingField("PLATEGA_MERCHANT_ID", "string", "payments", "Merchant ID", subsection="Platega"), + SettingField("PLATEGA_SECRET", "string", "payments", "Secret", subsection="Platega", secret=True), + SettingField("PLATEGA_PAYMENT_METHOD", "int", "payments", "Метод оплаты (legacy)", subsection="Platega"), + SettingField("PLATEGA_SBP_ENABLED", "bool", "payments", "SBP-кнопка", subsection="Platega"), + SettingField("PLATEGA_SBP_METHOD", "int", "payments", "SBP method ID", subsection="Platega"), + SettingField("PLATEGA_CRYPTO_ENABLED", "bool", "payments", "Crypto-кнопка", subsection="Platega"), + SettingField("PLATEGA_CRYPTO_METHOD", "int", "payments", "Crypto method ID", subsection="Platega"), + SettingField("PLATEGA_RETURN_URL", "url", "payments", "Return URL", subsection="Platega"), + SettingField("PLATEGA_FAILED_URL", "url", "payments", "Failed URL", subsection="Platega"), + + # SeverPay + SettingField("SEVERPAY_ENABLED", "bool", "payments", "Включена", subsection="SeverPay"), + SettingField("SEVERPAY_MID", "int", "payments", "MID", subsection="SeverPay"), + SettingField("SEVERPAY_TOKEN", "string", "payments", "Token", subsection="SeverPay", secret=True), + SettingField("SEVERPAY_BASE_URL", "url", "payments", "Base URL", + placeholder="https://severpay.io/api/merchant", subsection="SeverPay"), + SettingField("SEVERPAY_RETURN_URL", "url", "payments", "Return URL", subsection="SeverPay"), + SettingField("SEVERPAY_LIFETIME_MINUTES", "int", "payments", "Срок жизни ссылки (мин)", + "30..4320; пусто — значение провайдера", subsection="SeverPay", min=30, max=4320), + + # CryptoPay + SettingField("CRYPTOPAY_ENABLED", "bool", "payments", "Включена", subsection="CryptoPay"), + SettingField("CRYPTOPAY_TOKEN", "string", "payments", "Token", subsection="CryptoPay", secret=True), + SettingField("CRYPTOPAY_NETWORK", "string", "payments", "Network", + "mainnet или testnet", subsection="CryptoPay"), + SettingField("CRYPTOPAY_CURRENCY_TYPE", "string", "payments", "Currency type", + "fiat или crypto", subsection="CryptoPay"), + SettingField("CRYPTOPAY_ASSET", "string", "payments", "Asset", + placeholder="RUB", subsection="CryptoPay"), + # ─── Trial ───────────────────────────────────────────────────── + SettingField("TRIAL_ENABLED", "bool", "trial", "Триал включён"), + SettingField("TRIAL_DURATION_DAYS", "int", "trial", "Длительность триала (дней)", min=0), + SettingField("TRIAL_TRAFFIC_LIMIT_GB", "float", "trial", "Лимит трафика триала (ГБ)", min=0), + SettingField("TRIAL_TRAFFIC_STRATEGY", "string", "trial", "Стратегия сброса трафика триала"), + # ─── Referral program ────────────────────────────────────────── + SettingField("REFERRAL_ONE_BONUS_PER_REFEREE", "bool", "referral", "Один бонус на приглашённого"), + SettingField("REFERRAL_WELCOME_BONUS_DAYS", "int", "referral", "Приветственный бонус (дней)", min=0), + SettingField("LEGACY_REFS", "bool", "referral", "Поддержка старых ref-ссылок"), + SettingField("REFERRAL_BONUS_DAYS_INVITER_1_MONTH", "int", "referral", "Бонус приглашающему: 1 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_INVITER_3_MONTHS", "int", "referral", "Бонус приглашающему: 3 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_INVITER_6_MONTHS", "int", "referral", "Бонус приглашающему: 6 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_INVITER_12_MONTHS", "int", "referral", "Бонус приглашающему: 12 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_REFEREE_1_MONTH", "int", "referral", "Бонус приглашённому: 1 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_REFEREE_3_MONTHS", "int", "referral", "Бонус приглашённому: 3 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_REFEREE_6_MONTHS", "int", "referral", "Бонус приглашённому: 6 мес.", min=0), + SettingField("REFERRAL_BONUS_DAYS_REFEREE_12_MONTHS", "int", "referral", "Бонус приглашённому: 12 мес.", min=0), + # ─── Notifications ───────────────────────────────────────────── + SettingField("SUBSCRIPTION_NOTIFICATIONS_ENABLED", "bool", "notifications", "Включены уведомления о подписке"), + SettingField("SUBSCRIPTION_NOTIFY_ON_EXPIRE", "bool", "notifications", "Уведомлять об истечении"), + SettingField("SUBSCRIPTION_NOTIFY_AFTER_EXPIRE", "bool", "notifications", "Уведомлять после истечения"), + SettingField("SUBSCRIPTION_NOTIFY_DAYS_BEFORE", "int", "notifications", "За сколько дней предупреждать", min=0), + SettingField("LOG_NEW_USERS", "bool", "notifications", "Логировать новых пользователей"), + SettingField("LOG_PAYMENTS", "bool", "notifications", "Логировать платежи"), + SettingField("LOG_PROMO_ACTIVATIONS", "bool", "notifications", "Логировать активации промокодов"), + SettingField("LOG_TRIAL_ACTIVATIONS", "bool", "notifications", "Логировать активации триала"), + SettingField("LOG_SUSPICIOUS_ACTIVITY", "bool", "notifications", "Логировать подозрительные действия"), + SettingField("LOG_LEVEL", "string", "notifications", "Глобальный уровень логов", "DEBUG / INFO / WARNING / ERROR"), + SettingField("LOG_CHAT_ID", "int", "notifications", "ID чата для логов"), + SettingField("LOG_THREAD_ID", "int", "notifications", "ID треда (для супергрупп)"), + # ─── Devices ─────────────────────────────────────────────────── + SettingField("MY_DEVICES_SECTION_ENABLED", "bool", "devices", "Раздел «Мои устройства»"), + SettingField("USER_HWID_DEVICE_LIMIT", "int", "devices", "Лимит устройств по умолчанию (0 = ∞)", min=0), + SettingField("USER_TRAFFIC_LIMIT_GB", "float", "devices", "Лимит трафика пользователя (ГБ)"), + SettingField("USER_TRAFFIC_STRATEGY", "string", "devices", "Стратегия сброса трафика"), +] + + +def get_field_by_key(key: str) -> Optional[SettingField]: + for field in SETTINGS_MANIFEST: + if field.key == key: + return field + return None + + +def manifest_keys() -> List[str]: + return [f.key for f in SETTINGS_MANIFEST] + + +def coerce_value(field: SettingField, raw: Any) -> Any: + """Coerce a value coming from JSON to the type declared by the field.""" + + if raw is None or (isinstance(raw, str) and raw.strip() == ""): + return None + + if field.type == "bool": + if isinstance(raw, bool): + return raw + if isinstance(raw, (int, float)): + return bool(raw) + if isinstance(raw, str): + return raw.strip().lower() in {"1", "true", "yes", "on"} + return bool(raw) + + if field.type == "int": + try: + value = int(str(raw).strip()) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field.key}: integer expected") from exc + if field.min is not None and value < field.min: + raise ValueError(f"{field.key}: must be >= {field.min:g}") + if field.max is not None and value > field.max: + raise ValueError(f"{field.key}: must be <= {field.max:g}") + return value + + if field.type == "float": + try: + value = float(str(raw).strip()) + except (TypeError, ValueError) as exc: + raise ValueError(f"{field.key}: number expected") from exc + if field.min is not None and value < field.min: + raise ValueError(f"{field.key}: must be >= {field.min:g}") + if field.max is not None and value > field.max: + raise ValueError(f"{field.key}: must be <= {field.max:g}") + return value + + if isinstance(raw, str): + return raw.strip() + return str(raw) + + +def manifest_payload() -> List[dict]: + """Serialize the manifest for the admin UI.""" + + sections_order = { + "general": 1, + "appearance": 2, + "pricing": 3, + "payments": 4, + "trial": 5, + "referral": 6, + "notifications": 7, + "devices": 8, + } + items: List[dict] = [] + for field in SETTINGS_MANIFEST: + items.append( + { + "key": field.key, + "type": field.type, + "section": field.section, + "section_order": sections_order.get(field.section, 99), + "subsection": field.subsection, + "label": field.label, + "description": field.description, + "placeholder": field.placeholder, + "optional": field.optional, + "secret": field.secret, + } + ) + return items diff --git a/bot/app/web/frontend/src/App.svelte b/bot/app/web/frontend/src/App.svelte index bb523f4..699ad9d 100644 --- a/bot/app/web/frontend/src/App.svelte +++ b/bot/app/web/frontend/src/App.svelte @@ -36,6 +36,7 @@ import Dialog from "./lib/components/ui/dialog.svelte"; import Input from "./lib/components/ui/input.svelte"; import PreviewBoard from "./PreviewBoard.svelte"; + import AdminPanel from "./admin/AdminPanel.svelte"; const MANUAL_LOGOUT_FLAG_KEY = "rw_webapp_manual_logout"; const LANGUAGE_LABELS = { @@ -62,7 +63,19 @@ invite: "/invite", devices: "/devices", settings: "/settings", + admin: "/admin", }; + const ADMIN_SECTIONS = new Set([ + "stats", + "users", + "payments", + "promos", + "ads", + "broadcast", + "logs", + "tariffs", + "settings", + ]); const TELEGRAM_WEBAPP_SCRIPT_URL = "https://telegram.org/js/telegram-web-app.js"; const TELEGRAM_OAUTH_AVAILABILITY_URL = "https://oauth.telegram.org/"; const TELEGRAM_SDK_BOOT_TIMEOUT_MS = 900; @@ -100,6 +113,7 @@ telegram_photo_url: "", first_name: "Preview", language_code: "ru", + is_admin: true, }, subscription: { active: true, @@ -487,6 +501,11 @@ hasActiveTariffSubscription && Number(subscription?.traffic_limit_bytes || 0) > 0 && trafficPercent(subscription) >= 85, ); $: user = data?.user || {}; + $: isAdmin = Boolean(user?.is_admin); + $: if (screen === "admin" && !isAdmin) { + screen = "settings"; + activeTab = "settings"; + } $: referral = data?.referral || DEV_MOCK.data.referral; $: currentLang = normalizeLangCode(user?.language_code || CFG.language || "ru"); $: languageOptions = WEBAPP_LANGUAGE_ORDER.map((code) => ({ @@ -570,6 +589,10 @@ const onPopState = () => { const section = sectionFromPath(window.location.pathname); if (mode === "app") { + if (section === "admin" && isAdmin) { + screen = "admin"; + return; + } const nextSection = section === "devices" && !devicesEnabled ? "home" : section; activeTab = nextSection; screen = nextSection; @@ -709,7 +732,10 @@ function normalizeSection(value) { const section = String(value || "").trim().toLowerCase(); - return section === "invite" || section === "devices" || section === "settings" ? section : "home"; + if (section === "invite" || section === "devices" || section === "settings" || section === "admin") { + return section; + } + return "home"; } function sectionFromPath(pathname) { @@ -718,14 +744,26 @@ .toLowerCase() .replace(/\/+$/, ""); if (!normalizedPath || normalizedPath === "/") return "home"; + if (normalizedPath === "/admin" || normalizedPath.startsWith("/admin/")) return "admin"; const section = normalizedPath.startsWith("/") ? normalizedPath.slice(1) : normalizedPath; return normalizeSection(section); } - function syncSectionPath(section, replace = false) { + function adminSectionFromPath(pathname) { + const normalized = String(pathname || "").toLowerCase().replace(/\/+$/, ""); + const m = normalized.match(/^\/admin\/([a-z0-9_-]+)$/); + if (m && ADMIN_SECTIONS.has(m[1])) return m[1]; + return "stats"; + } + + function syncSectionPath(section, replace = false, adminSection = null) { if (window.location.protocol === "file:") return; const normalized = normalizeSection(section); - const targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; + let targetPath = APP_SECTION_PATHS[normalized] || APP_SECTION_PATHS.home; + if (normalized === "admin") { + const adm = adminSection || adminSectionFromPath(window.location.pathname) || "stats"; + targetPath = `/admin/${adm}`; + } if (window.location.pathname === targetPath) return; const nextUrl = `${targetPath}${window.location.search}${window.location.hash}`; window.history[replace ? "replaceState" : "pushState"](null, "", nextUrl); @@ -949,11 +987,16 @@ paymentStep = "tariff"; selectedMethod = payload.payment_methods?.[0]?.id || ""; let section = MOCK && query.get("screen") ? normalizeSection(query.get("screen")) : sectionFromPath(window.location.pathname); + if (section === "admin" && !payload.user?.is_admin) section = "settings"; if (section === "devices" && !payload.settings?.my_devices_enabled) section = "home"; - activeTab = section; + activeTab = section === "admin" ? "settings" : section; screen = section; mode = "app"; - syncSectionPath(section, true); + syncSectionPath( + section, + true, + section === "admin" ? adminSectionFromPath(window.location.pathname) : null, + ); if (section === "devices" && payload.settings?.my_devices_enabled) { await loadDevices(); } @@ -1002,6 +1045,105 @@ async function mockApi(path, options = {}) { await new Promise((resolve) => window.setTimeout(resolve, 120)); + const cleanPath = String(path || "").split("?")[0]; + const adminUsers = [ + { + user_id: 100200300, + telegram_id: 100200300, + username: "anna_ops", + first_name: "Анна", + last_name: "Смирнова", + email: "anna@example.com", + telegram_photo_url: "", + registration_date: "2026-04-24T10:20:00Z", + is_banned: false, + }, + { + user_id: 100200301, + telegram_id: 87543123, + username: "client_pro", + first_name: "Максим", + last_name: "Котов", + email: "", + telegram_photo_url: "", + registration_date: "2026-04-26T08:15:00Z", + is_banned: false, + }, + { + user_id: 100200302, + telegram_id: 88440011, + username: "", + first_name: "Daria", + last_name: "", + email: "daria@example.com", + telegram_photo_url: "", + registration_date: "2026-04-29T16:45:00Z", + is_banned: true, + }, + ]; + if (path === "/admin/stats") { + return { + ok: true, + users: { total_users: 248, active_subscriptions: 172, banned_users: 3 }, + financial: { total_revenue: 186240, successful_payments_count: 934 }, + panel_sync: { status: "success", last_sync_time: new Date().toISOString(), users_processed: 172, subscriptions_synced: 168 }, + recent_payments: [ + { payment_id: 1, user_id: 100200300, user_label: "anna_ops", amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: new Date().toISOString() }, + ], + }; + } + if (cleanPath === "/admin/users") return { ok: true, users: adminUsers, total: adminUsers.length, page: 0, page_size: 25 }; + if (cleanPath.startsWith("/admin/users/")) { + const id = Number(cleanPath.split("/")[3]); + const user = adminUsers.find((item) => item.user_id === id) || adminUsers[0]; + return { + ok: true, + user, + active_subscription: { + subscription_id: 10, + end_date: "2026-06-08T12:00:00Z", + tariff_key: "standard", + auto_renew_enabled: true, + provider: "yookassa", + }, + subscriptions: [ + { subscription_id: 10, end_date: "2026-06-08T12:00:00Z", tariff_key: "standard", is_active: true, status_from_panel: "ACTIVE" }, + { subscription_id: 9, end_date: "2026-05-08T12:00:00Z", tariff_key: "standard", is_active: false, status_from_panel: "EXPIRED" }, + ], + total_paid: 2380, + recent_payments: [ + { payment_id: 12, amount: 790, currency: "RUB", provider: "yookassa", status: "succeeded", created_at: "2026-05-01T14:15:00Z" }, + { payment_id: 11, amount: 790, currency: "RUB", provider: "stars", status: "succeeded", created_at: "2026-04-01T14:15:00Z" }, + ], + log_count: 18, + }; + } + if (path === "/admin/tariffs") { + return { + ok: true, + path: "config/tariffs.json", + catalog: { + default_tariff: "standard", + topup_packages_default: { rub: [{ gb: 10, price: 99 }], stars: [] }, + tariffs: [ + { + key: "standard", + names: { ru: "Стандарт", en: "Standard" }, + descriptions: { ru: "Базовый набор серверов" }, + squad_uuids: ["db786ee8-816b-4760-80aa-1fc7a3669ff2"], + billing_model: "period", + monthly_gb: 500, + prices_rub: { "1": 150, "3": 400 }, + prices_stars: { "1": 0, "3": 0 }, + enabled_periods: [1, 3], + enabled: true, + }, + ], + }, + }; + } + if (path === "/admin/settings") return { ok: true, sections: [] }; + if (cleanPath.startsWith("/admin/")) return { ok: true, payments: [], promos: [], logs: [], campaigns: [], total: 0 }; if (path === "/me") return structuredCloneSafe(DEV_MOCK.data); if (path === "/auth/email/request") return { ok: true }; if (path === "/auth/email/verify" || path === "/auth/email/magic") { @@ -2042,6 +2184,27 @@ syncSectionPath("settings"); } + function openAdminPanel() { + if (!isAdmin) return; + paymentModalOpen = false; + screen = "admin"; + syncSectionPath("admin", false, adminSectionFromPath(window.location.pathname)); + } + + function closeAdminPanel() { + screen = "settings"; + activeTab = "settings"; + syncSectionPath("settings"); + } + + function handleAdminSectionChange(adminSection) { + if (screen !== "admin") return; + if (window.location.protocol === "file:") return; + const targetPath = `/admin/${adminSection}`; + if (window.location.pathname === targetPath) return; + window.history.pushState(null, "", `${targetPath}${window.location.search}${window.location.hash}`); + } + function openPaymentModal() { if (tariffMode) { if (singleTariffMode && tariffCatalog[0]?.key) { @@ -2702,8 +2865,16 @@ {/if} + {:else if screen === "admin" && isAdmin} + showToast(text)} + initialSection={adminSectionFromPath(window.location.pathname)} + onSectionChange={handleAdminSectionChange} + /> {:else} -
+
{#if screen === "invite" || screen === "devices" || screen === "settings"}
@@ -2977,6 +3148,20 @@ {profileTelegramId}
+ {#if isAdmin} +
+ + + +
+ {/if} {#if supportUrl} - {/if} {#if userAgreementUrl} - {/if} {#if privacyPolicyUrl} - {/if} - + {#if isAdmin} + + {/if} {/if}
diff --git a/bot/app/web/frontend/src/admin/AdminPanel.svelte b/bot/app/web/frontend/src/admin/AdminPanel.svelte new file mode 100644 index 0000000..394299f --- /dev/null +++ b/bot/app/web/frontend/src/admin/AdminPanel.svelte @@ -0,0 +1,2423 @@ + + +{#snippet renderField(field)} + {@const revealed = isSecretRevealed(field.key)} +
+
+ + {field.label} + {#if field.secret} + Secret + {/if} + {#if isOverridden(field)} + Override + {/if} + + {field.key} + {#if field.description} + {field.description} + {/if} +
+
+ {#if field.type === "bool"} + + {:else if field.type === "color"} + markDirty(field.key, e.currentTarget.value)} + /> + markDirty(field.key, e.currentTarget.value)} + /> + {:else if field.type === "int" || field.type === "float"} + markDirty(field.key, e.currentTarget.value)} + /> + {:else if field.secret} + markDirty(field.key, e.currentTarget.value)} + /> + + {:else} + markDirty(field.key, e.currentTarget.value)} + /> + {/if} + {#if isOverridden(field) || settingsDirty[field.key]} + + {/if} +
+
+{/snippet} + +
+ {#if sidebarOpen} + + {/if} + + + +
+
+
+ +
+

{meta.title}

+ {#if meta.subtitle}{meta.subtitle}{/if} +
+
+
+ {#if active === "stats"} + + {/if} + {#if active === "payments"} + + {/if} + {#if active === "promos"} + + {/if} + {#if active === "ads"} + + {/if} + {#if active === "tariffs"} + + {/if} + {#if active === "settings"} + {#if dirtyCount} + Изменений: {dirtyCount} + {/if} + + {/if} +
+
+ +
+ {#if active === "stats"} + {#if statsError} +
Не удалось загрузить статистику: {statsError}
+ {:else if statsLoading || !stats} +
Загрузка…
+ {:else} +
+
+ Пользователи + {stats.users?.total_users ?? 0} + В бане: {stats.users?.banned_users ?? 0} +
+
+ Платные подписки + {stats.users?.paid_subscriptions ?? 0} + Триалы: {stats.users?.trial_users ?? 0} +
+
+ Доход за день + {fmtMoney(stats.financial?.today_revenue, stats.currency_symbol)} + {stats.financial?.today_payments_count ?? 0} платежей +
+
+ За неделю + {fmtMoney(stats.financial?.week_revenue, stats.currency_symbol)} + Месяц: {fmtMoney(stats.financial?.month_revenue, stats.currency_symbol)} +
+
+ Всё время + {fmtMoney(stats.financial?.all_time_revenue, stats.currency_symbol)} + Sync: {stats.panel_sync?.status ?? "—"} +
+ {#if stats.queue} +
+ Очередь + {stats.queue.user_queue_size ?? 0} + Группы: {stats.queue.group_queue_size ?? 0} +
+ {/if} +
+ +
+
+

Последние платежи

+ {(stats.recent_payments || []).length} записей +
+ {#if (stats.recent_payments || []).length} + + + + + + + + + + + + + {#each stats.recent_payments as p} + + + + + + + + + {/each} + +
IDПользовательСуммаПровайдерСтатусДата
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider} + {p.status} + {fmtDate(p.created_at)}
+ {:else} +
Нет данных
+ {/if} +
+ {/if} + {/if} + + {#if active === "users"} +
+ e.key === "Enter" && ((usersPage = 0), loadUsers())} + /> + +
+ + +
+ Всего: {usersTotal} +
+ +
+ {#if usersLoading} +
Загрузка…
+ {:else if !users.length} +
Никого не найдено
+ {:else} +
    + {#each users as user} + {@const avatar = resolvedAvatarUrl(user)} + {@const badge = panelStatusBadge(user)} +
  • + +
  • + {/each} +
+ {/if} +
+ +
+ Страница {usersPage + 1} +
+ + +
+
+ {/if} + + {#if active === "payments"} +
+ {#if paymentsLoading} +
Загрузка…
+ {:else if !payments.length} +
Нет платежей
+ {:else} + + + + + + + + + + + + + + {#each payments as p} + + + + + + + + + + {/each} + +
IDПользовательСуммаПровайдерОписаниеСтатусДата
#{p.payment_id}{p.user_label || p.user_id}{fmtMoney(p.amount, p.currency)}{p.provider}{p.description || "—"} + {p.status} + {fmtDate(p.created_at)}
+ {/if} +
+ +
+ Стр. {paymentsPage + 1} · Всего {paymentsTotal} +
+ + +
+
+ {/if} + + {#if active === "promos"} +
+ {#if promosLoading} +
Загрузка…
+ {:else if !promos.length} +
Промокодов нет
+ {:else} + + + + + + + + + + + + + {#each promos as p} + + + + + + + + + {/each} + +
КодБонусАктивацийДействует доСтатусДействия
{p.code}+{p.bonus_days} дн.{p.current_activations}/{p.max_activations}{p.valid_until ? fmtDateShort(p.valid_until) : "∞"} + {#if p.is_active} + Активен + {:else} + Выключен + {/if} + + + +
+ {/if} +
+ {/if} + + {#if active === "broadcast"} +
+
+

Рассылка

+ Доставка через очередь сообщений +
+
+
+ + +
+ + {#if broadcastResult} + В очереди: {broadcastResult.queued} · Неудач: {broadcastResult.failed} + {/if} +
+
+
+
+ {/if} + + {#if active === "logs"} +
+ e.key === "Enter" && ((logsPage = 0), loadLogs())} + /> + + + Всего: {logsTotal} +
+ +
+ {#if logsLoading} +
Загрузка…
+ {:else if !logs.length} +
Записей нет
+ {:else} + + + + + + + + + + + + {#each logs as entry} + + + + + + + + {/each} + +
ДатаСобытиеUserTargetКонтент
{fmtDate(entry.timestamp)}{entry.event_type}{entry.user_id || "—"}{entry.target_user_id || "—"}{entry.content || ""}
+ {/if} +
+ +
+ Стр. {logsPage + 1} +
+ + +
+
+ {/if} + + {#if active === "ads"} +
+ {#if adsLoading} +
Загрузка…
+ {:else if !ads.length} +
Кампаний нет
+ {:else} + + + + + + + + + + + + + + + {#each ads as ad} + + + + + + + + + + + {/each} + +
IDИсточникПараметрСтоимостьРегистрацииКонверсииСтатусДействия
#{ad.id}{ad.source}{ad.start_param}{fmtMoney(ad.cost)}{ad.stats?.registrations ?? 0}{ad.stats?.conversions ?? 0} + {#if ad.is_active} + Активна + {:else} + Выключена + {/if} + + + +
+ {/if} +
+ {/if} + + {#if active === "tariffs"} + {#if tariffsLoading} +
Загрузка…
+ {:else} +
+
+ Всего тарифов + {tariffsCatalog.tariffs.length} + Включено: {enabledTariffs.length} +
+
+ По умолчанию + {tariffsCatalog.default_tariff || "—"} + Используется для новых подписок +
+
+ Отключено + {disabledTariffs} + Скрыто с витрины +
+
+ +
+
+
+

Каталог тарифов

+ {tariffsPath || "config/tariffs.json"} +
+ +
+
+ {#if !tariffsCatalog.tariffs.length} +
+ Каталог пуст. Добавьте первый тариф, после сохранения будет создан JSON-файл каталога. +
+ {:else} +
+ {#each tariffsCatalog.tariffs as tariff} +
+
+
+
+ {tariffName(tariff)} + {#if tariff.key === tariffsCatalog.default_tariff} + Default + {/if} +
+ {tariff.key} +
+ {#if tariff.enabled === false} + Выключен + {:else} + Активен + {/if} +
+

{tariff.descriptions?.ru || tariff.descriptions?.en || "Без описания"}

+
+ {tariff.billing_model === "traffic" ? "Трафик" : "Периоды"} + {tariffPriceSummary(tariff)} + Squads: {(tariff.squad_uuids || []).length} + Устройства: {tariff.hwid_device_limit ?? "env"} +
+
+ + + + +
+
+ {/each} +
+ {/if} +
+
+ {/if} + {/if} + + {#if active === "settings"} + {#if settingsLoading || !settingsSections.length} +
{settingsLoading ? "Загрузка…" : "Нет данных"}
+ {:else} +
+

+ Изменения в админке имеют приоритет над .env. Кнопка «Восстановить» возвращает значение из переменных окружения. +

+ +
+ + {#each settingsSections as section} + {@const dirtyInSection = section.fields.filter((f) => Boolean(settingsDirty[f.key])).length} + {@const overriddenInSection = section.fields.filter((f) => isOverridden(f)).length} + + + + {sectionTitle(section.id)} + + {section.fields.length} параметров{#if overriddenInSection} · {overriddenInSection} override{/if}{#if dirtyInSection} · {dirtyInSection} изм.{/if} + + + + + + {@const groups = groupSectionFields(section)} + {@const rootGroup = groups.find((g) => !g.label)} + {@const labelGroups = groups.filter((g) => g.label)} +
+ {#if rootGroup} + {#each rootGroup.fields as field} + {@render renderField(field)} + {/each} + {/if} + {#if labelGroups.length} + (settingsOpenSubsections = { ...settingsOpenSubsections, [section.id]: v })} + class="admin-subsection-accordion" + > + {#each labelGroups as group} + {@const subDirty = group.fields.filter((f) => Boolean(settingsDirty[f.key])).length} + {@const subOverridden = group.fields.filter((f) => isOverridden(f)).length} + + + + {group.label} + + {group.fields.length} полей{#if subOverridden} · {subOverridden} override{/if}{#if subDirty} · {subDirty} изм.{/if} + + + + + +
+ {#each group.fields as field} + {@render renderField(field)} + {/each} +
+
+
+ {/each} +
+ {/if} +
+
+
+ {/each} +
+ {/if} + {/if} +
+
+
+ + (tariffEditorOpen = false)} + class="admin-dialog admin-tariff-dialog" +> + + + Основное + Цены + Докупки + Устройства + + + +
+ + Ключ + Стабильный ID для платежей и подписок + + + +
+ Модель тарификации + Период — фикс. длительность; Трафик — оплата за GB + + + {tariffDraft.billing_model === "traffic" ? "Трафик" : "Период"} + + + + + + Период + + + + Трафик + + + + + +
+
+ +
+ (tariffDraft.enabled = v)} + class="admin-switch-root" + > + + + + {tariffDraft.enabled ? "Тариф включён" : "Тариф выключен"} + Скрытые тарифы не отображаются на витрине + +
+ +
+ + Название · RU + + + + Название · EN + + +
+ +
+ + Описание · RU + + + + Описание · EN + + +
+ + + Internal Squads UUID + Один UUID на строку или через запятую + + + +
+ + Базовый лимит устройств + Пусто — значение из env, 0 — безлимит + + + {#if tariffDraft.billing_model === "period"} + + Месячный лимит, GB + 0 — безлимит + + + {:else} + + Курс конвертации, RUB/GB + Нужен для перехода period → traffic + + + {/if} +
+
+ + + {#if tariffDraft.billing_model === "period"} +
+
+ Периоды и цены + +
+ {#if !tariffDraft.periodRows.length} +

Добавьте хотя бы один период.

+ {/if} +
+ {#each tariffDraft.periodRows as row, index} +
+ + + + +
+ {/each} +
+
+ {:else} +
+
+ Пакеты трафика +
+ + +
+
+
+
+ RUB + {#each tariffDraft.trafficRubRows as row, index} +
+ + + +
+ {/each} +
+
+ Stars + {#each tariffDraft.trafficStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+ {/if} +
+ + + {#if tariffDraft.billing_model === "period"} +
+
+ Докупка трафика для тарифа +
+ + +
+
+
+
+ RUB + {#each tariffDraft.topupRubRows as row, index} +
+ + + +
+ {/each} +
+
+ Stars + {#each tariffDraft.topupStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+ {:else} +

Для трафиковой модели докупки не нужны — настройте пакеты трафика на вкладке «Цены».

+ {/if} +
+ + +
+
+ Пакеты HWID-устройств +
+ + +
+
+
+
+ RUB + {#each tariffDraft.hwidRubRows as row, index} +
+ + + +
+ {/each} +
+
+ Stars + {#each tariffDraft.hwidStarsRows as row, index} +
+ + + +
+ {/each} +
+
+
+
+
+ +
+ + +
+
+ + (tariffDeleteOpen = false)} + class="admin-dialog" +> +
+ + +
+
+ + + {#if openedUser} + {#if userDetailLoading || !openedUserDetail} +

Загрузка…

+ {:else} +
+ + {#if resolvedAvatarUrl(openedUser)} + + {:else} + {userInitials(openedUser)} + {/if} + +
+ {userDisplayName(openedUser)} + {userSecondaryName(openedUser)} +
+ {#if openedUser.is_banned} + Бан + {:else} + Активен + {/if} + {#if openedUserDetail.active_subscription} + Подписка + {:else} + Без подписки + {/if} + Заплачено: {fmtMoney(openedUserDetail.total_paid)} +
+
+
+ + + + Профиль + Подписка + Активность + Действия + + + +
    +
  • ID{openedUser.user_id}
  • +
  • Telegram ID{openedUser.telegram_id || "—"}
  • +
  • Username{openedUser.username ? "@" + openedUser.username : "—"}
  • +
  • Email{openedUser.email || "—"}
  • +
  • Регистрация{fmtDate(openedUser.registration_date)}
  • +
  • Реф. код{openedUserDetail.user?.referral_code || "—"}
  • +
  • Логов{openedUserDetail.log_count}
  • +
+
+ + + {#if openedUserDetail.active_subscription} +
    +
  • Активна до{fmtDate(openedUserDetail.active_subscription.end_date)}
  • +
  • Тариф{openedUserDetail.active_subscription.tariff_key || "—"}
  • +
  • Авто-продление{pretty(openedUserDetail.active_subscription.auto_renew_enabled)}
  • +
  • Провайдер{openedUserDetail.active_subscription.provider || "—"}
  • +
+ {:else} +

Активной подписки нет

+ {/if} + + {#if (openedUserDetail.subscriptions || []).length} + +
История подписок · {openedUserDetail.subscriptions.length}
+
+ {#each openedUserDetail.subscriptions.slice(0, 8) as sub} +
+
+ {sub.tariff_key || "Без тарифа"} + до {fmtDate(sub.end_date)} +
+ {#if sub.is_active} + Активна + {:else} + {sub.status_from_panel || "История"} + {/if} +
+ {/each} +
+ {/if} +
+ + +
Последние платежи · {(openedUserDetail.recent_payments || []).length}
+ {#if (openedUserDetail.recent_payments || []).length} +
+ {#each openedUserDetail.recent_payments.slice(0, 8) as payment} +
+
+ {fmtMoney(payment.amount, payment.currency)} + {payment.provider} · {fmtDateShort(payment.created_at)} +
+ {payment.status} +
+ {/each} +
+ {:else} +

Платежей нет

+ {/if} +
+ + +
+
+ Безопасные действия + Можно выполнять без подтверждения +
+
+ +
+ + +
+
+
+ + + Сообщение в Telegram + Поддерживается HTML-разметка Telegram + + + + +
+
+ Опасные действия + Эти действия требуют подтверждения и (для удаления) необратимы +
+
+ {#if openedUser.is_banned} + + {:else} + + {/if} + +
+
+
+
+ {/if} + {/if} +
+ + (userBanConfirmOpen = false)} + class="admin-dialog" +> +
+ + +
+
+ + (userDeleteOpen = false)} + class="admin-dialog" +> +
+ + +
+
+ + (promoCreateOpen = false)} class="admin-dialog"> +
+ +
+ + + +
+ +
+
+ + (adCreateOpen = false)} class="admin-dialog"> +
+ + + + +
+
diff --git a/bot/app/web/frontend/src/styles.css b/bot/app/web/frontend/src/styles.css index c71bf6f..1fe81dc 100644 --- a/bot/app/web/frontend/src/styles.css +++ b/bot/app/web/frontend/src/styles.css @@ -1487,6 +1487,10 @@ a { color: var(--accent); } +.rail-admin-entry { + display: none !important; +} + .home-layout { display: grid; min-height: calc(100dvh - 34px); @@ -2133,3 +2137,2399 @@ a { background: var(--accent); } } + +/* ============================================================ + Desktop layout for the user-facing Mini App (≥ 1024px) + A full-bleed dashboard: brand + nav rail on the left (the + mobile bottom-nav is repositioned, no markup changes), main + content area takes the rest of the viewport with a generous + max-width. Auth screens are kept centered. + Mobile (≤ 1023px) is intentionally untouched. + ============================================================ */ + +:root { + --desktop-rail-width: 252px; + --desktop-page-gutter: clamp(28px, 4vw, 72px); +} + +@media (min-width: 1024px) { + body { + background: + radial-gradient(900px 600px at 8% -10%, color-mix(in srgb, var(--accent) 9%, transparent) 0%, transparent 60%), + radial-gradient(900px 500px at 92% 110%, rgba(45, 156, 255, 0.07) 0%, transparent 55%), + #02070b; + } + + .app-shell { + min-height: 100dvh; + display: block; + padding: 0; + background: transparent !important; + } + + /* Full-width canvas: the side rail is a fixed sibling, so we leave room + for it on the left and center content inside the right-hand work area. */ + .phone-screen { + width: auto; + max-width: none; + min-height: 100dvh; + margin: 0; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + padding: + max(28px, env(safe-area-inset-top)) + var(--desktop-page-gutter) + 40px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + overflow-x: visible; + } + + /* Centre and cap the width of the actual content blocks. */ + .phone-screen > .app-header, + .phone-screen > main, + .phone-screen > .home-layout, + .phone-screen > nav.bottom-nav ~ * { + max-width: 1080px; + margin-left: auto; + margin-right: auto; + } + + /* Auth screen: keep it as a focused card in the centre, hide rail. */ + .phone-screen.auth-screen { + width: min(100%, 460px); + min-height: 100dvh; + padding: + max(48px, env(safe-area-inset-top)) + clamp(24px, 4vw, 48px) + 48px; + margin: 0 auto; + } + + .phone-screen.auth-screen ~ .bottom-nav, + .auth-screen .bottom-nav { + display: none !important; + } + + .phone-screen.home-screen { + display: grid; + grid-template-columns: minmax(0, 1fr); + align-items: stretch; + min-height: 100dvh; + padding: + max(32px, env(safe-area-inset-top)) + var(--desktop-page-gutter) + 42px + calc(var(--desktop-rail-width) + var(--desktop-page-gutter)); + } + + /* Home: keep the phone-style vertical flow, but center it in the + right-hand desktop area and let the action block sit on the bottom. */ + .home-layout { + width: min(100%, 680px); + min-height: calc(100dvh - 74px); + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(280px, 1fr) auto; + justify-self: center; + gap: clamp(24px, 4vh, 52px); + max-width: none; + margin: 0; + padding-bottom: 0; + align-items: stretch; + } + + .home-layout > .home-brand { + grid-column: auto; + align-self: center; + justify-items: center; + text-align: center; + gap: 22px; + } + + .home-brand h1 { + max-width: 560px; + font-size: clamp(36px, 4vw, 58px); + letter-spacing: 0; + line-height: 1.02; + } + + /* Make the brand mark a focal element on desktop. */ + .home-brand .brand-mark, + .home-brand .brand-mark-lg, + .home-brand .brand-mark-xl { + width: clamp(180px, 18vw, 260px); + height: clamp(180px, 18vw, 260px); + font-size: clamp(124px, 13vw, 184px); + } + + .home-bottom { + align-self: end; + width: min(100%, 560px); + justify-self: center; + gap: 12px; + } + + /* Top header (brand on settings/invite/devices) becomes a flat strip. */ + .app-header.accent-title { + display: none; + } + + /* Reset clearance — the bottom-nav is now a sidebar. */ + .content.with-nav { + padding-bottom: 32px; + } + + /* Transform the mobile bottom-nav into a fixed-left vertical rail. */ + .bottom-nav { + position: fixed !important; + left: 0 !important; + top: 0 !important; + bottom: 0 !important; + right: auto !important; + width: var(--desktop-rail-width) !important; + height: 100dvh !important; + transform: none !important; + display: grid !important; + grid-template-columns: 1fr !important; + grid-template-rows: none !important; + grid-auto-rows: auto !important; + align-content: start !important; + gap: 2px !important; + padding: 28px 14px !important; + border: 0 !important; + border-right: 1px solid var(--border) !important; + border-radius: 0 !important; + background: rgba(7, 12, 17, 0.55) !important; + backdrop-filter: blur(14px) !important; + box-shadow: inset -1px 0 0 rgba(255, 255, 255, 0.02) !important; + z-index: 40; + } + + .bottom-nav-devices { + grid-template-columns: 1fr !important; + } + + .bottom-nav button { + display: grid !important; + grid-template-columns: 22px 1fr !important; + grid-template-rows: auto !important; + align-items: center !important; + justify-items: start !important; + gap: 12px !important; + padding: 12px 14px !important; + border-radius: 10px !important; + text-align: left !important; + font-size: 13px !important; + color: var(--muted); + border: 1px solid transparent; + transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease; + } + + .bottom-nav button > svg { + width: 20px; + height: 20px; + } + + .bottom-nav button > span { + text-align: left !important; + font-size: 13px !important; + font-weight: 600; + } + + .bottom-nav button:hover { + background: rgba(255, 255, 255, 0.04); + color: var(--text); + } + + .bottom-nav button.active { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 30%, transparent); + color: var(--text); + } + + .bottom-nav .nav-attention-dot { + top: 14px; + right: 14px; + } + + .bottom-nav .rail-admin-entry { + display: grid !important; + } + + .settings-admin-block { + display: none !important; + } + + .phone-screen > main.content.with-nav { + padding-top: 0; + } + + .settings-profile { + padding: 12px; + } + + .settings-links-block { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .settings-links-block .settings-divider { + display: none; + } + + .settings-links-block .settings-row, + .settings-links-block .settings-telegram-link-btn { + min-height: 48px; + } + + .settings-list { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + } + + .settings-list .settings-row { + min-height: 48px; + } + + .settings-row-language, + .settings-row-support, + .settings-row-logout { + grid-column: 1 / -1; + } + + /* Wider modals on desktop. */ + .dialog-card { + width: min(100%, 560px); + } + + .payment-dialog-card, + .link-email-dialog-card { + width: min(100%, 640px); + } +} + +@media (min-width: 1280px) { + :root { + --desktop-rail-width: 264px; + } +} + +/* Brand block sits at the top of the desktop side rail; hidden on mobile. */ +.rail-brand { + display: none; +} + +@media (min-width: 1024px) { + .bottom-nav .rail-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 12px 18px; + margin-bottom: 8px; + border-bottom: 1px solid var(--border); + color: var(--text); + } + + .bottom-nav .rail-brand strong { + font-size: 14px; + font-weight: 800; + letter-spacing: -0.01em; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + } +} + +/* ============================================================ + Admin panel — shadcn-svelte dashboard treatment + Sidebar + sticky header + cards + semantic tables. + ============================================================ */ + +.admin-screen-wrap { + --admin-sidebar-w: 248px; + --admin-header-h: 60px; + --admin-bg: var(--bg); + --admin-surface: var(--panel); + --admin-surface-2: var(--panel-2); + --admin-elev: var(--panel-3); + --admin-border: var(--border); + --admin-border-strong: var(--border-strong); + --admin-text: var(--text); + --admin-muted: var(--muted); + --admin-dim: var(--dim); + --admin-ring: color-mix(in srgb, var(--accent) 50%, transparent); + + position: fixed; + inset: 0; + width: 100vw; + height: 100dvh; + background: var(--admin-bg); + color: var(--admin-text); + overflow: hidden; + overscroll-behavior: contain; + z-index: 60; + display: flex; + flex-direction: row; + /* Forces a new compositing layer — required for reliable scrolling + inside position:fixed on iOS Safari. */ + transform: translateZ(0); +} + +.admin-sidebar { + flex: 0 0 var(--admin-sidebar-w); + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 4px; + padding: 14px 12px 18px; + border-right: 1px solid var(--admin-border); + background: var(--admin-surface); + overflow-y: auto; +} + +.admin-sidebar > *:not(.admin-sidebar-footer) { + flex-shrink: 0; +} + +.admin-sidebar-brand { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) 32px; + align-items: center; + gap: 10px; + padding: 6px 6px 14px; + margin-bottom: 6px; + border-bottom: 1px solid var(--admin-border); +} + +.admin-sidebar-brand .admin-brand-mark { + width: 36px; + height: 36px; + border-radius: 10px; + background: color-mix(in srgb, var(--accent) 22%, var(--admin-surface-2)); + border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent); + display: grid; + place-items: center; + color: var(--accent); +} + +.admin-sidebar-brand strong { + display: block; + font-size: 13px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.admin-sidebar-brand small { + display: block; + color: var(--admin-muted); + font-size: 11px; +} + +.admin-sidebar-section-label { + font-size: 10px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--admin-dim); + padding: 14px 12px 6px; +} + +.admin-nav { + display: grid; + gap: 1px; +} + +.admin-nav-item { + position: relative; + display: grid; + grid-template-columns: 18px minmax(0, 1fr) auto; + align-items: center; + gap: 10px; + padding: 9px 12px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--admin-muted); + font-size: 13px; + font-weight: 500; + text-align: left; + width: 100%; + cursor: pointer; + transition: background 0.12s ease, color 0.12s ease; +} + +.admin-nav-item:hover { + background: var(--admin-surface-2); + color: var(--admin-text); +} + +.admin-nav-item.active { + background: var(--admin-elev); + color: var(--admin-text); + font-weight: 600; +} + +.admin-nav-item.active::before { + content: ""; + position: absolute; + width: 3px; + height: 18px; + left: -4px; + top: 50%; + transform: translateY(-50%); + border-radius: 2px; + background: var(--accent); +} + +.admin-sidebar-footer { + margin-top: auto; + padding: 10px 6px 0; + border-top: 1px solid var(--admin-border); + font-size: 11px; + color: var(--admin-dim); + display: grid; + gap: 6px; +} + +.admin-content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-width: 0; + min-height: 0; + height: 100%; + overflow: hidden; +} + +.admin-header { + flex: 0 0 auto; + min-height: var(--admin-header-h); + z-index: 5; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 16px; + padding: 0 24px; + border-bottom: 1px solid var(--admin-border); + background: var(--admin-bg); +} + +.admin-header-title { + display: grid; + gap: 2px; + min-width: 0; +} + +.admin-header-title h2 { + margin: 0; + font-size: 16px; + font-weight: 700; + letter-spacing: -0.01em; +} + +.admin-header-title small { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-header-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-mobile-toggle { + display: none; + width: 36px; + height: 36px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + color: var(--admin-text); + align-items: center; + justify-content: center; +} + +.admin-main { + flex: 1 1 auto; + min-height: 0; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; + overflow-y: auto; + overflow-x: hidden; + padding: 22px 24px 32px; + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Flex children must NOT shrink — otherwise they collapse to fit the + container and the main never overflows, breaking scroll. */ +.admin-main > * { + flex-shrink: 0; +} + +.admin-card { + border: 1px solid var(--admin-border); + background: var(--admin-surface); + border-radius: 12px; + display: grid; + grid-template-columns: minmax(0, 1fr); + min-width: 0; + overflow: hidden; +} + +.admin-card-head { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 14px 18px; + border-bottom: 1px solid var(--admin-border); + min-width: 0; +} + +.admin-card-head h3 { + margin: 0; + font-size: 13px; + font-weight: 600; + letter-spacing: 0; + text-transform: none; + color: var(--admin-text); + word-break: break-word; +} + +.admin-card-head small { + color: var(--admin-muted); + font-size: 12px; + word-break: break-word; +} + +.admin-card-body { + padding: 16px 18px; + display: grid; + gap: 12px; + min-width: 0; +} + +.admin-empty { + display: grid; + place-items: center; + padding: 48px 16px; + border: 1px dashed var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); + color: var(--admin-muted); + font-size: 13px; +} + +.admin-muted { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-stat-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 560px) { + .admin-stat-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1024px) { + .admin-stat-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +@media (min-width: 1440px) { + .admin-stat-grid { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } +} + +.admin-stat-card { + border: 1px solid var(--admin-border); + background: var(--admin-surface); + border-radius: 12px; + padding: 16px 18px; + display: grid; + gap: 6px; + min-height: 96px; +} + +.admin-stat-card .admin-stat-label { + display: flex; + align-items: center; + gap: 8px; + color: var(--admin-muted); + font-size: 12px; + font-weight: 500; +} + +.admin-stat-card .admin-stat-value { + font-size: 26px; + font-weight: 700; + letter-spacing: -0.02em; + color: var(--admin-text); +} + +.admin-stat-card .admin-stat-trend { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-toolbar { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.admin-toolbar > .input, +.admin-toolbar input[type="text"], +.admin-toolbar input[type="number"] { + flex: 1 1 200px; + min-width: 180px; +} + +.admin-segmented { + display: inline-flex; + border: 1px solid var(--admin-border); + background: var(--admin-surface); + border-radius: 8px; + padding: 2px; + gap: 2px; +} + +.admin-segmented button { + padding: 6px 12px; + border: 0; + background: transparent; + color: var(--admin-muted); + font-size: 12px; + font-weight: 600; + border-radius: 6px; + cursor: pointer; +} + +.admin-segmented button.active { + background: var(--admin-elev); + color: var(--admin-text); +} + +.admin-table-wrap { + border: 1px solid var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); + overflow: hidden; +} + +.admin-table-wrap > .admin-card-head { + border-bottom: 1px solid var(--admin-border); +} + +.admin-table { + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-size: 13px; +} + +.admin-table thead th { + text-align: left; + font-weight: 500; + color: var(--admin-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + padding: 12px 16px; + background: var(--admin-surface-2); + border-bottom: 1px solid var(--admin-border); + white-space: nowrap; +} + +.admin-table tbody td { + padding: 12px 16px; + border-bottom: 1px solid var(--admin-border); + vertical-align: middle; + color: var(--admin-text); +} + +.admin-table tbody tr:last-child td { + border-bottom: 0; +} + +.admin-table tbody tr { + transition: background 0.08s ease; +} + +.admin-table tbody tr:hover { + background: color-mix(in srgb, var(--admin-elev) 50%, transparent); +} + +.admin-table td.admin-cell-mono { + font-family: var(--font-mono); + font-size: 12px; +} + +.admin-table td.admin-cell-actions { + text-align: right; + white-space: nowrap; +} + +.admin-table td.admin-cell-actions .admin-btn { + margin-left: 4px; +} + +.admin-table td.admin-cell-wrap { + white-space: normal; + max-width: 320px; + word-break: break-word; + color: var(--admin-muted); + font-size: 12px; +} + +.admin-table td.admin-cell-id { + color: var(--admin-muted); + font-family: var(--font-mono); + font-size: 12px; + width: 80px; +} + +.admin-table tbody tr.is-clickable { + cursor: pointer; +} + +.admin-user-list { + display: grid; + list-style: none; + margin: 0; + padding: 0; + gap: 0; +} + +.admin-user-list > li { + border-bottom: 1px solid var(--admin-border); +} + +.admin-user-list > li:last-child { + border-bottom: 0; +} + +.admin-user-row { + display: grid; + grid-template-columns: 36px minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + width: 100%; + border: 0; + background: transparent; + color: var(--admin-text); + /* Card height = avatar (36px) + padding-y * 2 = 52px. */ + padding: 8px 14px; + text-align: left; + cursor: pointer; + transition: background 0.12s ease; +} + +.admin-user-row:hover { + background: color-mix(in srgb, var(--admin-elev) 60%, transparent); +} + +.admin-user-row:focus-visible { + outline: 2px solid var(--admin-ring); + outline-offset: -2px; +} + +.admin-user-side { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + white-space: nowrap; +} + +.admin-user-tertiary { + color: var(--admin-dim); + font-size: 11px; + white-space: nowrap; +} + +.admin-avatar.admin-avatar-sm { + width: 36px; + height: 36px; + font-size: 11px; +} + +@media (max-width: 480px) { + /* On very narrow screens, drop the date but keep the badge inline so the + row still fits within `avatar + padding` height. */ + .admin-user-tertiary { + display: none; + } + + .admin-user-row { + gap: 10px; + } +} + +.admin-avatar { + display: inline-grid; + width: 42px; + height: 42px; + flex: 0 0 auto; + place-items: center; + overflow: hidden; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: color-mix(in srgb, var(--accent) 16%, var(--admin-surface-2)); + color: var(--accent); + font-size: 12px; + font-weight: 700; + letter-spacing: 0; +} + +.admin-avatar img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.admin-avatar-lg { + width: 58px; + height: 58px; + font-size: 16px; +} + +.admin-user-main, +.admin-user-meta, +.admin-user-profile-head > div { + display: grid; + gap: 3px; + min-width: 0; +} + +.admin-user-main strong, +.admin-user-profile-head strong { + overflow: hidden; + color: var(--admin-text); + font-size: 13px; + font-weight: 650; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-main small, +.admin-user-profile-head small, +.admin-user-meta span { + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-meta strong { + overflow: hidden; + color: var(--admin-text); + font-size: 12px; + font-weight: 500; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-user-status { + justify-self: end; +} + +.admin-user-profile-head { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; + padding-bottom: 12px; + border-bottom: 1px solid var(--admin-border); +} + +.admin-mini-list { + display: grid; + gap: 0; +} + +.admin-mini-list-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: 12px; + padding: 9px 0; + border-bottom: 1px solid var(--admin-border); +} + +.admin-mini-list-row:last-child { + border-bottom: 0; +} + +.admin-mini-list-row div { + display: grid; + gap: 3px; + min-width: 0; +} + +.admin-mini-list-row strong { + overflow: hidden; + color: var(--admin-text); + font-size: 13px; + font-weight: 600; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-mini-list-row small { + overflow: hidden; + color: var(--admin-muted); + font-size: 11px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-pagination { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 4px 0; +} + +.admin-pagination .admin-pagination-meta { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-pagination-buttons { + display: flex; + gap: 6px; +} + +.admin-screen-wrap input, +.admin-screen-wrap textarea, +.admin-screen-wrap select, +.admin-screen-wrap .input { + min-width: 0; + max-width: 100%; +} + +.admin-screen-wrap .input, +.admin-screen-wrap input[type="text"], +.admin-screen-wrap input[type="number"], +.admin-screen-wrap input[type="email"], +.admin-screen-wrap input[type="search"], +.admin-screen-wrap input[type="password"], +.admin-screen-wrap input[type="url"], +.admin-screen-wrap select { + height: 36px; + border-radius: 8px; + border: 1px solid var(--admin-border-strong); + background: var(--admin-bg); + color: var(--admin-text); + padding: 0 12px; + font-size: 13px; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-screen-wrap .input::placeholder, +.admin-screen-wrap input::placeholder, +.admin-screen-wrap textarea::placeholder { + color: var(--admin-dim); +} + +.admin-screen-wrap .input:focus, +.admin-screen-wrap input:focus, +.admin-screen-wrap select:focus { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-select { + width: 100%; + appearance: auto; +} + +.admin-textarea, +.admin-screen-wrap textarea { + width: 100%; + min-height: 110px; + border-radius: 10px; + border: 1px solid var(--admin-border-strong); + background: var(--admin-bg); + color: var(--admin-text); + padding: 12px; + font: inherit; + font-size: 13px; + resize: vertical; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-textarea:focus { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-form { + display: grid; + gap: 12px; +} + +.admin-form-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; +} + +.admin-form-row.admin-form-row-3 { + grid-template-columns: minmax(180px, 1.1fr) minmax(160px, 0.8fr) minmax(140px, 0.6fr); +} + +.admin-form label { + display: grid; + gap: 6px; + font-size: 12px; + color: var(--admin-muted); + font-weight: 500; +} + +.admin-form label > span { + color: var(--admin-text); + font-weight: 500; +} + +.admin-form label > small { + color: var(--admin-dim); + font-weight: 400; +} + +.admin-switch.admin-switch-field { + align-self: end; + min-height: 36px; + display: flex; +} + +.admin-btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + height: 34px; + padding: 0 14px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + color: var(--admin-text); + font-size: 13px; + font-weight: 500; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease; +} + +.admin-btn:hover:not(:disabled) { + background: var(--admin-elev); +} + +.admin-btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-btn.admin-btn-primary { + background: var(--accent); + color: #02110a; + border-color: color-mix(in srgb, var(--accent) 70%, #000); + font-weight: 600; +} + +.admin-btn.admin-btn-primary:hover:not(:disabled) { + background: color-mix(in srgb, var(--accent) 90%, #fff); +} + +.admin-btn.admin-btn-ghost { + background: transparent; + border-color: transparent; + color: var(--admin-muted); +} + +.admin-btn.admin-btn-ghost:hover:not(:disabled) { + background: var(--admin-surface-2); + color: var(--admin-text); +} + +.admin-btn.admin-btn-danger { + background: color-mix(in srgb, #ff5757 80%, #000); + color: #fff; + border-color: color-mix(in srgb, #ff5757 60%, #000); + font-weight: 600; +} + +.admin-btn.admin-btn-danger:hover:not(:disabled) { + background: #ff5757; + border-color: #ff5757; +} + +.admin-btn.admin-btn-danger-soft { + color: #ffb4b4; + background: var(--admin-surface-2); + border-color: color-mix(in srgb, #ff6b6b 30%, transparent); +} + +.admin-btn.admin-btn-danger-soft:hover:not(:disabled) { + background: color-mix(in srgb, #ff6b6b 14%, var(--admin-surface)); + border-color: color-mix(in srgb, #ff6b6b 50%, transparent); +} + +.admin-btn.admin-btn-icon { + width: 34px; + padding: 0; +} + +.admin-btn.admin-btn-sm { + height: 28px; + padding: 0 10px; + font-size: 12px; + border-radius: 6px; +} + +.admin-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + color: var(--admin-muted); + white-space: nowrap; +} + +.admin-badge.admin-badge-success { + border-color: color-mix(in srgb, var(--accent) 36%, transparent); + color: var(--accent); + background: color-mix(in srgb, var(--accent) 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-danger { + border-color: color-mix(in srgb, #ff6b6b 32%, transparent); + color: #ffb4b4; + background: color-mix(in srgb, #ff6b6b 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-warning { + border-color: color-mix(in srgb, #ffd166 32%, transparent); + color: #ffd166; + background: color-mix(in srgb, #ffd166 12%, var(--admin-surface)); +} + +.admin-badge.admin-badge-muted { + color: var(--admin-muted); +} + +.admin-detail-grid { + display: grid; + grid-template-columns: 1fr; + gap: 14px; +} + +@media (min-width: 900px) { + .admin-detail-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +.admin-meta-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; +} + +.admin-meta-list li { + display: grid; + grid-template-columns: 130px minmax(0, 1fr); + align-items: baseline; + gap: 12px; + padding: 8px 0; + border-bottom: 1px solid var(--admin-border); + font-size: 13px; +} + +.admin-meta-list li:last-child { + border-bottom: 0; +} + +.admin-meta-list li > span { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-meta-list li > strong { + font-weight: 500; + color: var(--admin-text); + word-break: break-word; +} + +.admin-accordion { + display: grid; + gap: 10px; +} + +.admin-accordion-item.admin-card { + display: block; + min-width: 0; +} + +.admin-accordion-header { + margin: 0; + display: block; +} + +.admin-accordion-trigger { + appearance: none; + background: transparent; + border: 0; + text-align: left; + padding: 14px 18px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + color: var(--admin-text); + cursor: pointer; + font: inherit; + width: 100%; + transition: background 0.12s ease; + outline: none; +} + +.admin-accordion-trigger:hover { + background: var(--admin-surface-2); +} + +.admin-accordion-trigger:focus-visible { + background: var(--admin-surface-2); + box-shadow: inset 0 0 0 2px var(--admin-ring); +} + +.admin-accordion-trigger[data-state="open"] { + border-bottom: 1px solid var(--admin-border); +} + +.admin-accordion-title { + font-size: 13px; + font-weight: 600; + color: var(--admin-text); + text-transform: capitalize; + word-break: break-word; + min-width: 0; +} + +.admin-accordion-meta { + color: var(--admin-muted); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; +} + +.admin-accordion-chev { + transition: transform 0.2s ease; + color: var(--admin-muted); + flex: 0 0 auto; +} + +.admin-accordion-trigger[data-state="open"] .admin-accordion-chev { + transform: rotate(90deg); +} + +.admin-accordion-content { + overflow: hidden; +} + +.admin-accordion-content[data-state="closed"] { + display: none; +} + +@media (max-width: 720px) { + .admin-accordion-trigger { + padding: 12px 14px; + gap: 8px; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title chev" + "meta meta"; + } + + .admin-accordion-title { + grid-area: title; + } + + .admin-accordion-chev { + grid-area: chev; + } + + .admin-accordion-meta { + grid-area: meta; + white-space: normal; + font-size: 11px; + } +} + +.admin-setting { + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr); + gap: 24px; + padding: 16px 18px; + border-bottom: 1px solid var(--admin-border); + align-items: center; +} + +.admin-setting:last-child { + border-bottom: 0; +} + +.admin-setting.is-overridden { + background: color-mix(in srgb, var(--accent) 5%, transparent); +} + +.admin-setting-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-setting-meta strong { + font-size: 13px; + font-weight: 600; + color: var(--admin-text); + display: inline-flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + word-break: break-word; +} + +.admin-setting-meta code { + font-family: var(--font-mono); + font-size: 11px; + color: var(--admin-dim); + word-break: break-all; + overflow-wrap: anywhere; +} + +.admin-setting-meta small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.5; + overflow-wrap: anywhere; +} + +.admin-setting-control { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-width: 0; +} + +.admin-setting-control .input, +.admin-setting-control input[type="text"], +.admin-setting-control input[type="number"] { + flex: 1 1 160px; + min-width: 0; + width: 100%; +} + +.admin-setting-control .admin-color { + width: 36px; + height: 36px; + flex: 0 0 36px; + border-radius: 8px; + border: 1px solid var(--admin-border); + background: var(--admin-surface-2); + padding: 0; + cursor: pointer; +} + +.admin-switch { + display: inline-flex; + align-items: center; + gap: 10px; + font-size: 13px; + color: var(--admin-text); + cursor: pointer; + user-select: none; + position: relative; +} + +.admin-switch-track { + width: 36px; + height: 20px; + border-radius: 999px; + background: var(--admin-elev); + border: 1px solid var(--admin-border); + position: relative; + transition: background 0.18s ease, border-color 0.18s ease; +} + +.admin-switch-track::after { + content: ""; + position: absolute; + top: 1px; + left: 1px; + width: 16px; + height: 16px; + border-radius: 999px; + background: #fff; + transition: transform 0.18s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); +} + +.admin-switch input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.admin-switch input:checked + .admin-switch-track { + background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +.admin-switch input:checked + .admin-switch-track::after { + transform: translateX(15px); +} + +.admin-tabs { + display: inline-flex; + border-bottom: 1px solid var(--admin-border); + gap: 2px; +} + +.admin-tabs button { + padding: 9px 12px; + border: 0; + background: transparent; + color: var(--admin-muted); + font-size: 13px; + font-weight: 500; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; +} + +.admin-tabs button.active { + color: var(--admin-text); + border-bottom-color: var(--accent); +} + +/* Admin dialogs don't share the user-facing bottom-nav, so reset the .dialog + wrapper's padding (which reserves space for the mobile nav) and let the + card use the full viewport for its scroll area. */ +.dialog:has(.admin-dialog) { + padding: max(12px, env(safe-area-inset-top)) + max(12px, env(safe-area-inset-right)) + max(12px, env(safe-area-inset-bottom)) + max(12px, env(safe-area-inset-left)); + z-index: 90; +} + +.admin-dialog { + max-height: 100%; + width: 100%; + overflow-y: auto; + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; +} + +.admin-tariff-grid { + display: grid; + grid-template-columns: minmax(0, 1fr); + gap: 12px; +} + +@media (min-width: 720px) { + .admin-tariff-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (min-width: 1280px) { + .admin-tariff-grid { + grid-template-columns: repeat(3, minmax(0, 1fr)); + } +} + +.admin-tariff-card { + display: grid; + gap: 12px; + min-width: 0; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: var(--admin-surface-2); + padding: 14px; +} + +.admin-tariff-card.is-disabled { + opacity: 0.72; +} + +.admin-tariff-top, +.admin-tariff-title, +.admin-tariff-actions, +.admin-editor-section-head { + display: flex; + align-items: center; + gap: 8px; +} + +.admin-tariff-top { + justify-content: space-between; +} + +.admin-tariff-title { + min-width: 0; + flex-wrap: wrap; +} + +.admin-tariff-title strong { + color: var(--admin-text); + font-size: 15px; +} + +.admin-tariff-card code { + display: block; + margin-top: 4px; + color: var(--admin-dim); + font-family: var(--font-mono); + font-size: 11px; +} + +.admin-tariff-card p { + margin: 0; + color: var(--admin-muted); + font-size: 13px; + line-height: 1.45; +} + +.admin-tariff-facts { + display: grid; + gap: 6px; + color: var(--admin-muted); + font-size: 12px; +} + +.admin-tariff-facts span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.admin-tariff-actions { + flex-wrap: wrap; +} + +.admin-tariff-dialog { + width: min(980px, calc(100vw - 32px)); +} + +.admin-editor-section { + display: grid; + gap: 10px; + border: 1px solid var(--admin-border); + border-radius: 8px; + background: color-mix(in srgb, var(--admin-surface-2) 62%, transparent); + padding: 12px; +} + +.admin-editor-section-head { + justify-content: space-between; + min-height: 28px; +} + +.admin-editor-section-head strong { + color: var(--admin-text); + font-size: 13px; +} + +.admin-editor-section-head > div { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.admin-row-editor { + display: grid; + gap: 8px; + min-width: 0; +} + +.admin-row-editor-line { + display: grid; + grid-template-columns: minmax(90px, 1fr) minmax(110px, 1fr) 32px; + gap: 8px; + align-items: center; +} + +.admin-row-editor-line.admin-row-editor-4 { + grid-template-columns: minmax(80px, 0.8fr) minmax(100px, 1fr) minmax(100px, 1fr) 32px; +} + +.admin-row-editor-line .admin-btn { + width: 32px; + padding: 0; +} + +.admin-package-columns { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.admin-dialog-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding-top: 2px; +} + +@media (max-width: 1023px) { + .admin-screen-wrap { + --admin-header-h: 56px; + } + + .admin-sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 280px; + flex: 0 0 auto; + z-index: 80; + transform: translateX(-100%); + transition: transform 0.22s ease; + box-shadow: 0 32px 80px rgba(0, 0, 0, 0.6); + } + + .admin-screen-wrap.is-sidebar-open .admin-sidebar { + transform: translateX(0); + } + + .admin-sidebar-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 70; + border: 0; + cursor: pointer; + } + + .admin-mobile-toggle { + display: inline-flex; + } + + .admin-header { + padding: 8px 16px; + grid-template-columns: minmax(0, 1fr); + grid-auto-flow: row; + grid-auto-rows: auto; + align-content: center; + gap: 6px; + min-height: var(--admin-header-h); + } + + .admin-header-actions { + overflow-x: auto; + padding-bottom: 2px; + scrollbar-width: none; + } + + .admin-header-actions::-webkit-scrollbar { + display: none; + } + + .admin-main { + padding: 16px; + } +} + +@media (max-width: 720px) { + .admin-setting { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + padding: 14px 14px; + } + + .admin-card-head { + padding: 12px 14px; + } + + .admin-card-body { + padding: 14px; + } + + .admin-tariff-card { + padding: 12px; + } + + .admin-tariff-actions .admin-btn { + flex: 1 1 calc(50% - 4px); + } + + .admin-tariff-actions .admin-btn[aria-label] { + flex: 0 0 auto; + } + + /* (legacy three-row user-row layout removed in favour of the compact + single-row layout defined above) */ + + .admin-user-meta strong { + font-size: 11px; + } + + .admin-form-row.admin-form-row-3, + .admin-package-columns, + .admin-row-editor-line, + .admin-row-editor-line.admin-row-editor-4 { + grid-template-columns: 1fr; + } + + .admin-dialog-actions { + justify-content: stretch; + flex-direction: column-reverse; + } + + .admin-dialog-actions .admin-btn { + width: 100%; + } + + .admin-table thead { + display: none; + } + + .admin-table tbody td { + display: block; + padding: 6px 16px; + border: 0; + } + + .admin-table tbody tr { + display: block; + padding: 10px 0; + border-bottom: 1px solid var(--admin-border); + } + + .admin-table tbody tr:last-child { + border-bottom: 0; + } + + .admin-table tbody td::before { + content: attr(data-label); + display: inline-block; + min-width: 110px; + color: var(--admin-muted); + font-size: 11px; + text-transform: uppercase; + letter-spacing: 0.05em; + margin-right: 8px; + } + + .admin-table td.admin-cell-actions { + text-align: left; + } +} + +.settings-admin-block { + display: grid; + gap: 10px; + margin: 6px 0 10px; +} + +.settings-row.settings-row-admin { + border: 1px solid color-mix(in srgb, var(--accent) 36%, transparent); + background: color-mix(in srgb, var(--accent) 9%, transparent); + border-radius: var(--radius); + padding: 10px 12px; + width: 100%; + cursor: pointer; + transition: background 0.12s ease, border-color 0.12s ease; +} + +.settings-row.settings-row-admin:hover { + background: color-mix(in srgb, var(--accent) 16%, transparent); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +.settings-row.settings-row-admin > svg:first-child { + color: var(--accent); + opacity: 1; +} + +.settings-row.settings-row-admin strong { + color: var(--text); +} + +/* ============================================================ + shadcn-svelte primitives: Tabs / Select / Switch / Label + bits-ui exposes data-state attributes; styling here matches + shadcn-svelte defaults adapted to the dark admin palette. + ============================================================ */ + +.admin-tabs-root { + display: flex; + flex-direction: column; + gap: 14px; + min-width: 0; +} + +.admin-tabs-list { + display: inline-flex; + align-items: center; + gap: 4px; + padding: 4px; + border-radius: 10px; + background: var(--admin-surface-2); + border: 1px solid var(--admin-border); + overflow-x: auto; + scrollbar-width: none; + flex: 0 0 auto; +} + +.admin-tabs-list::-webkit-scrollbar { + display: none; +} + +.admin-tabs-trigger { + appearance: none; + border: 0; + background: transparent; + color: var(--admin-muted); + font: inherit; + font-size: 13px; + font-weight: 500; + padding: 7px 14px; + border-radius: 7px; + cursor: pointer; + white-space: nowrap; + transition: background 0.12s ease, color 0.12s ease, box-shadow 0.12s ease; + outline: none; +} + +.admin-tabs-trigger:hover { + color: var(--admin-text); +} + +.admin-tabs-trigger[data-state="active"] { + background: var(--admin-surface); + color: var(--admin-text); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.4); +} + +.admin-tabs-trigger:focus-visible { + box-shadow: 0 0 0 2px var(--admin-ring); +} + +.admin-tabs-content { + display: flex; + flex-direction: column; + gap: 14px; + outline: none; +} + +.admin-tabs-content[data-state="inactive"] { + display: none; +} + +/* Switch (bits-ui Switch.Root + Thumb) */ +.admin-switch-root { + appearance: none; + position: relative; + display: inline-flex; + align-items: center; + width: 40px; + height: 22px; + flex: 0 0 auto; + padding: 0; + border-radius: 999px; + border: 1px solid var(--admin-border); + background: var(--admin-elev); + cursor: pointer; + transition: background 0.18s ease, border-color 0.18s ease; + outline: none; +} + +.admin-switch-root[data-state="checked"] { + background: color-mix(in srgb, var(--accent) 80%, var(--admin-elev)); + border-color: color-mix(in srgb, var(--accent) 50%, transparent); +} + +.admin-switch-root:focus-visible { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 30%, transparent); +} + +.admin-switch-root:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.admin-switch-thumb { + display: block; + width: 16px; + height: 16px; + border-radius: 999px; + background: #fff; + margin-left: 2px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.4); + transition: transform 0.18s ease; +} + +.admin-switch-root[data-state="checked"] .admin-switch-thumb { + transform: translateX(18px); +} + +/* Select (bits-ui Select.Root + Trigger + Content + Item) */ +.admin-select-trigger { + display: inline-flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: 100%; + height: 36px; + padding: 0 12px; + border: 1px solid var(--admin-border-strong); + border-radius: 8px; + background: var(--admin-bg); + color: var(--admin-text); + font: inherit; + font-size: 13px; + cursor: pointer; + text-align: left; + outline: none; + transition: border-color 0.12s ease, box-shadow 0.12s ease; +} + +.admin-select-trigger:hover { + border-color: var(--admin-border-strong); +} + +.admin-select-trigger:focus-visible, +.admin-select-trigger[data-state="open"] { + border-color: var(--admin-ring); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 20%, transparent); +} + +.admin-select-icon { + color: var(--admin-muted); + transition: transform 0.18s ease; + flex: 0 0 auto; +} + +.admin-select-trigger[data-state="open"] .admin-select-icon { + transform: rotate(180deg); +} + +.admin-select-content { + z-index: 90; + min-width: var(--bits-select-anchor-width); + max-height: var(--bits-select-content-available-height, 320px); + overflow-y: auto; + border: 1px solid var(--admin-border); + border-radius: 10px; + background: var(--admin-surface); + padding: 4px; + box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45); + outline: none; + display: flex; + flex-direction: column; + gap: 1px; +} + +.admin-select-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 8px 10px; + border-radius: 7px; + font-size: 13px; + color: var(--admin-text); + cursor: pointer; + outline: none; + user-select: none; +} + +.admin-select-item[data-highlighted], +.admin-select-item:hover { + background: var(--admin-surface-2); +} + +.admin-select-item-check { + color: var(--accent); + opacity: 0; +} + +.admin-select-item[data-state="checked"] .admin-select-item-check { + opacity: 1; +} + +/* Label primitive (bits-ui Label.Root) */ +.admin-field-label { + display: flex; + flex-direction: column; + gap: 6px; + font-size: 12px; + color: var(--admin-muted); + font-weight: 500; + min-width: 0; +} + +.admin-field-label > span { + color: var(--admin-text); + font-weight: 500; + font-size: 13px; +} + +.admin-field-label > small { + color: var(--admin-dim); + font-weight: 400; + font-size: 12px; + line-height: 1.45; +} + +/* Action rows in dialogs */ +.admin-action-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 0; + min-width: 0; +} + +.admin-action-row-bordered { + border: 1px solid var(--admin-border); + border-radius: 10px; + padding: 12px 14px; + background: var(--admin-surface-2); +} + +.admin-action-label { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; + font-size: 13px; + color: var(--admin-text); + cursor: pointer; +} + +.admin-action-label > strong { + font-weight: 600; +} + +.admin-action-label > small { + color: var(--admin-muted); + font-size: 12px; + line-height: 1.4; +} + +.admin-action-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); + gap: 8px; + align-items: stretch; +} + +.admin-input-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; + align-items: center; +} + +.admin-input-row .input { + height: 34px; +} + +/* Two-column variant of admin-form-row */ +.admin-form-row.admin-form-row-2 { + grid-template-columns: repeat(2, minmax(0, 1fr)); +} + +@media (max-width: 720px) { + .admin-form-row.admin-form-row-2 { + grid-template-columns: minmax(0, 1fr); + } +} + +/* User summary header inside the user dialog */ +.admin-user-summary { + display: grid; + grid-template-columns: 56px minmax(0, 1fr); + align-items: center; + gap: 14px; + padding: 14px 14px 16px; + border-radius: 12px; + background: var(--admin-surface-2); + border: 1px solid var(--admin-border); + margin-bottom: 14px; + min-width: 0; +} + +.admin-user-summary-meta { + display: grid; + gap: 4px; + min-width: 0; +} + +.admin-user-summary-meta strong { + font-size: 15px; + font-weight: 700; + color: var(--admin-text); + word-break: break-word; +} + +.admin-user-summary-meta small { + color: var(--admin-muted); + font-size: 12px; + word-break: break-word; +} + +.admin-user-summary-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 4px; +} + +.admin-avatar.admin-avatar-lg { + width: 56px; + height: 56px; + border-radius: 16px; + overflow: hidden; + display: grid; + place-items: center; + background: color-mix(in srgb, var(--accent) 18%, var(--admin-surface)); + color: var(--accent); + font-weight: 700; + font-size: 18px; + flex: 0 0 auto; +} + +.admin-avatar.admin-avatar-lg img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.admin-subsection-title { + font-size: 12px; + font-weight: 600; + color: var(--admin-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.admin-separator { + height: 1px; + background: var(--admin-border); + border: 0; + margin: 4px 0; +} + +/* Editor section header tweaks */ +.admin-editor-section-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.admin-row-editor-caption { + font-size: 11px; + font-weight: 600; + color: var(--admin-muted); + text-transform: uppercase; + letter-spacing: 0.05em; +} + + +/* Action sections inside the user-detail dialog */ +.admin-action-section { + border: 1px solid var(--admin-border); + border-radius: 12px; + background: var(--admin-surface); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.admin-action-section-head { + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-action-section-head strong { + font-size: 13px; + font-weight: 600; + color: var(--admin-text); +} + +.admin-action-section-head small { + color: var(--admin-muted); + font-size: 12px; +} + +.admin-danger-zone { + border: 1px solid color-mix(in srgb, #ff6b6b 32%, transparent); + border-radius: 12px; + background: color-mix(in srgb, #ff6b6b 6%, var(--admin-surface)); + padding: 14px 16px; + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 4px; +} + +.admin-danger-zone-head { + display: flex; + flex-direction: column; + gap: 2px; +} + +.admin-danger-zone-head strong { + font-size: 13px; + font-weight: 700; + color: #ffb4b4; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.admin-danger-zone-head small { + color: color-mix(in srgb, #ffb4b4 70%, var(--admin-muted)); + font-size: 12px; + line-height: 1.45; +} + +/* Subsection grouping inside settings sections (per-payment-provider). + Each subsection is a nested Accordion.Item that defaults to closed — + users see provider names and expand only the one they want to edit. */ +.admin-subsection-accordion { + display: flex; + flex-direction: column; + gap: 8px; + margin-top: 8px; +} + +.admin-settings-subsection { + border: 1px solid var(--admin-border); + border-radius: 10px; + background: color-mix(in srgb, var(--admin-surface-2) 40%, transparent); + overflow: hidden; +} + +.admin-settings-subsection-trigger { + appearance: none; + background: transparent; + border: 0; + text-align: left; + width: 100%; + padding: 10px 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto auto; + align-items: center; + gap: 12px; + color: var(--admin-text); + font: inherit; + cursor: pointer; + outline: none; + transition: background 0.12s ease; +} + +.admin-settings-subsection-trigger:hover { + background: var(--admin-surface); +} + +.admin-settings-subsection-trigger[data-state="open"] { + background: var(--admin-surface); + border-bottom: 1px solid var(--admin-border); +} + +.admin-settings-subsection-trigger:focus-visible { + background: var(--admin-surface); + box-shadow: inset 0 0 0 2px var(--admin-ring); +} + +.admin-settings-subsection-trigger strong { + font-size: 12px; + font-weight: 700; + color: var(--admin-text); + text-transform: uppercase; + letter-spacing: 0.06em; + min-width: 0; +} + +.admin-settings-subsection-meta { + color: var(--admin-muted); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.admin-settings-subsection-trigger .admin-accordion-chev { + color: var(--admin-muted); + transition: transform 0.18s ease; + flex: 0 0 auto; +} + +.admin-settings-subsection-trigger[data-state="open"] .admin-accordion-chev { + transform: rotate(90deg); +} + +.admin-settings-subsection-body .admin-setting:last-child { + border-bottom: 0; +} + +@media (max-width: 720px) { + .admin-settings-subsection-trigger { + padding: 9px 12px; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "title chev" + "meta meta"; + row-gap: 4px; + } + + .admin-settings-subsection-trigger strong { + grid-area: title; + } + + .admin-settings-subsection-trigger .admin-accordion-chev { + grid-area: chev; + } + + .admin-settings-subsection-meta { + grid-area: meta; + white-space: normal; + } +} diff --git a/bot/app/web/subscription_webapp.py b/bot/app/web/subscription_webapp.py index d350eb0..0de71f4 100644 --- a/bot/app/web/subscription_webapp.py +++ b/bot/app/web/subscription_webapp.py @@ -23,6 +23,10 @@ from pydantic import BaseModel, ConfigDict, EmailStr, ValidationError, constr, f from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import sessionmaker +from bot.app.web.admin_api import ( + admin_auth_middleware, + setup_admin_routes, +) from bot.app.web.webapp_auth import ( create_signed_telegram_oauth_state, create_telegram_oauth_nonce, @@ -148,7 +152,13 @@ def create_subscription_webapp_application( settings: Settings, async_session_factory: sessionmaker, ) -> web.Application: - app = web.Application(middlewares=[_security_headers_middleware, _csrf_protection_middleware]) + app = web.Application( + middlewares=[ + _security_headers_middleware, + _csrf_protection_middleware, + admin_auth_middleware, + ] + ) app["bot"] = bot app["dp"] = dp app["settings"] = settings @@ -179,6 +189,7 @@ def create_subscription_webapp_application( "severpay_service", "promo_code_service", "referral_service", + "panel_service", ): if hasattr(dp, "workflow_data") and key in dp.workflow_data: # type: ignore[attr-defined] app[key] = dp.workflow_data[key] # type: ignore[index] @@ -196,6 +207,8 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_get("/invite", index_route) app.router.add_get("/devices", index_route) app.router.add_get("/settings", index_route) + app.router.add_get("/admin", index_route) + app.router.add_get("/admin/{section:[a-z][a-z0-9_-]*}", index_route) app.router.add_get("/auth/telegram/start", telegram_oauth_start_route) app.router.add_get("/auth/telegram/callback", telegram_oauth_callback_route) app.router.add_get("/health", health_route) @@ -226,6 +239,7 @@ def setup_subscription_webapp_routes(app: web.Application) -> None: app.router.add_post("/api/tariffs/change-payment", tariff_change_payment_route) app.router.add_post("/api/payments", create_payment_route) app.router.add_get("/api/payments/{payment_id}", payment_status_route) + setup_admin_routes(app) async def health_route(request: web.Request) -> web.Response: @@ -2942,6 +2956,8 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A await session.rollback() lang = _normalize_language(db_user.language_code or settings.DEFAULT_LANGUAGE) + admin_ids = {int(x) for x in (settings.ADMIN_IDS or [])} + is_admin = bool(db_user.telegram_id and int(db_user.telegram_id) in admin_ids) return { "user": { "id": user_id, @@ -2953,6 +2969,7 @@ async def _build_user_payload(request: web.Request, user_id: int) -> Dict[str, A "telegram_photo_url": _telegram_avatar_url(avatar), "first_name": db_user.first_name, "language_code": lang, + "is_admin": is_admin, }, "subscription": _serialize_subscription(active, local_sub, lang), "referral": { diff --git a/bot/services/settings_override_service.py b/bot/services/settings_override_service.py new file mode 100644 index 0000000..23bf673 --- /dev/null +++ b/bot/services/settings_override_service.py @@ -0,0 +1,164 @@ +"""Apply persisted setting overrides on top of the env-based Settings. + +The runtime treats DB overrides as the source of truth: env values are +loaded once via pydantic, then any matching keys from the +``app_setting_overrides`` table replace those attributes in-process. +This way the admin can flip flags, adjust prices or rename labels +without restarting the container. +""" + +from __future__ import annotations + +import logging +from typing import Any, Dict, Optional + +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import sessionmaker + +from bot.app.web.admin_settings_manifest import ( + SettingField, + coerce_value, + get_field_by_key, + manifest_keys, +) +from config.settings import Settings +from db.dal import app_settings_dal + +logger = logging.getLogger(__name__) + + +def _resolve_attribute_name(settings: Settings, key: str) -> Optional[str]: + """Resolve the actual attribute name on the Settings model. + + Some settings expose their env name via ``alias`` (e.g. MONTH_1_ENABLED is + aliased to "1_MONTH_ENABLED"). Lookups by either alias or attribute name + should both succeed, with the attribute name returned in either case. + """ + + if hasattr(settings, key): + return key + + fields = type(settings).model_fields + for attr_name, field_info in fields.items(): + alias = getattr(field_info, "alias", None) + if alias and alias == key: + return attr_name + return None + + +def _apply_value(settings: Settings, key: str, value: Any) -> bool: + attr_name = _resolve_attribute_name(settings, key) + if not attr_name: + return False + try: + setattr(settings, attr_name, value) + return True + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to apply override %s=%r: %s", key, value, exc) + return False + + +def apply_overrides(settings: Settings, overrides: Dict[str, Any]) -> int: + applied = 0 + for key, raw_value in overrides.items(): + field = get_field_by_key(key) + if not field: + continue + try: + coerced = coerce_value(field, raw_value) + except ValueError as exc: + logger.warning("Skipping override %s: %s", key, exc) + continue + if _apply_value(settings, key, coerced): + applied += 1 + return applied + + +async def load_overrides_from_db( + settings: Settings, async_session_factory: sessionmaker +) -> int: + """Fetch overrides from the DB and apply them to the in-memory settings.""" + + try: + async with async_session_factory() as session: + overrides = await app_settings_dal.get_all_overrides(session) + except Exception as exc: + logger.warning("Could not load setting overrides from DB: %s", exc) + return 0 + + applied = apply_overrides(settings, overrides) + if applied: + logger.info("Applied %s setting overrides from DB", applied) + return applied + + +async def update_overrides( + settings: Settings, + async_session_factory: sessionmaker, + *, + updates: Dict[str, Any], + deletes: Optional[list] = None, + actor_id: Optional[int] = None, +) -> Dict[str, Any]: + """Persist + apply a batch of changes coming from the admin UI.""" + + deletes = list(deletes or []) + coerced_updates: Dict[str, Any] = {} + errors: Dict[str, str] = {} + + for key, raw in updates.items(): + field: Optional[SettingField] = get_field_by_key(key) + if not field: + errors[key] = "unknown_setting" + continue + try: + coerced_updates[key] = coerce_value(field, raw) + except ValueError as exc: + errors[key] = str(exc) + + valid_deletes = [] + for key in deletes: + if get_field_by_key(key) is None: + errors.setdefault(key, "unknown_setting") + continue + valid_deletes.append(key) + + if errors: + return {"ok": False, "errors": errors} + + async with async_session_factory() as session: # type: AsyncSession + async with session.begin(): + for key, value in coerced_updates.items(): + await app_settings_dal.upsert_override( + session, key=key, value=value, updated_by=actor_id + ) + for key in valid_deletes: + await app_settings_dal.delete_override(session, key) + + # Apply locally; deletes need an env-default fallback. We re-read the env + # default by instantiating a fresh Settings() (cheap; just a few ms) and + # copying the matching attributes back over. + if valid_deletes: + try: + env_only = Settings() + for key in valid_deletes: + attr_name = _resolve_attribute_name(env_only, key) or key + if hasattr(env_only, attr_name): + setattr(settings, attr_name, getattr(env_only, attr_name)) + except Exception as exc: # pragma: no cover - defensive + logger.warning("Failed to restore env defaults: %s", exc) + + apply_overrides(settings, coerced_updates) + + return {"ok": True, "applied": len(coerced_updates), "reverted": len(valid_deletes)} + + +def overridable_keys() -> list: + return list(manifest_keys()) + + +def current_value(settings: Settings, key: str) -> Any: + attr_name = _resolve_attribute_name(settings, key) + if not attr_name: + return None + return getattr(settings, attr_name, None) diff --git a/db/dal/__init__.py b/db/dal/__init__.py index d85402a..15347e7 100644 --- a/db/dal/__init__.py +++ b/db/dal/__init__.py @@ -7,6 +7,7 @@ from . import message_log_dal from . import user_billing_dal from . import ad_dal from . import security_dal +from . import app_settings_dal __all__ = ( "user_dal", @@ -18,6 +19,7 @@ __all__ = ( "user_billing_dal", "ad_dal", "security_dal", + "app_settings_dal", ) diff --git a/db/dal/app_settings_dal.py b/db/dal/app_settings_dal.py new file mode 100644 index 0000000..a7ea1a5 --- /dev/null +++ b/db/dal/app_settings_dal.py @@ -0,0 +1,100 @@ +"""Persistent overrides for application settings. + +Overrides take priority over `.env` values for keys exposed via the admin +manifest. Values are stored as JSON-encoded text to preserve typing across +strings, booleans, integers and floats. +""" + +import json +import logging +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional, Tuple + +from sqlalchemy import delete, select +from sqlalchemy.dialects.postgresql import insert as pg_insert +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import AppSettingOverride + +logger = logging.getLogger(__name__) + + +def _encode(value: Any) -> str: + return json.dumps(value, ensure_ascii=False, separators=(",", ":")) + + +def _decode(raw: Optional[str]) -> Any: + if raw is None: + return None + try: + return json.loads(raw) + except (TypeError, ValueError): + return raw + + +async def get_all_overrides(session: AsyncSession) -> Dict[str, Any]: + rows = (await session.execute(select(AppSettingOverride))).scalars().all() + return {row.key: _decode(row.value) for row in rows} + + +async def get_overrides_with_meta(session: AsyncSession) -> List[Dict[str, Any]]: + rows = (await session.execute(select(AppSettingOverride))).scalars().all() + items: List[Dict[str, Any]] = [] + for row in rows: + items.append( + { + "key": row.key, + "value": _decode(row.value), + "updated_at": row.updated_at.isoformat() if row.updated_at else None, + "updated_by": row.updated_by, + } + ) + return items + + +async def upsert_override( + session: AsyncSession, + *, + key: str, + value: Any, + updated_by: Optional[int], +) -> None: + encoded = _encode(value) + now = datetime.now(timezone.utc) + stmt = ( + pg_insert(AppSettingOverride) + .values(key=key, value=encoded, updated_at=now, updated_by=updated_by) + .on_conflict_do_update( + index_elements=[AppSettingOverride.key], + set_={ + "value": encoded, + "updated_at": now, + "updated_by": updated_by, + }, + ) + ) + await session.execute(stmt) + + +async def delete_override(session: AsyncSession, key: str) -> bool: + stmt = delete(AppSettingOverride).where(AppSettingOverride.key == key) + result = await session.execute(stmt) + return bool(result.rowcount or 0) + + +async def bulk_apply( + session: AsyncSession, + *, + updates: Dict[str, Tuple[bool, Any]], + updated_by: Optional[int], +) -> None: + """Apply a batch of changes. Each entry maps key -> (set_flag, value). + + When set_flag is False the override is deleted (revert to env). Otherwise + the value is upserted. + """ + for key, (set_flag, value) in updates.items(): + if set_flag: + await upsert_override(session, key=key, value=value, updated_by=updated_by) + else: + await delete_override(session, key) diff --git a/db/database_setup.py b/db/database_setup.py index 9a94bfc..abe15f3 100644 --- a/db/database_setup.py +++ b/db/database_setup.py @@ -70,6 +70,14 @@ async def init_db(settings: Settings, session_factory: sessionmaker): "PostgreSQL database initialized/checked successfully using SQLAlchemy." ) + try: + from bot.services.settings_override_service import load_overrides_from_db + await load_overrides_from_db(settings, session_factory) + except Exception as e_overrides: + logging.warning( + f"Failed to load setting overrides on startup: {e_overrides}" + ) + async with session_factory() as session: from .dal.panel_sync_dal import get_panel_sync_status, update_panel_sync_status from sqlalchemy import text diff --git a/db/migrator.py b/db/migrator.py index 4ad3bfa..99b20ec 100644 --- a/db/migrator.py +++ b/db/migrator.py @@ -531,6 +531,22 @@ MIGRATIONS: List[Migration] = [ description="Add tariff catalog columns and traffic accounting tables", upgrade=_migration_0012_add_tariffs_schema, ), + Migration( + id="0013_add_app_setting_overrides", + description="Persisted runtime overrides for application settings managed via admin webapp", + upgrade=lambda connection: connection.execute( + text( + """ + CREATE TABLE IF NOT EXISTS app_setting_overrides ( + key VARCHAR(128) PRIMARY KEY, + value TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_by BIGINT + ) + """ + ) + ), + ), ] diff --git a/db/models.py b/db/models.py index e406aca..0dd58e8 100644 --- a/db/models.py +++ b/db/models.py @@ -415,3 +415,17 @@ class AdAttribution(Base): user = relationship("User") campaign = relationship("AdCampaign", back_populates="attributions") + + +class AppSettingOverride(Base): + __tablename__ = "app_setting_overrides" + + key = Column(String(128), primary_key=True) + value = Column(Text, nullable=True) + updated_at = Column( + DateTime(timezone=True), + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) + updated_by = Column(BigInteger, nullable=True) diff --git a/docs/tariffs.md b/docs/tariffs.md index 0d6bebf..9a7e9be 100644 --- a/docs/tariffs.md +++ b/docs/tariffs.md @@ -7,6 +7,21 @@ JSON-каталог может содержать несколько тарифов разных моделей: подписки на срок, пакеты трафика без срока действия, разные наборы Internal Squads, лимиты устройств и пакеты докупки. Пример формата: [config/tariffs.example.json](../config/tariffs.example.json). +## Управление через админку + +Каталог тарифов можно настраивать из Web App админки: раздел **Система → Тарифы**. Админка читает и сохраняет файл из `TARIFFS_CONFIG_PATH`, валидирует данные той же моделью `TariffsConfig`, что и бот, и атомарно перезаписывает JSON только после успешной проверки. + +В интерфейсе доступны: + +- добавление, редактирование и удаление тарифов; +- включение и выключение тарифа на витрине; +- выбор тарифа по умолчанию; +- настройка `period`-тарифов: месячный лимит, периоды, RUB/Stars цены, пакеты докупки трафика; +- настройка `traffic`-тарифов: пакеты GB, RUB/Stars цены, курс конвертации; +- настройка Internal Squads, базового HWID-лимита и пакетов докупки устройств. + +После сохранения изменения применяются к новым запросам Web App сразу, потому что конфиг тарифов загружается из JSON при обращении. Уже созданные подписки сохраняют свой `tariff_key`; при удалении или отключении тарифа проверьте, что активные подписки с этим ключом не требуют дальнейшего продления или смены. + ## Как выбирается режим Если файл из `TARIFFS_CONFIG_PATH` существует и проходит валидацию, используется каталог тарифов. В этом режиме `TRAFFIC_PACKAGES` и цены подписок из `.env` не формируют витрину продаж, потому что цены и пакеты берутся из JSON.