2026-09-10 17:45:43 +05:00
<!doctype html>
< html lang = "ru" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > MBS Panel< / title >
< link rel = "preconnect" href = "https://fonts.gstatic.com" crossorigin >
< link href = "https://fonts.googleapis.com/css2?family=Montserrat:wght@400;500;600;700&family=Fira+Mono:wght@500;700&display=swap" rel = "stylesheet" >
< style >
:root {
/* Tokens lifted from remnawave/frontend's actual Mantine theme.ts (GitHub-dark scale, cyan primary). */
--bg: #0d1117; --sidebar: #010409; --card-tint: rgba(255,255,255,0.02); --card2-tint: rgba(255,255,255,0.045);
--border: rgba(255,255,255,0.08); --border-strong: rgba(255,255,255,0.14);
--text: #c9d1d9; --text-dim: #b1bac4; --muted: #8b949e; --muted2: #6e7681;
--accent: #22b8cf; --accent-deep: #0c8599; --accent-dim: rgba(34,184,207,0.12); --accent-border: rgba(34,184,207,0.35);
--green: #3fb950; --red: #f85149; --yellow: #d29922; --blue: #58a6ff; --pink: #db61a2;
--ease: cubic-bezier(0.16, 1, 0.3, 1);
--radius: 8px;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--text);
font-family: Montserrat, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
-webkit-font-smoothing: antialiased; font-size: 14px;
}
2026-09-10 23:25:37 +05:00
* { scrollbar-color: var(--border-strong) transparent; scrollbar-width: thin; }
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 10px; border: 2px solid var(--bg); background-clip: padding-box; }
::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
2026-09-10 17:45:43 +05:00
button, input, select { font-family: inherit; font-size: 14px; }
a { color: var(--accent); text-decoration: none; }
/* ---------- Login ---------- */
#login-screen {
min-height: 100vh; display: flex; align-items: center; justify-content: center; padding: 24px;
}
.login-card {
max-width: 340px; width: 100%; background: var(--card-tint); border: 1px solid var(--border);
border-radius: 14px; padding: 32px 28px;
opacity: 0; animation: fadeIn 0.25s linear forwards;
}
@keyframes fadeIn { to { opacity: 1; } }
@keyframes enter { to { opacity: 1; transform: translateY(0); filter: blur(0); } }
.splash { display: flex; flex-direction: column; align-items: center; text-align: center; padding-bottom: 22px; }
.splash-mark {
width: 44px; height: 44px; border-radius: 13px; margin-bottom: 14px;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
display: flex; align-items: center; justify-content: center;
opacity: 0; transform: scale(0.6) rotate(-8deg); filter: blur(4px);
animation: splashMark 0.6s var(--ease) 0.05s forwards;
}
.splash-mark svg { width: 24px; height: 24px; }
.splash-title {
font-size: 19px; font-weight: 700; letter-spacing: -0.01em;
opacity: 0; transform: translateY(8px); filter: blur(3px);
animation: splashRise 0.5s var(--ease) 0.28s forwards;
}
.splash-tagline {
font-size: 11.5px; color: var(--muted2); letter-spacing: 0.04em; margin-top: 4px;
opacity: 0;
animation: splashFade 0.5s var(--ease) 0.5s forwards;
}
@keyframes splashMark { to { opacity: 1; transform: scale(1) rotate(0deg); filter: blur(0); } }
@keyframes splashRise { to { opacity: 1; transform: translateY(0); filter: blur(0); } }
@keyframes splashFade { to { opacity: 1; } }
.login-card h1 { font-size: 17px; margin: 0 0 4px; font-weight: 600; }
.login-card p { color: var(--muted); font-size: 13px; margin: 0 0 20px; }
input[type=password], input[type=text] {
width: 100%; background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius);
padding: 11px 13px; color: var(--text); transition: border-color 0.2s var(--ease);
}
input:focus { outline: none; border-color: var(--accent); }
.btn {
padding: 10px 18px; border-radius: var(--radius); border: 1px solid var(--accent-border); cursor: pointer;
background: linear-gradient(135deg, rgba(34,184,207,0.15) 0%, rgba(12,133,153,0.1) 100%); color: var(--accent);
font-weight: 600; transition: all 0.2s ease;
}
.btn:hover { background: rgba(34,184,207,0.22); }
.btn:active { transform: scale(0.97); }
.btn.block { width: 100%; margin-top: 14px; }
.btn.ghost { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn.ghost:hover { color: var(--text); border-color: var(--border-strong); background: rgba(255,255,255,0.03); }
.btn.danger { background: rgba(248,81,73,0.1); color: var(--red); border: 1px solid rgba(248,81,73,0.3); }
.btn.danger:hover { background: rgba(248,81,73,0.18); }
#login-err { color: var(--red); font-size: 13px; min-height: 16px; margin-top: 10px; }
/* ---------- App shell ---------- */
#app { display: none; min-height: 100vh; grid-template-columns: 216px 1fr; }
#app.show { display: grid; }
.sidebar {
background: var(--sidebar); border-right: 1px solid var(--border); padding: 18px 10px;
display: flex; flex-direction: column;
}
.brand { display: flex; align-items: center; gap: 9px; font-weight: 600; font-size: 14px; padding: 8px 10px 22px; }
.brand .mark {
width: 22px; height: 22px; border-radius: 7px; flex: none;
background: linear-gradient(135deg, var(--accent), var(--accent-deep));
display: flex; align-items: center; justify-content: center;
}
.brand .mark svg { width: 13px; height: 13px; }
.nav-item {
display: flex; align-items: center; gap: 10px; padding: 9px 11px; border-radius: 8px;
color: var(--muted); cursor: pointer; margin-bottom: 1px; font-size: 13.5px;
transition: background 0.2s var(--ease), color 0.2s var(--ease);
}
.nav-item svg { width: 16px; height: 16px; flex: none; opacity: 0.85; }
.nav-item:hover { background: var(--card2-tint); color: var(--text); }
.nav-item.active { background: var(--accent-dim); color: var(--accent); }
.nav-item.active svg { opacity: 1; }
.sidebar-footer { margin-top: auto; padding: 10px 0 0; }
2026-09-10 22:46:55 +05:00
.version-tag { text-align: center; font-size: 11px; color: var(--muted2); margin-top: 10px; font-family: "Fira Mono", ui-monospace, monospace; }
2026-09-10 17:45:43 +05:00
.main { padding: 30px 40px; max-width: 1120px; }
.page-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; letter-spacing: -0.01em; }
.page-sub { color: var(--muted); font-size: 13px; margin: 0 0 26px; }
.view { display: none; }
.view.active { display: block; }
.reveal {
opacity: 0; animation: fadeIn 0.25s linear forwards;
}
.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; background: var(--border); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 30px; }
.stat-card {
background: var(--bg); position: relative; padding: 14px 18px 16px;
transition: background 0.2s var(--ease);
}
.stat-card::before { content: ""; position: absolute; inset: 0; background: var(--card-tint); }
.stat-card:hover::before { background: var(--card2-tint); }
.stat-card > * { position: relative; }
.stat-card .l { color: var(--muted); font-size: 11.5px; margin-bottom: 10px; }
.stat-card .row { display: flex; align-items: center; gap: 8px; }
.stat-card svg { width: 16px; height: 16px; flex: none; }
.stat-card .v { font-size: 19px; font-weight: 700; font-variant-numeric: tabular-nums; letter-spacing: -0.01em; font-family: "Fira Mono", ui-monospace, monospace; }
table { width: 100%; border-collapse: collapse; }
.table-wrap {
background: var(--card-tint); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
}
th {
text-align: left; font-size: 12px; color: var(--muted); font-weight: 500;
padding: 12px 16px; border-bottom: 1px solid var(--border); text-transform: uppercase; letter-spacing: 0.04em;
}
td { padding: 13px 16px; border-bottom: 1px solid var(--border); font-size: 13.5px; }
tr:last-child td { border-bottom: none; }
tbody tr { transition: background 0.15s var(--ease); }
tbody tr:hover { background: var(--card2-tint); }
.badge {
display: inline-block; padding: 2px 9px; border-radius: var(--radius); font-size: 11.5px; font-weight: 600;
border: 1px solid; background: transparent;
}
.badge.ok { border-color: rgba(63,185,80,0.4); color: var(--green); }
.badge.bad { border-color: rgba(248,81,73,0.4); color: var(--red); }
.badge.warn { border-color: rgba(210,153,34,0.4); color: var(--yellow); }
.section { margin-bottom: 32px; }
.section-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.section-head h2 { font-size: 15px; margin: 0; font-weight: 600; }
.check-row { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 14px; }
.check { display: flex; align-items: flex-start; gap: 8px; font-size: 13.5px; cursor: pointer; }
.check input { margin-top: 2px; accent-color: var(--accent); }
.check-hint { color: var(--muted); font-size: 12px; }
.form-row { display: flex; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
.form-row > * { flex: 1; min-width: 140px; }
label.f { display: block; font-size: 12px; color: var(--muted); margin-bottom: 5px; }
/* ---------- Custom dropdown (replaces native < select > — no OS-rendered white
popup, animated open/close) ---------- */
.dd { position: relative; }
.dd-trigger {
width: 100%; display: flex; align-items: center; justify-content: space-between; gap: 8px;
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius);
padding: 11px 13px; color: var(--text); cursor: pointer; text-align: left;
transition: border-color 0.2s var(--ease);
}
.dd-trigger:hover { border-color: var(--border-strong); }
.dd.dd-open .dd-trigger { border-color: var(--accent); }
.dd-trigger-label.placeholder { color: var(--muted); }
.dd-chevron { width: 15px; height: 15px; color: var(--muted); flex: none; transition: transform 0.2s var(--ease); }
.dd.dd-open .dd-chevron { transform: rotate(180deg); }
.dd-menu {
position: absolute; top: calc(100% + 6px); left: 0; right: 0; z-index: 60;
background: #161b22; border: 1px solid var(--border-strong); border-radius: 10px;
padding: 6px; max-height: 264px; overflow: hidden; display: flex; flex-direction: column;
box-shadow: 0 12px 32px rgba(0,0,0,0.45);
opacity: 0; transform: translateY(-6px) scale(0.98); filter: blur(3px);
pointer-events: none; transition: opacity 0.16s var(--ease), transform 0.16s var(--ease), filter 0.16s var(--ease);
}
.dd-menu.show { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); pointer-events: auto; }
.dd-menu.closing { opacity: 0; transform: translateY(-4px) scale(0.99); filter: blur(2px); }
.dd-search {
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: 7px;
padding: 8px 10px; color: var(--text); margin-bottom: 6px; flex: none; width: 100%;
}
.dd-search:focus { outline: none; border-color: var(--accent); }
.dd-list { overflow-y: auto; }
.dd-option { padding: 9px 10px; border-radius: 7px; cursor: pointer; font-size: 13.5px; transition: background 0.12s var(--ease); }
.dd-option:hover { background: var(--card2-tint); }
.dd-option.selected { color: var(--accent); }
.dd-empty { padding: 10px; color: var(--muted); font-size: 13px; text-align: center; }
.tabs { display: flex; gap: 4px; margin-bottom: 18px; background: var(--card-tint); border: 1px solid var(--border); padding: 4px; border-radius: 10px; width: fit-content; }
.tab { padding: 8px 16px; border-radius: 7px; cursor: pointer; color: var(--muted); font-size: 13px; transition: all 0.2s var(--ease); }
.tab.active { background: var(--card2-tint); color: var(--text); }
.code-box {
background: rgba(255,255,255,0.03); border: 1px solid var(--border); border-radius: var(--radius); padding: 14px;
font-family: "Fira Mono", ui-monospace, monospace; font-size: 12.5px; color: var(--accent);
word-break: break-all; position: relative;
}
2026-09-10 23:25:37 +05:00
.doc-block { margin-bottom: 30px; padding-bottom: 26px; border-bottom: 1px solid var(--border); }
.doc-block:last-child { border-bottom: none; }
.doc-block h2 { font-size: 16px; margin: 0 0 12px; font-weight: 600; }
.doc-block p { font-size: 13.5px; color: var(--text-dim); line-height: 1.65; margin: 0 0 10px; }
.doc-block code { background: rgba(255,255,255,0.06); padding: 1px 6px; border-radius: 5px; font-family: "Fira Mono", ui-monospace, monospace; font-size: 12px; }
2026-09-10 17:45:43 +05:00
.copy-btn {
position: absolute; top: 8px; right: 8px; background: var(--card2-tint); border: 1px solid var(--border);
color: var(--muted); border-radius: 6px; padding: 4px 8px; font-size: 11px; cursor: pointer;
}
.copy-btn:hover { color: var(--text); }
.muted-btn { background: none; border: 1px solid transparent; color: var(--muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; font-size: 12.5px; transition: all 0.15s ease; }
.muted-btn:hover { color: var(--accent); border-color: var(--accent-border); background: var(--accent-dim); }
.empty { text-align: center; color: var(--muted); padding: 40px 0; font-size: 13px; }
2026-09-12 10:55:15 +05:00
.drag-handle { cursor: grab; color: var(--muted); text-align: center; user-select: none; font-size: 15px; }
.draggable-row.dragging { opacity: 0.4; }
.draggable-row.drag-over { box-shadow: inset 0 2px 0 var(--accent); }
.draggable-row:active .drag-handle { cursor: grabbing; }
2026-09-10 18:21:13 +05:00
.modal-overlay {
position: fixed; inset: 0; background: rgba(1,4,9,0.6); backdrop-filter: blur(2px);
display: flex; align-items: center; justify-content: center; padding: 24px; z-index: 100;
opacity: 0; pointer-events: none; transition: opacity 0.18s var(--ease);
}
.modal-overlay.show { opacity: 1; pointer-events: auto; }
.modal-card {
width: 100%; max-width: 480px; max-height: 88vh; overflow-y: auto;
background: #161b22; border: 1px solid var(--border-strong); border-radius: 14px; padding: 22px;
box-shadow: 0 24px 64px rgba(0,0,0,0.5);
opacity: 0; transform: translateY(10px) scale(0.98); filter: blur(4px);
transition: opacity 0.2s var(--ease), transform 0.2s var(--ease), filter 0.2s var(--ease);
}
2026-09-10 22:17:16 +05:00
.modal-card-lg { max-width: 620px; }
.uc-sub-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
font-size: 13.5px;
}
2026-09-10 18:21:13 +05:00
.modal-overlay.show .modal-card { opacity: 1; transform: translateY(0) scale(1); filter: blur(0); }
.modal-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 16px; }
.modal-head h2 { font-size: 16px; margin: 0; font-weight: 600; }
.modal-close { background: none; border: none; color: var(--muted); font-size: 22px; line-height: 1; cursor: pointer; padding: 2px 6px; border-radius: 6px; }
.modal-close:hover { color: var(--text); background: var(--card2-tint); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 10px; }
2026-09-10 22:06:15 +05:00
.device-row {
display: flex; align-items: center; justify-content: space-between; gap: 10px;
padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px;
font-size: 13.5px;
}
.devices-list { max-height: 260px; overflow-y: auto; margin: 4px 0 16px; }
2026-09-10 17:45:43 +05:00
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
2026-09-10 18:21:13 +05:00
.reveal, .login-card, .splash-mark, .splash-title, .splash-tagline, .modal-overlay, .modal-card { opacity: 1 !important; transform: none !important; filter: none !important; }
2026-09-10 17:45:43 +05:00
}
< / style >
< / head >
< body >
< div id = "login-screen" >
< div class = "login-card" >
< div class = "splash" >
< div class = "splash-mark" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "white" stroke-width = "2.2" stroke-linecap = "round" > < line x1 = "6" y1 = "16" x2 = "6" y2 = "8" / > < line x1 = "12" y1 = "19" x2 = "12" y2 = "5" / > < line x1 = "18" y1 = "14" x2 = "18" y2 = "10" / > < / svg > < / div >
< div class = "splash-title" > MBS Panel< / div >
< div class = "splash-tagline" > made by savsis< / div >
< / div >
2026-09-12 15:43:52 +05:00
< div id = "login-step-password" >
< h1 > Вход< / h1 >
< p > Логин и пароль администратора< / p >
< input type = "text" id = "login-username" placeholder = "Логин" value = "admin" autocomplete = "username" onkeydown = "if(event.key==='Enter')document.getElementById('login-password').focus()" >
< input type = "password" id = "login-password" placeholder = "Пароль" autocomplete = "current-password" onkeydown = "if(event.key==='Enter')login()" >
< button class = "btn block" onclick = "login()" > Войти< / button >
< / div >
< div id = "login-step-totp" style = "display:none" >
< h1 > Код из приложения< / h1 >
< p > Двухфакторка включена — введи 6-значный код< / p >
< input type = "text" id = "login-totp-code" placeholder = "000000" maxlength = "6" inputmode = "numeric" autocomplete = "one-time-code" onkeydown = "if(event.key==='Enter')loginTotp()" >
< button class = "btn block" onclick = "loginTotp()" > Подтвердить< / button >
< / div >
2026-09-10 17:45:43 +05:00
< div id = "login-err" > < / div >
< / div >
< / div >
< div id = "app" >
< div class = "sidebar" >
< div class = "brand" > < div class = "mark" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "white" stroke-width = "2.2" stroke-linecap = "round" > < line x1 = "6" y1 = "16" x2 = "6" y2 = "8" / > < line x1 = "12" y1 = "19" x2 = "12" y2 = "5" / > < line x1 = "18" y1 = "14" x2 = "18" y2 = "10" / > < / svg > < / div > MBS Panel< / div >
< div class = "nav-item active" data-view = "dashboard" onclick = "showView('dashboard')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "3" width = "7" height = "7" rx = "1.5" / > < rect x = "14" y = "3" width = "7" height = "7" rx = "1.5" / > < rect x = "3" y = "14" width = "7" height = "7" rx = "1.5" / > < rect x = "14" y = "14" width = "7" height = "7" rx = "1.5" / > < / svg > Дашборд< / div >
< div class = "nav-item" data-view = "subscriptions" onclick = "showView('subscriptions')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "16" rx = "2" / > < line x1 = "7" y1 = "9" x2 = "17" y2 = "9" / > < line x1 = "7" y1 = "13" x2 = "17" y2 = "13" / > < line x1 = "7" y1 = "17" x2 = "13" y2 = "17" / > < / svg > Подписки< / div >
< div class = "nav-item" data-view = "gifts" onclick = "showView('gifts')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "8" width = "18" height = "13" rx = "1.5" / > < line x1 = "3" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "12" y1 = "8" x2 = "12" y2 = "21" / > < path d = "M12 8c-1.2 0-2.3-1.3-2.3-2.6C9.7 4 10.6 3 11.6 3c1.4 0 2.4 2 .4 5" / > < path d = "M12 8c1.2 0 2.3-1.3 2.3-2.6C14.3 4 13.4 3 12.4 3c-1.4 0-2.4 2-.4 5" / > < / svg > Гифт-коды< / div >
< div class = "nav-item" data-view = "nodes" onclick = "showView('nodes')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "4" width = "18" height = "6" rx = "1.5" / > < rect x = "3" y = "14" width = "18" height = "6" rx = "1.5" / > < line x1 = "7" y1 = "7" x2 = "7.01" y2 = "7" / > < line x1 = "7" y1 = "17" x2 = "7.01" y2 = "17" / > < / svg > Ноды< / div >
< div class = "nav-item" data-view = "traffic" onclick = "showView('traffic')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "3,13 8,13 10,7 14,19 16,13 21,13" / > < / svg > Трафик< / div >
2026-09-10 22:46:55 +05:00
< div class = "nav-item" data-view = "payments" onclick = "showView('payments')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "2" y = "5" width = "20" height = "14" rx = "2" / > < line x1 = "2" y1 = "10" x2 = "22" y2 = "10" / > < / svg > Платежи< / div >
2026-09-10 23:25:37 +05:00
< div class = "nav-item" data-view = "docs" onclick = "showView('docs')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" / > < polyline points = "14,2 14,8 20,8" / > < line x1 = "8" y1 = "13" x2 = "16" y2 = "13" / > < line x1 = "8" y1 = "17" x2 = "16" y2 = "17" / > < / svg > Документация< / div >
2026-09-10 23:29:58 +05:00
< div class = "nav-item" data-view = "settings" onclick = "showView('settings')" > < svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "12" cy = "12" r = "3" / > < path d = "M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" / > < / svg > Настройки< / div >
2026-09-10 17:45:43 +05:00
< div class = "sidebar-footer" >
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
< div class = "version-tag" id = "logged-in-as" style = "margin-bottom:6px" > < / div >
2026-09-10 17:45:43 +05:00
< button class = "btn ghost" style = "width:100%" onclick = "logout()" > Выйти< / button >
2026-09-12 17:09:19 +05:00
< div class = "version-tag" > MBS Panel v1.1.0 · < a href = "https://github.com/devsavsis/mbs-panel/releases/latest" target = "_blank" style = "color:inherit" > обновления< / a > < / div >
2026-09-10 17:45:43 +05:00
< / div >
< / div >
< div class = "main" >
< div id = "view-dashboard" class = "view active" >
< div class = "page-title" > Дашборд< / div >
< div class = "page-sub" > Общая статистика по сервису< / div >
< div class = "stat-grid" id = "stat-grid" > < / div >
< div class = "section" >
< div class = "section-head" > < h2 > Последние подписки< / h2 > < / div >
< div class = "table-wrap" > < table > < thead > < tr >
< th > Пользователь< / th > < th > Сервер< / th > < th > Тариф< / th > < th > Истекает< / th > < th > Статус< / th >
< / tr > < / thead > < tbody id = "recent-subs-body" > < / tbody > < / table > < / div >
< / div >
< / div >
< div id = "view-subscriptions" class = "view" >
< div class = "page-title" > Подписки< / div >
< div class = "page-sub" > В с е выданные подписки< / div >
< div class = "table-wrap" > < table > < thead > < tr >
< th > Пользователь< / th > < th > Сервер< / th > < th > Тариф< / th > < th > Выдана< / th > < th > Истекает< / th > < th > Статус< / th > < th > < / th >
< / tr > < / thead > < tbody id = "subs-body" > < / tbody > < / table > < / div >
< / div >
< div id = "view-gifts" class = "view" >
< div class = "page-title" > Гифт-коды< / div >
< div class = "page-sub" > Ссылки, которые сразу выдают подписку — работают даже для тех, кто ни разу не открывал бота< / div >
< div class = "section" >
< div class = "form-row" >
< div > < label class = "f" > Сервер< / label > < div id = "gift-node" class = "dd" > < / div > < / div >
< div > < label class = "f" > Срок< / label > < div id = "gift-plan" class = "dd" > < / div > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "createGift()" > Создать< / button > < / div >
< / div >
< div id = "gift-result" > < / div >
< / div >
< div class = "table-wrap" > < table > < thead > < tr >
< th > Сервер< / th > < th > Срок< / th > < th > Создан< / th > < th > Статус< / th > < th > Ссылка< / th >
< / tr > < / thead > < tbody id = "gifts-body" > < / tbody > < / table > < / div >
< / div >
< div id = "view-nodes" class = "view" >
< div class = "page-title" > Ноды< / div >
< div class = "page-sub" > Локации, из которых бот выдаёт подписки< / div >
< div class = "section" >
< div class = "table-wrap" > < table > < thead > < tr >
2026-09-12 10:55:15 +05:00
< th style = "width:28px" > < / th > < th > Локация< / th > < th > Адрес< / th > < th > Тип< / th > < th > Статус< / th > < th > Live< / th > < th > < / th >
2026-09-10 17:45:43 +05:00
< / tr > < / thead > < tbody id = "nodes-body" > < / tbody > < / table > < / div >
< / div >
< div class = "section" >
< div class = "section-head" > < h2 > Добавить ноду< / h2 > < / div >
< div class = "tabs" >
< div class = "tab active" data-tab = "guide" onclick = "switchNodeTab('guide')" > Гайд по установке< / div >
< div class = "tab" data-tab = "manual" onclick = "switchNodeTab('manual')" > Вручную< / div >
< / div >
< div id = "node-tab-guide" >
< p class = "page-sub" style = "margin-bottom:16px" > Заполни данные новой локации — панель сгенерирует ключи и команду, включит TCP+Reality, gRPC+Reality и XHTTP+Reality разом. Выполни команду на чистом Ubuntu-сервере (по SSH) — Xray установится и настроится сам, ничего дополнительно передавать не нужно.< / p >
< div class = "form-row" >
< div > < label class = "f" > Страна< / label > < div id = "ng-country" class = "dd" > < / div > < / div >
< div > < label class = "f" > Название< / label > < input type = "text" id = "ng-label" placeholder = "Например: Финляндия (fi2)" > < / div >
< div > < label class = "f" > Домен/адрес< / label > < input type = "text" id = "ng-address" placeholder = "fi2.example.com" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Порт (TCP)< / label > < input type = "text" id = "ng-port" value = "443" > < / div >
< div > < label class = "f" > SNI-маскировка< / label > < input type = "text" id = "ng-sni" value = "www.wildberries.ru" > < / div >
< / div >
< div class = "check-row" >
< label class = "check" > < input type = "checkbox" id = "ng-ws" > + WS+TLS с настоящим сертификатом < span class = "check-hint" > (нужен уже привязанный A-record на этот адрес — certbot выпустит серт прямо в скрипте)< / span > < / label >
< label class = "check" > < input type = "checkbox" id = "ng-hy" onchange = "document.getElementById('ng-hy-port-wrap').style.display=this.checked?'block':'none'" > + Hysteria2 < span class = "check-hint" > (отдельный процесс по UDP/QUIC, свой самоподписанный серт — DNS не нужен)< / span > < / label >
< / div >
< div class = "form-row" id = "ng-hy-port-wrap" style = "display:none" >
< div > < label class = "f" > Порт Hysteria2 (UDP)< / label > < input type = "text" id = "ng-hy-port" value = "443" > < / div >
< / div >
< button class = "btn" onclick = "generateGuide()" > Сгенерировать команду< / button >
< div id = "guide-result" > < / div >
< / div >
< div id = "node-tab-manual" style = "display:none" >
< p class = "page-sub" style = "margin-bottom:16px" > Для ноды, которую ты уже настроил(а ) сам(а ) — просто вставь её параметры Reality.< / p >
< div class = "form-row" >
< div > < label class = "f" > Страна< / label > < div id = "nm-country" class = "dd" > < / div > < / div >
< div > < label class = "f" > Название< / label > < input type = "text" id = "nm-label" placeholder = "Название локации" > < / div >
< div > < label class = "f" > Код< / label > < input type = "text" id = "nm-code" placeholder = "fi2" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Адрес< / label > < input type = "text" id = "nm-address" placeholder = "fi2.example.com" > < / div >
< div > < label class = "f" > Порт< / label > < input type = "text" id = "nm-port" value = "443" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Public key< / label > < input type = "text" id = "nm-pbk" > < / div >
< div > < label class = "f" > Short ID< / label > < input type = "text" id = "nm-sid" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > SNI< / label > < input type = "text" id = "nm-sni" value = "www.wildberries.ru" > < / div >
< div > < label class = "f" > Shared UUID (если нодой управляешь не ты)< / label > < input type = "text" id = "nm-uuid" placeholder = "необязательно" > < / div >
< / div >
< button class = "btn" onclick = "createManualNode()" > Добавить ноду< / button >
< div id = "manual-result" > < / div >
< / div >
< / div >
< / div >
2026-09-10 18:21:13 +05:00
< div id = "edit-node-overlay" class = "modal-overlay" onclick = "if(event.target===this) closeEditNode()" >
< div class = "modal-card" >
< div class = "modal-head" >
< h2 id = "edit-node-title" > Редактировать ноду< / h2 >
< button class = "modal-close" onclick = "closeEditNode()" > × < / button >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Страна< / label > < div id = "edit-country" class = "dd" > < / div > < / div >
< div > < label class = "f" > Название< / label > < input type = "text" id = "edit-label" > < / div >
< / div >
< div id = "edit-node-advanced" >
< div class = "form-row" >
< div > < label class = "f" > Адрес< / label > < input type = "text" id = "edit-address" > < / div >
< div > < label class = "f" > Порт< / label > < input type = "text" id = "edit-port" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > SNI< / label > < input type = "text" id = "edit-sni" > < / div >
< div > < label class = "f" > Flow< / label > < input type = "text" id = "edit-flow" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Public key< / label > < input type = "text" id = "edit-pbk" > < / div >
< div > < label class = "f" > Short ID< / label > < input type = "text" id = "edit-sid" > < / div >
< / div >
< div class = "form-row" >
< div > < label class = "f" > Shared UUID< / label > < input type = "text" id = "edit-uuid" placeholder = "необязательно" > < / div >
< / div >
< p class = "check-hint" style = "margin:2px 0 4px" > Смена адреса/ключей/short ID сломает уже выданные ссылки у текущих подписчиков этой ноды — используй только если точно понимаешь, что делаешь.< / p >
< / div >
< p id = "edit-node-de1-note" class = "page-sub" style = "display:none;margin:0 0 4px" > У локальной ноды (de1) параметры подключения берутся из .env на сервере — здесь можно поменять только отображаемое название.< / p >
< div class = "modal-actions" >
< button class = "btn ghost" onclick = "closeEditNode()" > Отмена< / button >
< button class = "btn" onclick = "saveEditNode()" > Сохранить< / button >
< / div >
< div id = "edit-node-err" > < / div >
< / div >
< / div >
2026-09-10 22:06:15 +05:00
< div id = "devices-overlay" class = "modal-overlay" onclick = "if(event.target===this) closeDevices()" >
2026-09-10 22:17:16 +05:00
< div class = "modal-card modal-card-lg" >
2026-09-10 22:06:15 +05:00
< div class = "modal-head" >
2026-09-10 22:17:16 +05:00
< h2 id = "devices-title" > Карточка юзера< / h2 >
2026-09-10 22:06:15 +05:00
< button class = "modal-close" onclick = "closeDevices()" > × < / button >
< / div >
2026-09-10 22:17:16 +05:00
< div class = "tabs" >
< div class = "tab active" data-uc-tab = "subs" onclick = "switchUserTab('subs')" > Подписки< / div >
< div class = "tab" data-uc-tab = "devices" onclick = "switchUserTab('devices')" > Устройства< / div >
< / div >
< div id = "uc-tab-subs" >
< div id = "uc-subs-list" class = "devices-list" > < / div >
< div class = "form-row" style = "margin-top:6px" >
< div > < label class = "f" > Сервер< / label > < div id = "uc-grant-node" class = "dd" > < / div > < / div >
< div > < label class = "f" > Срок< / label > < div id = "uc-grant-plan" class = "dd" > < / div > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "grantSubscription()" > Выдать< / button > < / div >
< / div >
< / div >
< div id = "uc-tab-devices" style = "display:none" >
< div id = "devices-list" class = "devices-list" > < / div >
< label class = "f" > Лимит устройств для этого юзера< / label >
< input type = "text" id = "devices-limit" placeholder = "по умолчанию" >
< p class = "page-sub" id = "devices-limit-hint" style = "margin:6px 0 0" > < / p >
< div class = "modal-actions" >
< button class = "btn" onclick = "saveHwidLimit()" > Сохранить лимит< / button >
< / div >
2026-09-10 22:06:15 +05:00
< / div >
< / div >
< / div >
2026-09-10 17:45:43 +05:00
< div id = "view-traffic" class = "view" >
< div class = "page-title" > Трафик< / div >
< div class = "page-sub" > Суммарно по всем нодам, live через Xray Stats API< / div >
< div class = "stat-grid" id = "traffic-stat-grid" style = "grid-template-columns:repeat(3,1fr)" > < / div >
< div class = "section" >
< div class = "section-head" > < h2 > По подпискам< / h2 > < / div >
< div class = "table-wrap" > < table > < thead > < tr >
2026-09-10 22:14:16 +05:00
< th > Пользователь< / th > < th > Сервер< / th > < th > Входящий< / th > < th > Исходящий< / th > < th > В с е г о < / th > < th > < / th >
2026-09-10 17:45:43 +05:00
< / tr > < / thead > < tbody id = "traffic-body" > < / tbody > < / table > < / div >
< / div >
< / div >
2026-09-10 22:46:55 +05:00
< div id = "view-payments" class = "view" >
< div class = "page-title" > Платежи< / div >
< div class = "page-sub" > ЮKassa / Platega — история и статус, с проверкой на стороне провайдера при пропущенном вебхуке< / div >
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
< div class = "section" >
< div class = "section-head" > < h2 > Настройка приёма платежей< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Заполни один раз — панель сама соберёт из этого публичную оферту и политику конфиденциальности (обязательны для подключения ЮKassa) на своих страницах, готовых к показу клиентам.< / p >
< div class = "doc-block" style = "margin-bottom:20px" >
< h2 > Как это работает< / h2 >
< p > 1. Заполни реквизиты ниже (кто ты для закона — самозанятый/ИП/О О О , ИНН, контакты). Это те же данные, что ЮKassa попросит при регистрации магазина.< / p >
< p > 2. Подключи ЮKassa: заведи магазин на < a href = "https://yookassa.ru" target = "_blank" > yookassa.ru< / a > , в личном кабинете возьми < b > shop_id< / b > и < b > секретный ключ< / b > (Настройки → Ключи API), вставь сюда. Панель сразу проверит их и сохранит.< / p >
< p > 3. Ссылки на готовые оферту и политику (< code > https://{домен}/offer< / code > , < code > /privacy< / code > ) — дай их ЮKassa при регистрации магазина, она их обязательно спросит.< / p >
< p class = "muted" > Самозанятым для приёма платежей от физлиц регистрация магазина в ЮKassa доступна напрямую по паспорту и ИНН, без онлайн-кассы — она уже встроена в сервис ЮKassa. ИП/О О О — обычная регистрация магазина.< / p >
< / div >
< h3 style = "font-size:14px;margin:0 0 12px" > Реквизиты для документов< / h3 >
< div class = "form-row" >
< div > < label class = "f" > Кто ты< / label > < div id = "legal-type" class = "dd" > < / div > < / div >
< div > < label class = "f" id = "legal-name-label" > ФИО< / label > < input type = "text" id = "legal-name" placeholder = "Иванов Иван Иванович" > < / div >
< div > < label class = "f" > ИНН< / label > < input type = "text" id = "legal-inn" placeholder = "770123456789" > < / div >
< / div >
< div class = "form-row" style = "margin-top:12px" >
< div > < label class = "f" > Email поддержки< / label > < input type = "text" id = "legal-email" placeholder = "support@example.com" > < / div >
< div > < label class = "f" > Telegram-контакт поддержки< / label > < input type = "text" id = "legal-contact" placeholder = "@support" > < / div >
< div > < label class = "f" > Возврат в течение (часов)< / label > < input type = "text" id = "legal-refund" placeholder = "24" > < / div >
< / div >
< div class = "form-row" style = "margin-top:12px" >
< button class = "btn" onclick = "saveLegalSettings()" > Сохранить реквизиты< / button >
< / div >
< div id = "legal-result" > < / div >
< p class = "check-hint" > Страницы всегда доступны по ссылкам: < a href = "/offer" target = "_blank" id = "legal-offer-link" > /offer< / a > · < a href = "/privacy" target = "_blank" id = "legal-privacy-link" > /privacy< / a > — незаполненные поля показываются пометкой, что их надо указать, страница не ломается.< / p >
< h3 style = "font-size:14px;margin:24px 0 12px" > ЮKassa — ключи API< / h3 >
< div id = "yookassa-status" class = "page-sub" style = "margin-bottom:12px" > < / div >
< div class = "form-row" >
< div > < label class = "f" > shop_id< / label > < input type = "text" id = "yk-shop-id" placeholder = "123456" > < / div >
< div > < label class = "f" > Секретный ключ< / label > < input type = "password" id = "yk-secret-key" placeholder = "live_..." > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "saveYookassaSettings()" > Проверить и сохранить< / button > < / div >
< / div >
< p class = "check-hint" > Панель сама постучится в ЮKassa (< code > /v3/me< / code > ) и сохранит ключи только если они рабочие. После сохранения включаются приём оплаты и вебхуки; бот перезапустится сам, для самой панели (обработка вебхуков) один раз выполни на сервере < code > mbs restart< / code > .< / p >
< div id = "yookassa-result" > < / div >
2026-09-13 19:50:54 +05:00
< h3 style = "font-size:14px;margin:24px 0 12px" > Platega — ключи API< / h3 >
< div id = "platega-status" class = "page-sub" style = "margin-bottom:12px" > < / div >
< div class = "form-row" >
< div > < label class = "f" > Merchant ID< / label > < input type = "text" id = "pg-merchant-id" placeholder = "merchant_..." > < / div >
< div > < label class = "f" > Секрет< / label > < input type = "password" id = "pg-secret" placeholder = "secret_..." > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "savePlategaSettings()" > Сохранить< / button > < / div >
< / div >
< p class = "check-hint" > У Platega нет публичного эндпоинта для проверки ключей без реального платежа, так что сохраняется без предварительной проверки — если ключи неверные, это будет видно по первой неудачной оплате. Тот же рестарт нужен, что и для ЮKassa.< / p >
< div id = "platega-result" > < / div >
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
< / div >
< div class = "section" style = "margin-top:20px" >
< div class = "section-head" > < h2 > История< / h2 > < / div >
< div class = "table-wrap" > < table > < thead > < tr >
< th > Пользователь< / th > < th > Сервер< / th > < th > Тариф< / th > < th > Провайдер< / th > < th > Сумма< / th > < th > Создан< / th > < th > Статус< / th > < th > < / th >
< / tr > < / thead > < tbody id = "payments-body" > < / tbody > < / table > < / div >
< / div >
2026-09-10 22:46:55 +05:00
< / div >
2026-09-10 23:25:37 +05:00
< div id = "view-docs" class = "view" >
< div class = "page-title" > Документация< / div >
< div class = "page-sub" > Как устроена панель и как её обслуживать — без похода на GitHub< / div >
< div class = "doc-block" >
< h2 > Архитектура< / h2 >
< p > Панель — три процесса: < b > bot.py< / b > (телеграм-бот, aiogram) и < b > api.py< / b > (FastAPI — админка + выдача подписок) читают одну SQLite-базу; < b > Xray< / b > — отдельный процесс, который реально гоняет трафик. Панель никогда не проксирует VPN-трафик сама, только управляет конфигом Xray и читает е г о статистику через встроенный Stats API.< / p >
< p > Н а 443 порту одновременно живёт и настоящий HTTPS (для сайта/подписки), и замаскированный под HTTPS VLESS+Reality — их разводит < code > nginx stream< / code > модуль по SNI входящего TLS-соединения, до расшифровки.< / p >
< / div >
< div class = "doc-block" >
< h2 > Ноды< / h2 >
< p > < b > Локальная нода< / b > (обычно < code > de1< / code > ) — Xray на том же сервере, что и панель, управляется напрямую правкой < code > config.json< / code > . < b > Управляемые ноды< / b > — отдельные серверы, панель ходит на них по SSH management-ключу (генерится сам при первом добавлении ноды, публичная часть раздаётся install-скриптом ноды — панель никогда не просит пароль от нового сервера).< / p >
< p > Добавление ноды: Ноды → Добавить ноду → один < code > bash < (curl ...)> < / code > на чистый сервер. Редактирование существующей: кнопка «Редактировать» у ноды — для de1 доступно только название (реальные параметры подключения там берутся из < code > .env< / code > , не из базы).< / p >
< / div >
< div class = "doc-block" >
< h2 > Пароль и безопасность< / h2 >
< p > Пароль админ-панели меняется командой < code > mbs pass< / code > на сервере (без аргумента — сгенерит случайный). Панель физически откажется стартовать, если в < code > .env< / code > стоит "change-me"/"admin"/что-то короче 8 символов — так что пропустить это не выйдет по-тихому.< / p >
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
< p > Сессия логина живёт в httpOnly-куке, опционально поверх пароля — 2FA (TOTP). Н а < code > /admin/api/login< / code > и < code > /admin/api/login/totp< / code > висит rate-limit (10 попыток за 15 минут на пароль, 10 за 5 минут на код — с одного IP). SSH-доступ на сервер — сам по с е б е , панель на него не влияет; отдельно стоит подумать про отключение root-логина по паролю в пользу ключей, если этого ещё не сделано.< / p >
2026-09-10 23:25:37 +05:00
< / div >
< div class = "doc-block" >
< h2 > fail2ban< / h2 >
< p > < code > install.sh< / code > ставит и включает fail2ban автоматически (дефолтный jail — защита SSH от перебора паролей). Проверить, что работает:< / p >
< div class = "code-box" > fail2ban-client status< button class = "copy-btn" onclick = "copyText('fail2ban-client status')" > Копировать< / button > < / div >
< p class = "page-sub" style = "margin-top:8px" > Посмотреть забаненные IP по конкретному джейлу: < code > fail2ban-client status sshd< / code > . Разбанить: < code > fail2ban-client set sshd unbanip АЙПИ< / code > .< / p >
< / div >
< / div >
2026-09-10 23:29:58 +05:00
< div id = "view-settings" class = "view" >
< div class = "page-title" > Настройки< / div >
< div class = "page-sub" > Смена телеграм-бота без переустановки панели< / div >
< div class = "section" >
< div class = "section-head" > < h2 > Telegram-бот< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Сейчас: < b id = "settings-bot-username" > —< / b > (токен: < span id = "settings-bot-token" style = "font-family:'Fira Mono',monospace" > —< / span > )< / p >
< div class = "form-row" >
< div > < label class = "f" > Новый токен (от @BotFather)< / label > < input type = "text" id = "settings-bot-token-input" placeholder = "123456789:AAAA..." > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "saveBotSettings()" > Сменить бота< / button > < / div >
< / div >
< p class = "check-hint" > Панель сама проверит токен у Telegram (запрос getMe) перед применением и подставит настоящий юзернейм бота — придумывать не нужно. После смены перезапустится только бот; если уведомления от api (например о б оплате) продолжат идти с о старого бота, выполни на сервере < code > mbs restart< / code > .< / p >
< div id = "settings-bot-result" > < / div >
< / div >
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
< div class = "section" style = "margin-top:20px" >
< div class = "section-head" > < h2 > Админы< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Отдельные логины для входа в панель — на случай если админов несколько.< / p >
< div class = "table-wrap" > < table > < thead > < tr >
< th > Логин< / th > < th > Создан< / th > < th > < / th >
< / tr > < / thead > < tbody id = "admins-body" > < / tbody > < / table > < / div >
< div class = "form-row" style = "margin-top:16px" >
< div > < label class = "f" > Логин< / label > < input type = "text" id = "new-admin-username" placeholder = "Новый логин" > < / div >
< div > < label class = "f" > Пароль< / label > < input type = "password" id = "new-admin-password" placeholder = "Минимум 8 символов" > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "createAdmin()" > Добавить< / button > < / div >
< / div >
< div id = "admins-result" > < / div >
< / div >
2026-09-12 15:43:52 +05:00
< div class = "section" style = "margin-top:20px" >
< div class = "section-head" > < h2 > Двухфакторная аутентификация< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Код из Google Authenticator/Authy/1Password при входе, в дополнение к паролю. Настраивается для твоего текущего логина.< / p >
< div id = "totp-status" > < / div >
< div id = "totp-setup-box" style = "display:none;margin-top:16px" >
< p class = "page-sub" > Добавь в приложение-аутентификатор вручную (ключ) или скопируй ссылку:< / p >
< div class = "code-box" > < span id = "totp-secret-display" > < / span > < button class = "copy-btn" onclick = "copyText(document.getElementById('totp-secret-display').textContent)" > Копировать< / button > < / div >
< div class = "form-row" style = "margin-top:12px" >
< div > < label class = "f" > Код из приложения< / label > < input type = "text" id = "totp-confirm-code" placeholder = "000000" maxlength = "6" inputmode = "numeric" > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "confirmEnableTotp()" > Подтвердить< / button > < / div >
< / div >
< / div >
< div id = "totp-disable-box" style = "display:none;margin-top:16px" >
< div class = "form-row" >
< div > < label class = "f" > Пароль (подтвердить отключение)< / label > < input type = "password" id = "totp-disable-password" > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" style = "background:var(--red)" onclick = "confirmDisableTotp()" > Отключить< / button > < / div >
< / div >
< / div >
< div id = "totp-result" > < / div >
< / div >
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
< div class = "section" style = "margin-top:20px" >
< div class = "section-head" > < h2 > Бэкап и восстановление< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Бэкап — это база (юзеры, подписки, ноды, платежи) и < code > .env< / code > одним файлом. Держи копии где-то отдельно от сервера.< / p >
< div class = "form-row" style = "align-items:flex-start" >
< button class = "btn" onclick = "downloadBackup()" > Скачать бэкап< / button >
< / div >
< div style = "margin-top:20px;padding-top:20px;border-top:1px solid var(--border)" >
< label class = "f" > Восстановить из файла< / label >
< div class = "form-row" >
< input type = "file" id = "restore-file-input" accept = ".gz,.tar.gz" >
< div style = "flex:0" > < button class = "btn" style = "background:var(--red)" onclick = "restoreBackup()" > Восстановить< / button > < / div >
< / div >
< p class = "check-hint" > ⚠ Заменяет текущую базу целиком. Перед заменой панель сама делает копию текущей базы на сервере (файл < code > .before-restore-...< / code > ), но проверь, что заливаешь именно тот файл, что нужно.< / p >
< / div >
< div id = "backup-result" > < / div >
< / div >
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
< div class = "section" style = "margin-top:20px" >
< div class = "section-head" > < h2 > Webhook на события< / h2 > < / div >
< p class = "page-sub" style = "margin-bottom:16px" > Панель сама постучится на твой URL при оплате или ручной выдаче подписки — для своих интеграций (CRM, аналитика, что угодно), без опроса API.< / p >
< div class = "form-row" >
< div > < label class = "f" > URL< / label > < input type = "text" id = "webhook-url" placeholder = "https://example.com/hook" > < / div >
< div style = "flex:0" > < label class = "f" > < / label > < button class = "btn" onclick = "saveWebhookSettings()" > Сохранить< / button > < / div >
< / div >
< p class = "check-hint" >
События: < code > payment.paid< / code > , < code > subscription.granted_by_admin< / code > . Тело — JSON < code > {"event": "...", "data": {...}}< / code > , подписано заголовком < code > X-Signature< / code > (HMAC-SHA256 от тела запроса на секрете ниже) — так получатель проверяет, что запрос реально от панели.
Секрет для проверки: < code id = "webhook-secret-display" > —< / code >
< / p >
< div id = "webhook-result" > < / div >
< / div >
2026-09-10 23:29:58 +05:00
< / div >
2026-09-10 17:45:43 +05:00
< / div >
< / div >
< script >
function esc(s) {
if (s === null || s === undefined) return "";
return String(s).replace(/[& < >"']/g, (c) => ({ "& ": "& ", "< ": "< ", ">": "> ", '"': "" ", "'": "' " }[c]));
}
async function api(path, opts) {
const res = await fetch(path, { ...opts, headers: { "Content-Type": "application/json", ...(opts & & opts.headers) } });
if (res.status === 401) { showLogin(); throw new Error("unauthorized"); }
if (!res.ok) throw new Error(await res.text());
const ct = res.headers.get("content-type") || "";
return ct.includes("application/json") ? res.json() : res.text();
}
function showLogin() {
document.getElementById("login-screen").style.display = "flex";
document.getElementById("app").classList.remove("show");
2026-09-12 15:43:52 +05:00
document.getElementById("login-step-password").style.display = "block";
document.getElementById("login-step-totp").style.display = "none";
document.getElementById("login-totp-code").value = "";
document.getElementById("login-password").value = "";
pendingTotpToken = null;
2026-09-10 17:45:43 +05:00
}
function showApp() {
document.getElementById("login-screen").style.display = "none";
document.getElementById("app").classList.add("show");
loadDashboard();
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
api("/admin/api/me").then((me) => {
document.getElementById("logged-in-as").textContent = me.username ? "вошёл как " + me.username : "";
}).catch(() => {});
2026-09-10 17:45:43 +05:00
}
2026-09-12 15:43:52 +05:00
let pendingTotpToken = null;
2026-09-10 17:45:43 +05:00
async function login() {
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
const username = document.getElementById("login-username").value.trim();
2026-09-10 17:45:43 +05:00
const password = document.getElementById("login-password").value;
const err = document.getElementById("login-err");
err.textContent = "";
try {
2026-09-12 15:43:52 +05:00
const res = await api("/admin/api/login", { method: "POST", body: JSON.stringify({ username, password }) });
if (res.needs_totp) {
pendingTotpToken = res.pending_token;
document.getElementById("login-step-password").style.display = "none";
document.getElementById("login-step-totp").style.display = "block";
document.getElementById("login-totp-code").focus();
return;
}
2026-09-10 17:45:43 +05:00
showApp();
} catch (e) {
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
err.textContent = "Неверный логин или пароль";
2026-09-10 17:45:43 +05:00
}
}
2026-09-12 15:43:52 +05:00
async function loginTotp() {
const code = document.getElementById("login-totp-code").value.trim();
const err = document.getElementById("login-err");
err.textContent = "";
try {
await api("/admin/api/login/totp", { method: "POST", body: JSON.stringify({ pending_token: pendingTotpToken, code }) });
showApp();
} catch (e) {
err.textContent = "Неверный код";
}
}
2026-09-10 17:45:43 +05:00
async function logout() {
await api("/admin/api/logout", { method: "POST" });
showLogin();
}
function showView(name) {
document.querySelectorAll(".view").forEach((v) => v.classList.remove("active"));
document.querySelectorAll(".nav-item").forEach((n) => n.classList.remove("active"));
document.getElementById("view-" + name).classList.add("active");
document.querySelector(`.nav-item[data-view="${name}"]`).classList.add("active");
if (name === "dashboard") loadDashboard();
if (name === "subscriptions") loadSubscriptions();
if (name === "gifts") loadGifts();
if (name === "nodes") loadNodes();
if (name === "traffic") loadTraffic();
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
if (name === "payments") { loadPayments(); loadPaymentsSettings(); }
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
if (name === "settings") { loadBotSettings(); loadAdmins(); loadTotpStatus(); loadWebhookSettings(); }
2026-09-10 17:45:43 +05:00
}
const COUNTRIES = [
["AU", "Австралия"], ["AT", "Австрия"], ["AZ", "Азербайджан"], ["AL", "Албания"], ["DZ", "Алжир"],
["AO", "Ангола"], ["AD", "Андорра"], ["AG", "Антигуа и Барбуда"], ["AR", "Аргентина"], ["AM", "Армения"],
["AF", "Афганистан"], ["BS", "Багамы"], ["BD", "Бангладеш"], ["BB", "Барбадос"], ["BH", "Бахрейн"],
["BY", "Беларусь"], ["BZ", "Белиз"], ["BE", "Бельгия"], ["BJ", "Бенин"], ["BG", "Болгария"],
["BO", "Боливия"], ["BA", "Босния и Герцеговина"], ["BW", "Ботсвана"], ["BR", "Бразилия"], ["BN", "Бруней"],
["BF", "Буркина-Фасо"], ["BI", "Бурунди"], ["BT", "Бутан"], ["VU", "Вануату"], ["VA", "Ватикан"],
["GB", "Великобритания"], ["HU", "Венгрия"], ["VE", "Венесуэла"], ["TL", "Восточный Тимор"], ["VN", "Вьетнам"],
["GA", "Габон"], ["HT", "Гаити"], ["GY", "Гайана"], ["GM", "Гамбия"], ["GH", "Гана"],
["GT", "Гватемала"], ["GN", "Гвинея"], ["GW", "Гвинея-Бисау"], ["DE", "Германия"], ["HN", "Гондурас"],
["HK", "Гонконг"], ["GD", "Гренада"], ["GR", "Греция"], ["GE", "Грузия"], ["CD", "ДР Конго"],
["DK", "Дания"], ["DJ", "Джибути"], ["DM", "Доминика"], ["DO", "Доминиканская Республика"], ["EG", "Египет"],
["ZM", "Замбия"], ["ZW", "Зимбабве"], ["IL", "Израиль"], ["IN", "Индия"], ["ID", "Индонезия"],
["JO", "Иордания"], ["IQ", "Ирак"], ["IR", "Иран"], ["IE", "Ирландия"], ["IS", "Исландия"],
["ES", "Испания"], ["IT", "Италия"], ["YE", "Йемен"], ["KP", "КНДР"], ["CV", "К а б о -Верде"],
["KZ", "Казахстан"], ["KH", "Камбоджа"], ["CM", "Камерун"], ["CA", "Канада"], ["QA", "Катар"],
["KE", "Кения"], ["CY", "Кипр"], ["KG", "Киргизия"], ["KI", "Кирибати"], ["CN", "Китай"],
["CO", "Колумбия"], ["KM", "Коморы"], ["CG", "Конго"], ["CR", "Коста-Рика"], ["CI", "Кот-д'Ивуар"],
["CU", "К у б а "], ["KW", "Кувейт"], ["LA", "Лаос"], ["LV", "Латвия"], ["LS", "Лесото"],
["LR", "Либерия"], ["LB", "Ливан"], ["LY", "Ливия"], ["LT", "Литва"], ["LI", "Лихтенштейн"],
["LU", "Люксембург"], ["MU", "Маврикий"], ["MR", "Мавритания"], ["MG", "Мадагаскар"], ["MO", "Макао"],
["MW", "Малави"], ["MY", "Малайзия"], ["ML", "Мали"], ["MV", "Мальдивы"], ["MT", "Мальта"],
["MA", "Марокко"], ["MH", "Маршалловы Острова"], ["MX", "Мексика"], ["FM", "Микронезия"], ["MZ", "Мозамбик"],
["MD", "Молдова"], ["MC", "Монако"], ["MN", "Монголия"], ["MM", "Мьянма"], ["NA", "Намибия"],
["NR", "Н а у р у "], ["NP", "Непал"], ["NE", "Нигер"], ["NG", "Нигерия"], ["NL", "Нидерланды"],
["NI", "Никарагуа"], ["NZ", "Новая Зеландия"], ["NO", "Норвегия"], ["AE", "ОАЭ"], ["OM", "Оман"],
["PK", "Пакистан"], ["PW", "Палау"], ["PA", "Панама"], ["PG", "Папуа — Новая Гвинея"], ["PY", "Парагвай"],
["PE", "Перу"], ["PL", "Польша"], ["PT", "Португалия"], ["RU", "Россия"], ["RW", "Руанда"],
["RO", "Румыния"], ["US", "США"], ["SV", "Сальвадор"], ["WS", "Самоа"], ["SM", "Сан-Марино"],
["ST", "Сан-Томе и Принсипи"], ["SA", "Саудовская Аравия"], ["MK", "Северная Македония"], ["SC", "Сейшелы"], ["SN", "Сенегал"],
["VC", "Сент-Винсент и Гренадины"], ["KN", "Сент-Китс и Невис"], ["LC", "Сент-Люсия"], ["RS", "Сербия"], ["SG", "Сингапур"],
["SY", "Сирия"], ["SK", "Словакия"], ["SI", "Словения"], ["SB", "Соломоновы Острова"], ["SO", "Сомали"],
["SD", "Судан"], ["SR", "Суринам"], ["SL", "Сьерра-Леоне"], ["TJ", "Таджикистан"], ["TH", "Таиланд"],
["TW", "Тайвань"], ["TZ", "Танзания"], ["TG", "Т о г о "], ["TO", "Тонга"], ["TT", "Тринидад и Т о б а г о "],
["TV", "Тувалу"], ["TN", "Тунис"], ["TM", "Туркменистан"], ["TR", "Турция"], ["UG", "Уганда"],
["UZ", "Узбекистан"], ["UA", "Украина"], ["UY", "Уругвай"], ["FJ", "Фиджи"], ["PH", "Филиппины"],
["FI", "Финляндия"], ["FR", "Франция"], ["HR", "Хорватия"], ["CF", "ЦАР"], ["TD", "Чад"],
["ME", "Черногория"], ["CZ", "Чехия"], ["CL", "Чили"], ["CH", "Швейцария"], ["SE", "Швеция"],
["LK", "Шри-Ланка"], ["GQ", "Экв. Гвинея"], ["EC", "Эквадор"], ["ER", "Эритрея"], ["SZ", "Эсватини"],
["EE", "Эстония"], ["ET", "Эфиопия"], ["ZA", "ЮАР"], ["KR", "Южная Корея"], ["SS", "Южный Судан"],
["JM", "Ямайка"], ["JP", "Япония"],
];
function flagEmoji(code) {
return code.split("").map((c) => String.fromCodePoint(127397 + c.charCodeAt(0))).join("");
}
2026-09-10 23:06:46 +05:00
function flagToCode(label) {
const chars = Array.from(label || "");
if (chars.length < 2 ) return null ;
const cp1 = chars[0].codePointAt(0) - 127397;
const cp2 = chars[1].codePointAt(0) - 127397;
if (cp1 < 65 | | cp1 > 90 || cp2 < 65 | | cp2 > 90) return null;
return String.fromCharCode(cp1) + String.fromCharCode(cp2);
}
2026-09-10 17:45:43 +05:00
function createDropdown(id, { options, value, placeholder, searchable, onChange }) {
const container = document.getElementById(id);
container.classList.add("dd");
container.innerHTML = `
< button type = "button" class = "dd-trigger" >
< span class = "dd-trigger-label placeholder" > ${esc(placeholder || "Выбери")}< / span >
< svg class = "dd-chevron" viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "6,9 12,15 18,9" / > < / svg >
< / button >
< div class = "dd-menu" >
${searchable ? '< input type = "text" class = "dd-search" placeholder = "Поиск…" > ' : ""}
< div class = "dd-list" > < / div >
< / div > `;
const trigger = container.querySelector(".dd-trigger");
const label = container.querySelector(".dd-trigger-label");
const menu = container.querySelector(".dd-menu");
const list = container.querySelector(".dd-list");
const search = container.querySelector(".dd-search");
let current = options || [];
let val = value ?? null;
let open = false;
let closeTimer = null;
function renderList(filter) {
const f = (filter || "").trim().toLowerCase();
const filtered = f ? current.filter((o) => o.label.toLowerCase().includes(f)) : current;
list.innerHTML = filtered.length
? filtered.map((o) => `< div class = "dd-option${o.value === val ? " selected " : " " } " data-value = "${esc(o.value)}" > ${o.html || esc(o.label)}< / div > `).join("")
: '< div class = "dd-empty" > Ничего не найдено< / div > ';
}
function updateLabel() {
const found = current.find((o) => o.value === val);
label.textContent = found ? found.label : (placeholder || "Выбери");
label.classList.toggle("placeholder", !found);
}
function onDocClick(e) {
if (!container.contains(e.target)) closeMenu();
}
function openMenu() {
if (open) return;
open = true;
if (closeTimer) { clearTimeout(closeTimer); closeTimer = null; }
container.classList.add("dd-open");
renderList("");
menu.classList.remove("closing");
requestAnimationFrame(() => menu.classList.add("show"));
if (search) { search.value = ""; setTimeout(() => search.focus(), 30); }
document.addEventListener("click", onDocClick, true);
document.addEventListener("keydown", onKeydown);
}
function closeMenu() {
if (!open) return;
open = false;
container.classList.remove("dd-open");
menu.classList.remove("show");
menu.classList.add("closing");
document.removeEventListener("click", onDocClick, true);
document.removeEventListener("keydown", onKeydown);
closeTimer = setTimeout(() => menu.classList.remove("closing"), 200);
}
function onKeydown(e) {
if (e.key === "Escape") closeMenu();
}
trigger.addEventListener("click", () => (open ? closeMenu() : openMenu()));
if (search) search.addEventListener("input", () => renderList(search.value));
list.addEventListener("click", (e) => {
const opt = e.target.closest(".dd-option");
if (!opt) return;
val = opt.dataset.value;
updateLabel();
closeMenu();
if (onChange) onChange(val);
});
updateLabel();
return {
setOptions(opts) { current = opts; updateLabel(); },
setValue(v) { val = v; updateLabel(); },
getValue() { return val; },
};
}
const DD = {};
function initDropdowns() {
const countryOptions = COUNTRIES.map(([code, name]) => ({
value: code, label: `${flagEmoji(code)} ${name}`,
}));
DD.ngCountry = createDropdown("ng-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("ng-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
DD.nmCountry = createDropdown("nm-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("nm-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
DD.giftNode = createDropdown("gift-node", { options: [], placeholder: "Сервер" });
DD.giftPlan = createDropdown("gift-plan", { options: [], placeholder: "Срок" });
2026-09-10 18:21:13 +05:00
DD.editCountry = createDropdown("edit-country", {
options: countryOptions, placeholder: "Страна", searchable: true,
onChange: (code) => {
const c = COUNTRIES.find((x) => x[0] === code);
if (c) document.getElementById("edit-label").value = `${flagEmoji(code)} ${c[1]}`;
},
});
2026-09-10 22:17:16 +05:00
DD.ucGrantNode = createDropdown("uc-grant-node", { options: [], placeholder: "Сервер" });
DD.ucGrantPlan = createDropdown("uc-grant-plan", { options: [], placeholder: "Срок" });
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
DD.legalType = createDropdown("legal-type", {
options: [
{ value: "self", label: "Самозанятый" },
{ value: "ip", label: "ИП" },
{ value: "ooo", label: "О О О " },
],
value: "self", placeholder: "Кто ты",
onChange: (type) => {
const label = document.getElementById("legal-name-label");
const input = document.getElementById("legal-name");
if (type === "ooo") { label.textContent = "Название"; input.placeholder = 'О О О «Ромашка»'; }
else { label.textContent = "ФИО"; input.placeholder = "Иванов Иван Иванович"; }
},
});
2026-09-10 17:45:43 +05:00
}
const ICONS = {
users: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "9" cy = "8" r = "3.2" / > < path d = "M3 20c0-3.3 2.7-6 6-6s6 2.7 6 6" / > < circle cx = "17.5" cy = "9" r = "2.4" / > < path d = "M21 20c0-2.6-1.8-4.8-4.2-5.5" / > < / svg > ',
check: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < circle cx = "12" cy = "12" r = "9" / > < path d = "M8 12.5l2.5 2.5L16 9.5" / > < / svg > ',
box: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < path d = "M21 8l-9-5-9 5 9 5 9-5z" / > < path d = "M3 8v8l9 5 9-5V8" / > < line x1 = "12" y1 = "13" x2 = "12" y2 = "21" / > < / svg > ',
gift: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < rect x = "3" y = "8" width = "18" height = "13" rx = "1.5" / > < line x1 = "3" y1 = "12" x2 = "21" y2 = "12" / > < line x1 = "12" y1 = "8" x2 = "12" y2 = "21" / > < / svg > ',
up: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < line x1 = "12" y1 = "19" x2 = "12" y2 = "5" / > < polyline points = "6,11 12,5 18,11" / > < / svg > ',
down: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < line x1 = "12" y1 = "5" x2 = "12" y2 = "19" / > < polyline points = "6,13 12,19 18,13" / > < / svg > ',
pulse: '< svg viewBox = "0 0 24 24" fill = "none" stroke = "currentColor" stroke-width = "2" stroke-linecap = "round" stroke-linejoin = "round" > < polyline points = "3,13 8,13 10,7 14,19 16,13 21,13" / > < / svg > ',
};
function statCard(label, value, color, icon, delay) {
return `< div class = "stat-card reveal" style = "animation-delay:${delay}s" >
< div class = "l" > ${label}< / div >
< div class = "row" style = "color:${color}" > ${icon}< span class = "v" style = "color:var(--text)" > ${value}< / span > < / div >
< / div > `;
}
2026-09-10 22:19:34 +05:00
function rowAttr(i) {
return `class="reveal" style="animation-delay:${Math.min(i, 10) * 0.025}s"`;
}
2026-09-10 17:45:43 +05:00
async function loadTraffic() {
const t = await api("/admin/api/traffic");
const grid = document.getElementById("traffic-stat-grid");
grid.innerHTML = [
["Входящий (всего)", t.total_up_fmt, "var(--accent)", ICONS.up],
["Исходящий (всего)", t.total_down_fmt, "var(--blue)", ICONS.down],
["Суммарно", t.total_fmt, "var(--accent)", ICONS.pulse],
].map(([l, v, c, ic], i) => statCard(l, v, c, ic, i * 0.05)).join("");
const body = document.getElementById("traffic-body");
2026-09-10 22:19:34 +05:00
body.innerHTML = t.per_subscription.length ? t.per_subscription.map((r, i) => `
< tr $ { rowAttr ( i ) } > < td > ${esc(r.username)}< / td > < td > ${esc(r.node_label)}< / td > < td > ${r.up_fmt}< / td > < td > ${r.down_fmt}< / td > < td > ${r.total_fmt}< / td >
2026-09-10 22:14:16 +05:00
< td > < button class = "muted-btn" onclick = "resetTraffic('${r.uuid}', this)" > Сбросить< / button > < / td > < / tr >
`).join("") : '< tr > < td colspan = "6" > < div class = "empty" > Пока нет данных по трафику< / div > < / td > < / tr > ';
}
async function resetTraffic(uuid, btn) {
if (!confirm("Сбросить счётчик трафика для этой подписки?")) return;
btn.disabled = true;
btn.textContent = "…";
try {
await api(`/admin/api/subscriptions/${uuid}/reset-traffic`, { method: "POST" });
loadTraffic();
} catch (e) {
btn.disabled = false;
btn.textContent = "Сбросить";
}
2026-09-10 17:45:43 +05:00
}
2026-09-10 22:46:55 +05:00
function paymentStatusBadge(status) {
if (status === "paid") return '< span class = "badge ok" > оплачен< / span > ';
if (status === "failed") return '< span class = "badge bad" > не прошёл< / span > ';
return '< span class = "badge warn" > ожидание< / span > ';
}
async function loadPayments() {
const rows = await api("/admin/api/payments");
const body = document.getElementById("payments-body");
body.innerHTML = rows.length ? rows.map((p, i) => `
< tr $ { rowAttr ( i ) } >
< td > tg${p.tg_id}< / td > < td > ${esc(p.node_label)}< / td > < td > ${esc(p.plan_label)}< / td >
< td > ${esc(p.provider_label)}< / td > < td > ${p.amount} ₽< / td > < td > ${fmtDate(p.created_at)}< / td >
< td > ${paymentStatusBadge(p.status)}< / td >
< td > ${p.status === "pending" ? `< button class = "muted-btn" onclick = "checkPayment('${p.id}', this)" > Проверить< / button > ` : ""}< / td >
< / tr >
`).join("") : '< tr > < td colspan = "8" > < div class = "empty" > Пока нет платежей< / div > < / td > < / tr > ';
}
async function checkPayment(id, btn) {
btn.disabled = true;
btn.textContent = "…";
try {
await api(`/admin/api/payments/${id}/check`, { method: "POST" });
} finally {
loadPayments();
}
}
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
async function loadPaymentsSettings() {
2026-09-13 19:50:54 +05:00
const [legalRes, ykRes, pgRes] = await Promise.all([
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
api("/admin/api/payments/legal-settings"),
api("/admin/api/payments/yookassa-settings"),
2026-09-13 19:50:54 +05:00
api("/admin/api/payments/platega-settings"),
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
]);
document.getElementById("legal-name").value = legalRes.LEGAL_NAME || "";
document.getElementById("legal-inn").value = legalRes.LEGAL_INN || "";
document.getElementById("legal-refund").value = legalRes.REFUND_HOURS || "24";
document.getElementById("legal-contact").value = legalRes.SUPPORT_CONTACT || "";
document.getElementById("legal-email").value = legalRes.SUPPORT_EMAIL || "";
document.getElementById("yk-shop-id").value = ykRes.shop_id || "";
2026-09-13 19:50:54 +05:00
const ykStatus = document.getElementById("yookassa-status");
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
if (ykRes.enabled & & ykRes.has_secret) {
2026-09-13 19:50:54 +05:00
ykStatus.innerHTML = '< span class = "badge ok" > подключена< / span > shop_id: ' + esc(ykRes.shop_id);
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
} else {
2026-09-13 19:50:54 +05:00
ykStatus.innerHTML = '< span class = "badge bad" > не настроена< / span > ';
}
document.getElementById("pg-merchant-id").value = pgRes.merchant_id || "";
const pgStatus = document.getElementById("platega-status");
if (pgRes.enabled & & pgRes.has_secret) {
pgStatus.innerHTML = '< span class = "badge ok" > подключена< / span > merchant_id: ' + esc(pgRes.merchant_id);
} else {
pgStatus.innerHTML = '< span class = "badge bad" > не настроена< / span > ';
feat: payments setup wizard — legal pages + YooKassa keys from the admin UI
The site/offer.html and site/privacy.html legal templates existed in
the repo but were never actually wired to anything — no route served
them, install.sh never copied them anywhere. Nobody deploying this
for real payments had a live offer/privacy page, which YooKassa
requires for merchant approval.
Rewrote both templates with {{TOKEN}} placeholders (new legal.py
renders them from .env-backed settings, read fresh on every request,
no restart needed to fix a typo) and added a proper setup section in
the Payments tab: business type/name/INN/support contact/refund
window, saved via POST /admin/api/payments/legal-settings, live at
GET /offer and /privacy immediately. Unset fields render as a visible
not-set-yet badge instead of breaking the page. Effective date
auto-stamps once on first save and stays stable across later edits
(verified: editing the name afterward doesn't reset it).
YooKassa shop_id + secret_key get their own section: validated live
against YooKassa's own /v3/me before being saved (same pattern as the
existing bot-token getMe check), never echoed back to the frontend
once set. Includes an inline guide — where to find the keys in
YooKassa's dashboard, and that self-employed registration there needs
just passport + INN, no separate cash register.
Both new dropdowns use the existing custom .dd component, not a raw
select element — this codebase deliberately doesn't use native
selects (see the comment already in admin.html) because of the
OS-rendered white popup, so a new form had to follow that pattern,
not reintroduce it.
Verified: template rendering with empty settings (fallback badges,
no leftover tokens) and fully filled settings, HTML-escaping of field
values (a script tag in a field renders as text, not markup), the
one-time-only date stamp, and all new routes registering correctly.
Also fixed a stale doc string in the panel's own admin-facing docs
tab that still quoted the old rate-limit numbers from before the
real limits shipped.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 19:23:25 +05:00
}
}
async function saveLegalSettings() {
const result = document.getElementById("legal-result");
const body = {
LEGAL_NAME: document.getElementById("legal-name").value.trim(),
LEGAL_INN: document.getElementById("legal-inn").value.trim(),
REFUND_HOURS: document.getElementById("legal-refund").value.trim() || "24",
SUPPORT_CONTACT: document.getElementById("legal-contact").value.trim(),
SUPPORT_EMAIL: document.getElementById("legal-email").value.trim(),
};
try {
await api("/admin/api/payments/legal-settings", { method: "POST", body: JSON.stringify(body) });
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Сохранено — страницы /offer и /privacy обновились сразу, без рестарта< / p > ';
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
async function saveYookassaSettings() {
const shop_id = document.getElementById("yk-shop-id").value.trim();
const secret_key = document.getElementById("yk-secret-key").value.trim();
const result = document.getElementById("yookassa-result");
if (!shop_id || !secret_key) return;
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px" > Проверяю ключи у ЮKassa…< / p > ';
try {
const res = await api("/admin/api/payments/yookassa-settings", { method: "POST", body: JSON.stringify({ shop_id, secret_key }) });
result.innerHTML = `< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Ключи рабочие, сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере < code > mbs restart< / code > .< / p > `;
document.getElementById("yk-secret-key").value = "";
loadPaymentsSettings();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
2026-09-13 19:50:54 +05:00
async function savePlategaSettings() {
const merchant_id = document.getElementById("pg-merchant-id").value.trim();
const secret = document.getElementById("pg-secret").value.trim();
const result = document.getElementById("platega-result");
if (!merchant_id || !secret) return;
try {
const res = await api("/admin/api/payments/platega-settings", { method: "POST", body: JSON.stringify({ merchant_id, secret }) });
result.innerHTML = `< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Сохранено.${res.restarted_bot ? " Бот перезапущен." : " Бот сам не перезапустился — выполни mbs restart."} Для приёма вебхуков панелью выполни на сервере < code > mbs restart< / code > .< / p > `;
document.getElementById("pg-secret").value = "";
loadPaymentsSettings();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
2026-09-10 23:29:58 +05:00
async function loadBotSettings() {
const data = await api("/admin/api/settings/bot");
document.getElementById("settings-bot-username").textContent = "@" + data.username;
document.getElementById("settings-bot-token").textContent = data.token_masked;
document.getElementById("settings-bot-result").innerHTML = "";
}
async function saveBotSettings() {
const input = document.getElementById("settings-bot-token-input");
const token = input.value.trim();
const result = document.getElementById("settings-bot-result");
if (!token) return;
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px" > Проверяю токен у Telegram…< / p > ';
try {
const res = await api("/admin/api/settings/bot", { method: "POST", body: JSON.stringify({ token }) });
result.innerHTML = `< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Готово: бот сменён на @${esc(res.username)}${res.restarted ? "" : " (сохранено, но авто-рестарт не удался — перезапусти вручную: mbs restart)"}< / p > `;
input.value = "";
loadBotSettings();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось — проверь токен и попробуй снова< / p > ';
}
}
feat: multi-admin support — named logins instead of one shared password
Matches Marzban's multi-admin (WIP there) and closes a real gap vs
both. New 'admins' table (username + PBKDF2-SHA256 password hash,
200k iterations, random salt per account, stdlib hashlib/hmac only —
no new dependency), admin_sessions now tracks which admin is logged
in. Existing installs aren't broken: on first run, if no admins exist
yet, a default 'admin' account is seeded from the current
ADMIN_PANEL_PASSWORD — old password keeps working under username
'admin', pre-filled on the login screen.
Admin management lives in Settings: list, add (username + password,
min 8 chars), remove. Can't delete the last remaining admin or your
own currently-logged-in account. Sidebar now shows who's logged in.
Verified end-to-end: bootstrap, correct/wrong/nonexistent login,
session->admin resolution, last-admin-delete protection, duplicate
username rejection, add/remove round trip, and that identical
passwords hash to different values (unique salt) but both verify.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:13:16 +05:00
let currentAdminUsername = null;
async function loadAdmins() {
const admins = await api("/admin/api/admins");
const me = await api("/admin/api/me");
currentAdminUsername = me.username;
const body = document.getElementById("admins-body");
body.innerHTML = admins.map((a, i) => `
< tr $ { rowAttr ( i ) } >
< td > ${esc(a.username)}${a.username === currentAdminUsername ? ' < span class = "badge ok" > это ты< / span > ' : ""}< / td >
< td > ${fmtDate(a.created_at)}< / td >
< td > ${admins.length > 1 & & a.username !== currentAdminUsername ? `< button class = "muted-btn" onclick = "deleteAdmin(${a.id})" > Удалить< / button > ` : ""}< / td >
< / tr >
`).join("");
}
async function createAdmin() {
const username = document.getElementById("new-admin-username").value.trim();
const password = document.getElementById("new-admin-password").value;
const result = document.getElementById("admins-result");
if (!username || !password) return;
try {
await api("/admin/api/admins", { method: "POST", body: JSON.stringify({ username, password }) });
document.getElementById("new-admin-username").value = "";
document.getElementById("new-admin-password").value = "";
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Админ добавлен< / p > ';
loadAdmins();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
async function deleteAdmin(id) {
if (!confirm("Удалить этого админа?")) return;
try {
await api(`/admin/api/admins/${id}`, { method: "DELETE" });
loadAdmins();
} catch (e) {
document.getElementById("admins-result").innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
2026-09-12 15:43:52 +05:00
async function loadTotpStatus() {
const status = document.getElementById("totp-status");
const setupBox = document.getElementById("totp-setup-box");
const disableBox = document.getElementById("totp-disable-box");
setupBox.style.display = "none";
disableBox.style.display = "none";
const s = await api("/admin/api/2fa/status");
if (s.enabled) {
status.innerHTML = '< p class = "page-sub" > < span class = "badge ok" > включена< / span > < / p > ';
status.innerHTML += '< button class = "muted-btn" onclick = "document.getElementById(\'totp-disable-box\').style.display=\'block\'" > Отключить< / button > ';
} else {
status.innerHTML = '< p class = "page-sub" > < span class = "badge bad" > выключена< / span > < / p > ';
status.innerHTML += '< button class = "btn" onclick = "startEnableTotp()" > Включить 2FA< / button > ';
}
}
async function startEnableTotp() {
const res = await api("/admin/api/2fa/setup", { method: "POST" });
document.getElementById("totp-secret-display").textContent = res.secret;
document.getElementById("totp-setup-box").dataset.secret = res.secret;
document.getElementById("totp-setup-box").style.display = "block";
}
async function confirmEnableTotp() {
const secret = document.getElementById("totp-setup-box").dataset.secret;
const code = document.getElementById("totp-confirm-code").value.trim();
const result = document.getElementById("totp-result");
try {
await api("/admin/api/2fa/enable", { method: "POST", body: JSON.stringify({ secret, code }) });
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > 2FA включена< / p > ';
loadTotpStatus();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Неверный код< / p > ';
}
}
async function confirmDisableTotp() {
const password = document.getElementById("totp-disable-password").value;
const result = document.getElementById("totp-result");
try {
await api("/admin/api/2fa/disable", { method: "POST", body: JSON.stringify({ password }) });
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > 2FA отключена< / p > ';
document.getElementById("totp-disable-password").value = "";
loadTotpStatus();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Неверный пароль< / p > ';
}
}
feat: outbound webhooks for payment/subscription events
Per the docs.rw comparison researched earlier tonight, Remnawave
fires webhooks for users+nodes and Marzban for users — this panel
had neither, only received inbound webhooks from payment providers.
New webhooks.py, fired on payment.paid (both webhook-driven and
reconciler-driven grant paths, so it fires regardless of which one
actually processes a given payment) and
subscription.granted_by_admin (kept as a distinct event name rather
than reusing payment.paid, since no money necessarily changed hands
there). Settings tab gets a URL field; a secret is generated once on
first save via secrets.token_hex and never regenerated on later URL
edits, so a receiver's signature verification doesn't silently break
when the admin just updates the endpoint. Every delivery is
HMAC-SHA256 signed over the raw JSON body via X-Signature, same
verification shape Platega already uses for its inbound webhooks.
Delivery is fire-and-forget (10s timeout, swallows all exceptions) —
a receiver being down must never block or fail a payment grant.
Reads WEBHOOK_URL/WEBHOOK_SECRET fresh from .env via legal.py's
existing reader instead of adding a third copy of that logic.
Verified with a real local HTTP server: actual delivery, payload
shape, and that the received X-Signature verifies against the
configured secret using the receiver's own side of the HMAC — not
just asserting the sender computed *something*. Also verified the
no-URL-configured no-op path and that changing the URL later does not
rotate the secret.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-13 21:15:13 +05:00
async function loadWebhookSettings() {
const res = await api("/admin/api/webhook-settings");
document.getElementById("webhook-url").value = res.url || "";
document.getElementById("webhook-secret-display").textContent = res.secret || "будет создан при сохранении URL";
}
async function saveWebhookSettings() {
const url = document.getElementById("webhook-url").value.trim();
const result = document.getElementById("webhook-result");
try {
await api("/admin/api/webhook-settings", { method: "POST", body: JSON.stringify({ url }) });
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Сохранено< / p > ';
loadWebhookSettings();
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
feat: backup & restore built into the admin panel
Neither Remnawave nor Marzban has this natively (community tools only,
per docs.rw's own comparison table) — one-click download of a tar.gz
with a consistent SQLite snapshot (via sqlite3's backup API, safe even
under WAL) plus .env, and upload-to-restore from the same file.
Restore validates the archive is real (gzip + tar structure), that
mbs.db is an actual sqlite database with the expected tables (not
just any file named mbs.db), and rejects oversized uploads — before
touching anything live. Takes a timestamped safety copy of the
current db/.env before overwriting, clears stale -wal/-shm siblings
so the restored file doesn't get replayed against the wrong WAL, and
restarts mbs-bot automatically when .env was part of the restore
(api.py isn't restarted from within its own request handler for the
obvious reason).
Verified with a full round-trip test: backup -> mutate state -> restore
-> confirm the mutation is reverted, plus three negative cases (garbage
data, oversized upload, a fake non-sqlite mbs.db) all correctly
rejected with no side effects.
Needs python-multipart for FastAPI's UploadFile — added to
requirements.txt, picked up by the next 'mbs update'.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 10:23:38 +05:00
function downloadBackup() {
window.location.href = "/admin/api/backup";
}
async function restoreBackup() {
const input = document.getElementById("restore-file-input");
const result = document.getElementById("backup-result");
const file = input.files[0];
if (!file) return;
if (!confirm("Заменить текущую базу файлом " + file.name + "? Текущая база сохранится в файл .before-restore-... на сервере, но действие лучше не отменять просто так.")) return;
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px" > Восстанавливаю…< / p > ';
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/admin/api/backup/restore", { method: "POST", body: form });
if (res.status === 401) { showLogin(); return; }
if (!res.ok) throw new Error(await res.text());
const data = await res.json();
result.innerHTML = `< p class = "page-sub" style = "margin-top:10px;color:var(--green)" > Готово. Копия старой базы: < code > ${esc(data.safety_copy)}< / code > .${data.restored_env ? (data.restarted_bot ? " Бот перезапущен с новым .env." : " .env восстановлен, но бот сам не перезапустился — выполни mbs restart.") : ""}< / p > `;
input.value = "";
} catch (e) {
result.innerHTML = '< p class = "page-sub" style = "margin-top:10px;color:var(--red)" > Н е получилось: ' + esc(e.message) + '< / p > ';
}
}
2026-09-10 17:45:43 +05:00
function fmtDate(iso) { return iso ? iso.slice(0, 10) : "—"; }
function statusBadge(active, daysLeft) {
if (!active) return '< span class = "badge bad" > истекла< / span > ';
if (daysLeft < = 2) return '< span class = "badge warn" > ' + daysLeft + ' дн.< / span > ';
return '< span class = "badge ok" > ' + daysLeft + ' дн.< / span > ';
}
async function loadDashboard() {
const stats = await api("/admin/api/stats");
const grid = document.getElementById("stat-grid");
grid.innerHTML = [
["Пользователей", stats.users, "var(--blue)", ICONS.users],
["Активных подписок", stats.active_subscriptions, "var(--green)", ICONS.check],
["В с е г о подписок", stats.total_subscriptions, "var(--accent)", ICONS.box],
["Гифт-коды (созд./исп.)", stats.gifts_created + " / " + stats.gifts_used, "var(--pink)", ICONS.gift],
].map(([l, v, c, ic], i) => statCard(l, v, c, ic, i * 0.05)).join("");
2026-09-11 22:20:54 +05:00
const recent = await api("/admin/api/subscriptions?limit=8");
2026-09-10 17:45:43 +05:00
const body = document.getElementById("recent-subs-body");
2026-09-10 22:19:34 +05:00
body.innerHTML = recent.length ? recent.map((s, i) => `
< tr $ { rowAttr ( i ) } > < td > ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}< / td > < td > ${esc(s.node_label)}< / td > < td > ${esc(s.plan_label)}< / td >
2026-09-10 17:45:43 +05:00
< td > ${fmtDate(s.expires_at)}< / td > < td > ${statusBadge(s.active, s.days_left)}< / td > < / tr >
`).join("") : '< tr > < td colspan = "5" > < div class = "empty" > Пока нет подписок< / div > < / td > < / tr > ';
}
async function loadSubscriptions() {
const subs = await api("/admin/api/subscriptions");
const body = document.getElementById("subs-body");
2026-09-10 22:19:34 +05:00
body.innerHTML = subs.length ? subs.map((s, i) => `
< tr $ { rowAttr ( i ) } > < td > ${s.username ? "@" + esc(s.username) : "tg" + s.tg_id}< / td > < td > ${esc(s.node_label)}< / td > < td > ${esc(s.plan_label)}< / td >
2026-09-10 17:45:43 +05:00
< td > ${fmtDate(s.created_at)}< / td > < td > ${fmtDate(s.expires_at)}< / td > < td > ${statusBadge(s.active, s.days_left)}< / td >
2026-09-10 22:06:15 +05:00
< td >
2026-09-10 22:17:16 +05:00
< button class = "muted-btn" onclick = "openUserCard(${s.tg_id}, '${esc(s.username ? '@' + s.username : 'tg' + s.tg_id)}')" > Карточка< / button >
2026-09-10 22:06:15 +05:00
${s.active ? `< button class = "muted-btn" onclick = "revokeSub('${s.uuid}')" > Отозвать< / button > ` : ""}
< / td > < / tr >
2026-09-10 17:45:43 +05:00
`).join("") : '< tr > < td colspan = "7" > < div class = "empty" > Пока нет подписок< / div > < / td > < / tr > ';
}
2026-09-10 22:06:15 +05:00
let devicesTgId = null;
2026-09-10 22:17:16 +05:00
async function openUserCard(tgId, label) {
2026-09-10 22:06:15 +05:00
devicesTgId = tgId;
2026-09-10 22:17:16 +05:00
document.getElementById("devices-title").textContent = `Карточка: ${label}`;
document.getElementById("uc-subs-list").innerHTML = '< div class = "empty" > Загрузка…< / div > ';
document.getElementById("devices-list").innerHTML = "";
switchUserTab("subs");
2026-09-10 22:06:15 +05:00
document.getElementById("devices-overlay").classList.add("show");
2026-09-10 22:17:16 +05:00
if (!plansCache) plansCache = await api("/admin/api/plans");
2026-09-10 23:06:46 +05:00
nodesCache = await api("/admin/api/nodes");
2026-09-10 22:17:16 +05:00
DD.ucGrantNode.setOptions(nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label })));
DD.ucGrantPlan.setOptions(plansCache.map((p) => ({ value: p.code, label: p.label })));
const data = await api(`/admin/api/users/${tgId}`);
document.getElementById("devices-limit").value = data.hwid_limit || "";
document.getElementById("devices-limit-hint").textContent = `По умолчанию (если пусто): ${data.hwid_fallback_limit}`;
renderUcSubs(data.subscriptions);
2026-09-10 22:06:15 +05:00
renderDevices(data.devices);
}
function closeDevices() {
document.getElementById("devices-overlay").classList.remove("show");
}
2026-09-10 22:17:16 +05:00
function switchUserTab(tab) {
document.querySelectorAll("[data-uc-tab]").forEach((t) => t.classList.toggle("active", t.dataset.ucTab === tab));
document.getElementById("uc-tab-subs").style.display = tab === "subs" ? "block" : "none";
document.getElementById("uc-tab-devices").style.display = tab === "devices" ? "block" : "none";
}
function renderUcSubs(subs) {
const list = document.getElementById("uc-subs-list");
2026-09-10 22:19:34 +05:00
list.innerHTML = subs.length ? subs.map((s, i) => `
< div class = "uc-sub-row reveal" style = "animation-delay:${Math.min(i, 10) * 0.025}s" >
2026-09-10 22:17:16 +05:00
< div >
< div > ${esc(s.node_label)} — ${esc(s.plan_label)}< / div >
< div class = "page-sub" style = "margin:2px 0 0" > до ${fmtDate(s.expires_at)} · ${statusBadge(s.active, s.days_left)}< / div >
< / div >
${s.active ? `< button class = "muted-btn" onclick = "revokeSub('${s.uuid}'); openUserCard(devicesTgId, document.getElementById('devices-title').textContent.replace('Карточка: ',''))" > Отозвать< / button > ` : ""}
< / div >
`).join("") : '< div class = "empty" > Пока нет подписок< / div > ';
}
async function grantSubscription() {
const node = DD.ucGrantNode.getValue();
const plan = DD.ucGrantPlan.getValue();
if (!node || !plan) return;
await api(`/admin/api/users/${devicesTgId}/grant`, { method: "POST", body: JSON.stringify({ node, plan }) });
openUserCard(devicesTgId, document.getElementById("devices-title").textContent.replace("Карточка: ", ""));
}
2026-09-10 22:06:15 +05:00
function renderDevices(devices) {
const list = document.getElementById("devices-list");
2026-09-10 22:19:34 +05:00
list.innerHTML = devices.length ? devices.map((d, i) => `
< div class = "device-row reveal" style = "animation-delay:${Math.min(i, 10) * 0.025}s" >
2026-09-10 22:06:15 +05:00
< div >
< div > ${esc(d.device_model || d.device_os || "Неизвестное устройство")}< / div >
< div class = "page-sub" style = "margin:2px 0 0" > ${esc(d.device_os || "")} · с ${fmtDate(d.first_seen)}< / div >
< / div >
< button class = "muted-btn" onclick = "deleteDevice(${d.id})" > Удалить< / button >
< / div >
`).join("") : '< div class = "empty" > Нет привязанных устройств< / div > ';
}
async function deleteDevice(deviceId) {
await api(`/admin/api/users/${devicesTgId}/devices/${deviceId}`, { method: "DELETE" });
2026-09-10 22:17:16 +05:00
const data = await api(`/admin/api/users/${devicesTgId}`);
2026-09-10 22:06:15 +05:00
renderDevices(data.devices);
}
async function saveHwidLimit() {
const val = document.getElementById("devices-limit").value.trim();
await api(`/admin/api/users/${devicesTgId}/hwid-limit`, { method: "POST", body: JSON.stringify({ limit: val || null }) });
}
2026-09-10 17:45:43 +05:00
async function revokeSub(uuid) {
if (!confirm("Отозвать подписку?")) return;
await api(`/admin/api/subscriptions/${uuid}/revoke`, { method: "POST" });
loadSubscriptions();
}
let plansCache = null, nodesCache = null;
async function loadGifts() {
if (!plansCache) plansCache = await api("/admin/api/plans");
2026-09-10 23:06:46 +05:00
nodesCache = await api("/admin/api/nodes");
2026-09-10 17:45:43 +05:00
const nodeOptions = nodesCache.filter((n) => n.enabled).map((n) => ({ value: n.code, label: n.label }));
const planOptions = plansCache.map((p) => ({ value: p.code, label: p.label }));
DD.giftNode.setOptions(nodeOptions);
DD.giftPlan.setOptions(planOptions);
if (!DD.giftNode.getValue() & & nodeOptions.length) DD.giftNode.setValue(nodeOptions[0].value);
if (!DD.giftPlan.getValue() & & planOptions.length) DD.giftPlan.setValue(planOptions[0].value);
const codes = await api("/admin/api/gift-codes");
const body = document.getElementById("gifts-body");
2026-09-10 22:19:34 +05:00
body.innerHTML = codes.length ? codes.map((c, i) => `
< tr $ { rowAttr ( i ) } > < td > ${esc(c.node_label)}< / td > < td > ${esc(c.plan_label)}< / td > < td > ${fmtDate(c.created_at)}< / td >
2026-09-10 17:45:43 +05:00
< td > ${c.used_by ? '< span class = "badge bad" > использован< / span > ' : '< span class = "badge ok" > свободен< / span > '}< / td >
< td > < button class = "muted-btn" onclick = "copyText('${c.link}')" > Скопировать< / button > < / td > < / tr >
`).join("") : '< tr > < td colspan = "5" > < div class = "empty" > Пока нет гифт-кодов< / div > < / td > < / tr > ';
}
async function createGift() {
const node = DD.giftNode.getValue();
const plan = DD.giftPlan.getValue();
const res = await api("/admin/api/gift-codes", { method: "POST", body: JSON.stringify({ node, plan }) });
document.getElementById("gift-result").innerHTML = `< div class = "code-box" style = "margin-top:12px" > ${res.link}< button class = "copy-btn" onclick = "copyText('${res.link}')" > Копировать< / button > < / div > `;
loadGifts();
}
function copyText(t) { navigator.clipboard.writeText(t); }
async function loadNodes() {
2026-09-12 10:55:15 +05:00
nodesCache = await api("/admin/api/nodes");
renderNodesTable();
}
let dragSrcCode = null;
function wireNodeDragAndDrop() {
2026-09-10 17:45:43 +05:00
const body = document.getElementById("nodes-body");
2026-09-12 10:55:15 +05:00
body.querySelectorAll("tr.draggable-row").forEach((row) => {
row.addEventListener("dragstart", (e) => {
dragSrcCode = row.dataset.code;
row.classList.add("dragging");
e.dataTransfer.effectAllowed = "move";
});
row.addEventListener("dragend", () => {
row.classList.remove("dragging");
body.querySelectorAll("tr").forEach((r) => r.classList.remove("drag-over"));
});
row.addEventListener("dragover", (e) => {
e.preventDefault();
if (row.dataset.code === dragSrcCode) return;
row.classList.add("drag-over");
});
row.addEventListener("dragleave", () => row.classList.remove("drag-over"));
row.addEventListener("drop", async (e) => {
e.preventDefault();
row.classList.remove("drag-over");
const targetCode = row.dataset.code;
if (!dragSrcCode || targetCode === dragSrcCode) return;
const order = nodesCache.map((n) => n.code);
const from = order.indexOf(dragSrcCode);
const to = order.indexOf(targetCode);
order.splice(to, 0, order.splice(from, 1)[0]);
nodesCache.sort((a, b) => order.indexOf(a.code) - order.indexOf(b.code));
renderNodesTable();
try {
await api("/admin/api/nodes/reorder", { method: "POST", body: JSON.stringify({ codes: order }) });
} catch (err) {
loadNodes();
}
});
});
}
function renderNodesTable() {
const body = document.getElementById("nodes-body");
body.innerHTML = nodesCache.map((n, i) => `
< tr class = "reveal draggable-row" style = "animation-delay:${Math.min(i, 10) * 0.025}s" draggable = "true" data-code = "${esc(n.code)}" >
< td class = "drag-handle" title = "Перетащи, чтобы поменять порядок" > ⠿< / td >
2026-09-10 17:45:43 +05:00
< td > ${esc(n.label)}< / td > < td > ${esc(n.address) || "—"}${n.port & & n.address ? ":" + n.port : ""}< / td >
< td > ${n.kind === "local" ? "локальная" : n.kind === "managed" ? "управляемая" : "внешняя"}< / td >
< td > ${n.status === "pending" ? '< span class = "badge warn" > ожидает установки< / span > ' : (n.enabled ? '< span class = "badge ok" > включена< / span > ' : '< span class = "badge bad" > выключена< / span > ')}< / td >
< td id = "metrics-${esc(n.code)}" > ${n.status === "pending" ? "—" : `< button class = "muted-btn" onclick = "loadNodeMetrics('${esc(n.code)}')" > Проверить< / button > `}< / td >
< td >
2026-09-10 18:21:13 +05:00
< button class = "muted-btn" onclick = "openEditNode('${n.code}')" > Редактировать< / button >
2026-09-10 17:45:43 +05:00
${n.code !== "de1" ? `< button class = "muted-btn" onclick = "toggleNode('${n.code}', ${n.enabled ? 0 : 1})" > ${n.enabled ? "Выключить" : "Включить"}< / button > ` : ""}
${n.code !== "de1" ? `< button class = "muted-btn" onclick = "deleteNode('${n.code}')" > Удалить< / button > ` : ""}
< / td >
< / tr >
`).join("");
2026-09-12 10:55:15 +05:00
wireNodeDragAndDrop();
2026-09-10 17:45:43 +05:00
}
async function loadNodeMetrics(code) {
const cell = document.getElementById(`metrics-${code}`);
cell.textContent = "…";
try {
const m = await api(`/admin/api/nodes/${code}/metrics`);
if (!m.ok) { cell.innerHTML = '< span class = "badge bad" > офлайн< / span > '; return; }
const load = m.load1 !== null & & m.load1 !== undefined ? m.load1.toFixed(2) : "—";
cell.innerHTML = `< span style = "font-family:'Fira Mono',monospace;font-size:12px" > CPU ${load} · ${esc(m.mem_fmt)} · ${esc(m.uptime_fmt)}< / span > `;
} catch (e) {
cell.innerHTML = '< span class = "badge bad" > ошибка< / span > ';
}
}
2026-09-10 18:21:13 +05:00
let editingNodeCode = null;
function openEditNode(code) {
const n = nodesCache.find((x) => x.code === code);
if (!n) return;
editingNodeCode = code;
const isDe1 = code === "de1";
document.getElementById("edit-node-title").textContent = `Редактировать: ${n.label}`;
document.getElementById("edit-label").value = n.label || "";
2026-09-10 23:06:46 +05:00
DD.editCountry.setValue(flagToCode(n.label));
2026-09-10 18:21:13 +05:00
document.getElementById("edit-node-advanced").style.display = isDe1 ? "none" : "block";
document.getElementById("edit-node-de1-note").style.display = isDe1 ? "block" : "none";
document.getElementById("edit-address").value = n.address || "";
document.getElementById("edit-port").value = n.port || 443;
document.getElementById("edit-sni").value = n.sni || "";
document.getElementById("edit-flow").value = n.flow || "";
document.getElementById("edit-pbk").value = n.public_key || "";
document.getElementById("edit-sid").value = n.short_id || "";
document.getElementById("edit-uuid").value = n.shared_uuid || "";
document.getElementById("edit-node-err").textContent = "";
document.getElementById("edit-node-overlay").classList.add("show");
}
function closeEditNode() {
document.getElementById("edit-node-overlay").classList.remove("show");
}
async function saveEditNode() {
const code = editingNodeCode;
if (!code) return;
const body = { label: document.getElementById("edit-label").value.trim() };
if (code !== "de1") {
body.address = document.getElementById("edit-address").value.trim();
body.port = parseInt(document.getElementById("edit-port").value || "443");
body.sni = document.getElementById("edit-sni").value.trim();
body.flow = document.getElementById("edit-flow").value.trim();
body.public_key = document.getElementById("edit-pbk").value.trim();
body.short_id = document.getElementById("edit-sid").value.trim();
body.shared_uuid = document.getElementById("edit-uuid").value.trim() || null;
}
try {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify(body) });
closeEditNode();
loadNodes();
} catch (e) {
document.getElementById("edit-node-err").innerHTML = '< p class = "page-sub" style = "color:var(--red);margin-top:8px" > Н е удалось сохранить< / p > ';
}
}
2026-09-10 17:45:43 +05:00
async function toggleNode(code, enabled) {
await api(`/admin/api/nodes/${code}`, { method: "PATCH", body: JSON.stringify({ enabled }) });
loadNodes();
}
async function deleteNode(code) {
if (!confirm("Удалить ноду?")) return;
await api(`/admin/api/nodes/${code}`, { method: "DELETE" });
loadNodes();
}
function switchNodeTab(tab) {
document.querySelectorAll(".tab").forEach((t) => t.classList.toggle("active", t.dataset.tab === tab));
document.getElementById("node-tab-guide").style.display = tab === "guide" ? "block" : "none";
document.getElementById("node-tab-manual").style.display = tab === "manual" ? "block" : "none";
}
let pollTimer = null;
async function generateGuide() {
const label = document.getElementById("ng-label").value.trim();
const address = document.getElementById("ng-address").value.trim();
const port = parseInt(document.getElementById("ng-port").value || "443");
const sni = document.getElementById("ng-sni").value.trim();
const include_ws = document.getElementById("ng-ws").checked;
const include_hysteria2 = document.getElementById("ng-hy").checked;
const hysteria_port = parseInt(document.getElementById("ng-hy-port").value || "443");
if (!label || !address) return;
const res = await api("/admin/api/nodes/provision-guide", { method: "POST", body: JSON.stringify({ label, address, port, sni, include_ws, include_hysteria2, hysteria_port }) });
document.getElementById("guide-result").innerHTML = `
< p class = "page-sub" style = "margin:16px 0 8px" > Выполни на новом сервере:< / p >
< div class = "code-box" > ${res.command}< button class = "copy-btn" onclick = "copyText('${res.command}')" > Копировать< / button > < / div >
< p class = "page-sub" style = "margin-top:12px" id = "guide-status" > Ожидаю установки…< / p >
`;
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(async () => {
const st = await api(`/admin/api/nodes/${res.code}/status`);
if (st.status === "active") {
clearInterval(pollTimer);
document.getElementById("guide-status").innerHTML = '< span class = "badge ok" > Установлено и подключено< / span > ';
loadNodes();
}
}, 4000);
}
async function createManualNode() {
const body = {
label: document.getElementById("nm-label").value.trim(),
code: document.getElementById("nm-code").value.trim(),
address: document.getElementById("nm-address").value.trim(),
port: parseInt(document.getElementById("nm-port").value || "443"),
public_key: document.getElementById("nm-pbk").value.trim(),
short_id: document.getElementById("nm-sid").value.trim(),
sni: document.getElementById("nm-sni").value.trim(),
shared_uuid: document.getElementById("nm-uuid").value.trim() || null,
kind: document.getElementById("nm-uuid").value.trim() ? "external" : "managed",
};
await api("/admin/api/nodes", { method: "POST", body: JSON.stringify(body) });
document.getElementById("manual-result").innerHTML = '< p class = "page-sub" style = "margin-top:12px" > Нода добавлена.< / p > ';
loadNodes();
}
(async function init() {
initDropdowns();
try {
const me = await api("/admin/api/me");
if (me.authenticated) showApp(); else showLogin();
} catch (e) {
showLogin();
}
})();
< / script >
< / body >
< / html >