feat: admin dashboard

This commit is contained in:
3252a8
2026-05-09 22:32:44 +03:00
parent 0a9d929181
commit 6f9a87cf98
13 changed files with 6893 additions and 11 deletions
File diff suppressed because it is too large Load Diff
+261
View File
@@ -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
+205 -10
View File
@@ -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 @@
</div>
{/if}
</div>
{:else if screen === "admin" && isAdmin}
<AdminPanel
api={api}
onClose={closeAdminPanel}
onToast={(text) => showToast(text)}
initialSection={adminSectionFromPath(window.location.pathname)}
onSectionChange={handleAdminSectionChange}
/>
{:else}
<div class="phone-screen">
<div class="phone-screen" class:home-screen={screen === "home"}>
{#if screen === "invite" || screen === "devices" || screen === "settings"}
<header class="app-header accent-title">
<div class="brand-row">
@@ -2977,6 +3148,20 @@
<small>{profileTelegramId}</small>
</div>
</Card>
{#if isAdmin}
<div class="settings-admin-block">
<div class="settings-divider" aria-hidden="true"></div>
<button class="settings-row settings-row-admin" type="button" on:click={openAdminPanel}>
<Shield size={21} />
<span>
<strong>Админ-панель</strong>
<small>Управление приложением</small>
</span>
<ArrowRight size={17} />
</button>
<div class="settings-divider" aria-hidden="true"></div>
</div>
{/if}
<div class="settings-links-block">
<div class="settings-divider" aria-hidden="true"></div>
{#if user?.telegram_linked}
@@ -3068,27 +3253,27 @@
</Select.Root>
</div>
{#if supportUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(supportUrl)}>
<button class="settings-row settings-row-support" type="button" on:click={() => openExternalLink(supportUrl)}>
<Send size={21} />
<span><strong>{t("menu_support_button")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#if userAgreementUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(userAgreementUrl)}>
<button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(userAgreementUrl)}>
<FileText size={21} />
<span><strong>{t("wa_settings_user_agreement")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
{#if privacyPolicyUrl}
<button class="settings-row" type="button" on:click={() => openExternalLink(privacyPolicyUrl)}>
<button class="settings-row settings-row-policy" type="button" on:click={() => openExternalLink(privacyPolicyUrl)}>
<Shield size={21} />
<span><strong>{t("wa_settings_privacy_policy")}</strong></span>
<ArrowRight size={17} />
</button>
{/if}
<button class="settings-row" type="button" on:click={logout}>
<button class="settings-row settings-row-logout" type="button" on:click={logout}>
<UserRound size={21} />
<span><strong>{t("wa_logout")}</strong><small>{t("wa_end_session")}</small></span>
<ArrowRight size={17} />
@@ -3099,6 +3284,10 @@
{#if screen === "home" || screen === "invite" || screen === "devices" || screen === "settings"}
<nav class:bottom-nav-devices={devicesEnabled} class="bottom-nav" aria-label={t("wa_navigation")}>
<div class="rail-brand" aria-hidden="true">
<BrandMark logoUrl={CFG.logoUrl} emoji={brandEmoji} />
<strong>{brandTitle}</strong>
</div>
<button class:active={activeTab === "home"} type="button" on:click={goHome}>
<Home size={21} />
<span>{t("wa_nav_home")}</span>
@@ -3120,6 +3309,12 @@
<SettingsIcon size={21} />
<span>{t("wa_nav_settings")}</span>
</button>
{#if isAdmin}
<button class="rail-admin-entry" type="button" on:click={openAdminPanel}>
<Shield size={21} />
<span>Админ-панель</span>
</button>
{/if}
</nav>
{/if}
</div>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+18 -1
View File
@@ -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": {