neuroalpha 0.6.13 → 0.7.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/bin/cli.js +79 -69
- package/docs//320/230/320/235/320/241/320/242/320/240/320/243/320/232/320/246/320/230/320/257.md +2 -2
- package/package.json +4 -5
- package/public/activate.html +41 -19
- package/public/activate.js +81 -10
- package/public/app.js +471 -166
- package/public/index.html +9 -39
- package/public/styles.css +42 -3
- package/server/api/routes.js +595 -906
- package/server/auth/index.js +583 -761
- package/server/brokers/index.js +14 -3
- package/server/config.js +73 -53
- package/server/index.js +186 -212
- package/server/llm/local-engine.js +843 -0
- package/server/llm/providers.js +45 -34
- package/server/model/live.js +2 -1
- package/server/notify/index.js +304 -466
- package/server/strategist/index.js +4 -1
- package/deploy/docs/01-central.md +0 -96
- package/deploy/docs/02-cloud-instances.md +0 -130
- package/deploy/docs/03-desktop-build.md +0 -92
- package/deploy/docs/04-client-guide.md +0 -101
- package/deploy/docs/05-operations.md +0 -109
- package/deploy/docs/06-env-reference.md +0 -91
- package/deploy/docs/07-stress-test.md +0 -253
- package/deploy/docs/pdf/01-central.pdf +0 -0
- package/deploy/docs/pdf/02-cloud-instances.pdf +0 -0
- package/deploy/docs/pdf/03-desktop-build.pdf +0 -0
- package/deploy/docs/pdf/04-client-guide.pdf +0 -0
- package/deploy/docs/pdf/05-operations.pdf +0 -0
- package/deploy/docs/pdf/06-env-reference.pdf +0 -0
- package/deploy/docs/pdf/07-stress-test.pdf +0 -0
- package/deploy/install.sh +0 -128
- package/deploy/k8s/instance.yaml +0 -147
- package/public/admin.html +0 -604
- package/public/landing.html +0 -1207
- package/public/legal.html +0 -134
- package/scripts/set-admin-password.mjs +0 -30
- package/server/billing/yookassa.js +0 -142
- package/server/instance/agent.js +0 -259
- package/server/instance/central.js +0 -514
- package/server/instance/identity.js +0 -79
- package/server/instance/provision.js +0 -166
- package/server/instance/signing.js +0 -71
- package/server/role.js +0 -44
package/public/admin.html
DELETED
|
@@ -1,604 +0,0 @@
|
|
|
1
|
-
<!-- Copyright (c) 2026 Roman A. Perepelkin. All rights reserved. Proprietary software. -->
|
|
2
|
-
<!doctype html>
|
|
3
|
-
<html lang="ru">
|
|
4
|
-
<head>
|
|
5
|
-
<meta charset="utf-8">
|
|
6
|
-
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
7
|
-
<title>Админ-консоль · НейроАльфа</title>
|
|
8
|
-
<style>
|
|
9
|
-
:root {
|
|
10
|
-
color-scheme: dark;
|
|
11
|
-
--bg: #0b0e14;
|
|
12
|
-
--panel: #131822;
|
|
13
|
-
--panel2: #18202f;
|
|
14
|
-
--line: #242c3d;
|
|
15
|
-
--text: #eef2f6;
|
|
16
|
-
--muted: #94a3b8;
|
|
17
|
-
--accent: #3b82f6;
|
|
18
|
-
--green: #16a34a;
|
|
19
|
-
--red: #ef4444;
|
|
20
|
-
--yellow: #eab308;
|
|
21
|
-
}
|
|
22
|
-
* { box-sizing: border-box; }
|
|
23
|
-
[hidden] { display: none !important; }
|
|
24
|
-
body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.55 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; }
|
|
25
|
-
a, a:visited { color: var(--accent); text-decoration: none; }
|
|
26
|
-
a.brand, a.brand:visited { color: var(--text); }
|
|
27
|
-
code { background: rgba(255,255,255,0.06); padding: 2px 6px; border-radius: 4px; font-size: 12.5px; color: #93c5fd; }
|
|
28
|
-
.wrap { max-width: 1180px; margin: 0 auto; padding: 24px 24px 64px; }
|
|
29
|
-
.top { display: flex; justify-content: space-between; align-items: center; gap: 16px; flex-wrap: wrap; padding-bottom: 16px; margin-bottom: 22px; border-bottom: 1px solid var(--line); }
|
|
30
|
-
.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 18px; color: var(--text); }
|
|
31
|
-
.badge { background: rgba(59,130,246,0.16); color: #93c5fd; border: 1px solid rgba(59,130,246,0.35); border-radius: 6px; padding: 2px 8px; font-size: 12px; font-weight: 600; }
|
|
32
|
-
.tag { display: inline-block; padding: 2px 7px; border-radius: 5px; font-size: 12px; background: rgba(148,163,184,0.14); color: var(--muted); }
|
|
33
|
-
.tag.ok { background: rgba(22,163,74,0.16); color: #4ade80; }
|
|
34
|
-
.tag.mid { background: rgba(234,179,8,0.16); color: #facc15; }
|
|
35
|
-
.tag.no { background: rgba(239,68,68,0.16); color: #f87171; }
|
|
36
|
-
.pos { color: #4ade80; }
|
|
37
|
-
.neg { color: #f87171; }
|
|
38
|
-
.muted { color: var(--muted); }
|
|
39
|
-
.small { font-size: 12.5px; }
|
|
40
|
-
.kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 12px; margin-bottom: 20px; }
|
|
41
|
-
.kpi { background: var(--panel); border: 1px solid var(--line); border-radius: 10px; padding: 14px 16px; }
|
|
42
|
-
.kpi .v { font-size: 22px; font-weight: 700; }
|
|
43
|
-
.kpi .l { font-size: 12.5px; color: var(--muted); margin-top: 2px; }
|
|
44
|
-
.tabs { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 18px; border-bottom: 1px solid var(--line); padding-bottom: 10px; }
|
|
45
|
-
.tabs button { background: transparent; color: var(--muted); border: 1px solid transparent; padding: 8px 14px; border-radius: 8px; cursor: pointer; font-weight: 600; font-size: 13.5px; }
|
|
46
|
-
.tabs button:hover { color: var(--text); background: var(--panel); }
|
|
47
|
-
.tabs button.active { background: var(--accent); color: #fff; }
|
|
48
|
-
.card { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 20px 22px; margin-bottom: 18px; }
|
|
49
|
-
.card h2 { margin: 0 0 6px; font-size: 17px; }
|
|
50
|
-
.card p.sub { margin: 0 0 16px; color: var(--muted); font-size: 13px; }
|
|
51
|
-
.row-gap { display: flex; gap: 10px; align-items: center; }
|
|
52
|
-
input, select { background: #0e131d; color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 11px; font: inherit; }
|
|
53
|
-
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
|
54
|
-
input.secret-input { -webkit-text-security: disc; }
|
|
55
|
-
input.secret-input:focus { -webkit-text-security: none; }
|
|
56
|
-
button, .btn, a.btn, a.btn:visited { background: var(--panel2); color: var(--text); border: 1px solid var(--line); border-radius: 7px; padding: 8px 14px; font: inherit; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; text-decoration: none; }
|
|
57
|
-
button:hover, .btn:hover, a.btn:hover { border-color: #3b4963; color: var(--text); }
|
|
58
|
-
button.primary, .btn.primary, a.btn.primary, a.btn.primary:visited { background: var(--accent); border-color: var(--accent); color: #fff; font-weight: 600; }
|
|
59
|
-
button:disabled { opacity: 0.45; cursor: default; }
|
|
60
|
-
table { width: 100%; border-collapse: collapse; font-size: 13.5px; }
|
|
61
|
-
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--line); }
|
|
62
|
-
th { color: var(--muted); font-weight: 600; font-size: 12px; text-transform: uppercase; letter-spacing: 0.4px; }
|
|
63
|
-
.num { text-align: right; }
|
|
64
|
-
.grid-form { display: grid; gap: 12px; }
|
|
65
|
-
.field-row { display: grid; grid-template-columns: 240px 1fr; gap: 12px; align-items: center; }
|
|
66
|
-
#toast { position: fixed; right: 20px; bottom: 20px; background: #1e293b; color: #fff; border: 1px solid var(--accent); padding: 10px 16px; border-radius: 8px; box-shadow: 0 10px 25px rgba(0,0,0,0.4); z-index: 999; display: none; }
|
|
67
|
-
@media (max-width: 780px) {
|
|
68
|
-
.kpis { grid-template-columns: 1fr 1fr; }
|
|
69
|
-
.field-row { grid-template-columns: 1fr; }
|
|
70
|
-
}
|
|
71
|
-
</style>
|
|
72
|
-
</head>
|
|
73
|
-
<body>
|
|
74
|
-
<div class="wrap">
|
|
75
|
-
<header class="top">
|
|
76
|
-
<div class="brand">
|
|
77
|
-
<span>◈ НейроАльфа</span>
|
|
78
|
-
<span class="badge">Консоль администратора платформы</span>
|
|
79
|
-
</div>
|
|
80
|
-
<div class="row-gap" style="flex-wrap:wrap">
|
|
81
|
-
<a class="btn" href="/landing">Витрина (/landing)</a>
|
|
82
|
-
<a class="btn" href="/?terminal=1">Торговый терминал →</a>
|
|
83
|
-
<button type="button" id="btn-adm-logout" style="color:#f87171;border-color:rgba(248,113,113,0.35)">Выйти</button>
|
|
84
|
-
</div>
|
|
85
|
-
</header>
|
|
86
|
-
|
|
87
|
-
<div class="kpis" id="adm-kpis">
|
|
88
|
-
<div class="kpi"><div class="v" id="kpi-users">—</div><div class="l">всего пользователей</div></div>
|
|
89
|
-
<div class="kpi"><div class="v pos" id="kpi-paid">—</div><div class="l">платных подписок (Инвестор / Профи)</div></div>
|
|
90
|
-
<div class="kpi"><div class="v" id="kpi-revenue">—</div><div class="l">оплачено через ЮKassa</div></div>
|
|
91
|
-
<div class="kpi"><div class="v" id="kpi-bots">—</div><div class="l">привязано Telegram / MAX</div></div>
|
|
92
|
-
</div>
|
|
93
|
-
|
|
94
|
-
<nav class="tabs" id="adm-tabs">
|
|
95
|
-
<button type="button" data-tab="users" class="active">👥 Пользователи и тарифы</button>
|
|
96
|
-
<button type="button" data-tab="instances">🖥 Машины клиентов</button>
|
|
97
|
-
<button type="button" data-tab="billing">💳 ЮKassa, Платежи и Промокоды</button>
|
|
98
|
-
<button type="button" data-tab="oauth">🔑 OAuth-вход (Яндекс / Сбер / T-ID / VK)</button>
|
|
99
|
-
<button type="button" data-tab="bots">🤖 Боты платформы и Реквизиты оферты</button>
|
|
100
|
-
</nav>
|
|
101
|
-
|
|
102
|
-
<!-- Вкладка 1: Пользователи -->
|
|
103
|
-
<section data-pane="users">
|
|
104
|
-
<div class="card">
|
|
105
|
-
<div class="row-gap" style="justify-content:space-between;flex-wrap:wrap;margin-bottom:12px">
|
|
106
|
-
<div>
|
|
107
|
-
<h2>Пользователи платформы</h2>
|
|
108
|
-
<p class="sub" style="margin:0">Управление подписками, назначение запасных администраторов («Хозяин»), проверка привязки мессенджеров и API-ключей.</p>
|
|
109
|
-
</div>
|
|
110
|
-
<input id="usr-search" type="search" placeholder="Поиск по имени или email…" style="width:260px">
|
|
111
|
-
</div>
|
|
112
|
-
<form id="form-create-admin" class="row-gap" style="flex-wrap:wrap;margin-bottom:14px;padding:12px 14px;border:1px solid var(--line);border-radius:9px;background:var(--panel2)">
|
|
113
|
-
<span style="font-weight:600;font-size:13px">⚡ Запасной администратор («Хозяин»):</span>
|
|
114
|
-
<input name="email" type="email" placeholder="Email администратора" style="width:220px" required>
|
|
115
|
-
<input name="name" placeholder="Имя (необязательно)" style="width:170px">
|
|
116
|
-
<input name="password" type="password" placeholder="Пароль (≥6 симв., для нового или смены)" style="width:250px">
|
|
117
|
-
<button type="submit" class="primary">Назначить администратором</button>
|
|
118
|
-
</form>
|
|
119
|
-
<div id="users-table-wrap">Загрузка…</div>
|
|
120
|
-
</div>
|
|
121
|
-
</section>
|
|
122
|
-
|
|
123
|
-
<!-- Вкладка: Машины клиентов -->
|
|
124
|
-
<section data-pane="instances" hidden>
|
|
125
|
-
<div class="card">
|
|
126
|
-
<h2>Арендованные машины клиентов</h2>
|
|
127
|
-
<p class="sub">Облачная машина — отдельный namespace в Kubernetes на поддомене <code>naXXXXXX.odant.org</code>; при включённом развёртывании (<code>PAAS_DRIVER</code>) создаётся автоматически после оплаты. Установка на компьютер (VIP по договору) активируется клиентом одноразовым кодом из кабинета и привязывается к этому компьютеру. Раз в минуту машина присылает техническое состояние и получает подписанную лицензию на 72 ч. «Пауза» — новые заявки не отправляются, защитные стопы работают; «Стоп» — запрещены любые заявки.</p>
|
|
128
|
-
<form id="form-instance" class="row-gap" style="flex-wrap:wrap;margin-bottom:14px">
|
|
129
|
-
<select name="userId" style="width:300px" required></select>
|
|
130
|
-
<select name="kind" style="width:200px">
|
|
131
|
-
<option value="cloud">☁ Облако (Kubernetes)</option>
|
|
132
|
-
<option value="desktop">💻 Компьютер клиента (VIP)</option>
|
|
133
|
-
</select>
|
|
134
|
-
<input name="id" placeholder="ID машины (пусто — авто naXXXXXX)" style="width:240px">
|
|
135
|
-
<input name="note" placeholder="Заметка (тариф, узел кластера…)" style="width:240px">
|
|
136
|
-
<button type="submit" class="primary">Создать машину</button>
|
|
137
|
-
</form>
|
|
138
|
-
<div id="instance-secret" hidden style="margin-bottom:14px">
|
|
139
|
-
<p class="sub" style="margin:0 0 6px"><b style="color:var(--yellow,#d29922)">Ключ машины показывается один раз.</b> Сохраните переменные в Secret Kubernetes (пример манифеста — <code>deploy/k8s/instance.yaml</code>):</p>
|
|
140
|
-
<textarea id="instance-env" readonly rows="7" style="width:100%;font-family:monospace;font-size:12px"></textarea>
|
|
141
|
-
<button type="button" class="btn" id="btn-copy-env" style="margin-top:6px">Скопировать</button>
|
|
142
|
-
</div>
|
|
143
|
-
<div id="instances-wrap">Загрузка…</div>
|
|
144
|
-
</div>
|
|
145
|
-
</section>
|
|
146
|
-
|
|
147
|
-
<!-- Вкладка 2: ЮKassa и Промокоды -->
|
|
148
|
-
<section data-pane="billing" hidden>
|
|
149
|
-
<div class="card">
|
|
150
|
-
<h2>Приём платежей ЮKassa (API v3)</h2>
|
|
151
|
-
<p class="sub">Webhook URL для личного кабинета ЮKassa: <code id="yoo-webhook"></code></p>
|
|
152
|
-
<form id="form-billing" class="grid-form">
|
|
153
|
-
<div class="field-row">
|
|
154
|
-
<span>Shop ID магазина ЮKassa</span>
|
|
155
|
-
<input name="shopId" placeholder="Например, 123456">
|
|
156
|
-
</div>
|
|
157
|
-
<div class="field-row">
|
|
158
|
-
<span>Секретный ключ (Secret Key)</span>
|
|
159
|
-
<input name="secretKey" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="live_… или test_…">
|
|
160
|
-
</div>
|
|
161
|
-
<div class="field-row">
|
|
162
|
-
<span>Онлайн-чеки 54-ФЗ</span>
|
|
163
|
-
<label class="row-gap"><input type="checkbox" name="sendReceipt"> Отправлять структуру чека (receipt) в ЮKassa</label>
|
|
164
|
-
</div>
|
|
165
|
-
<div><button type="submit" class="primary">Сохранить настройки ЮKassa</button></div>
|
|
166
|
-
</form>
|
|
167
|
-
</div>
|
|
168
|
-
|
|
169
|
-
<div class="card">
|
|
170
|
-
<h2>Персональные VIP-токены и Промокоды</h2>
|
|
171
|
-
<p class="sub">Сгенерируйте персональный <code>VIP-…</code> токен (тариф VIP — лимиты по договору) или промокод для тарифов «Инвестор» / «Профи».</p>
|
|
172
|
-
<form id="form-promo" class="row-gap" style="flex-wrap:wrap;margin-bottom:14px">
|
|
173
|
-
<input name="code" placeholder="Код (или пусто для авто VIP-…)" style="width:220px">
|
|
174
|
-
<select name="plan" style="width:210px">
|
|
175
|
-
<option value="vip">👑 Тариф «VIP»</option>
|
|
176
|
-
<option value="pro">Тариф «Профи»</option>
|
|
177
|
-
<option value="investor">Тариф «Инвестор»</option>
|
|
178
|
-
</select>
|
|
179
|
-
<input name="days" type="number" value="365" min="1" max="3650" style="width:110px" title="Длительность, дней" placeholder="Дней">
|
|
180
|
-
<input name="maxUses" type="number" value="1" min="1" max="10000" style="width:120px" title="Лимит активаций" placeholder="Лимит">
|
|
181
|
-
<button type="submit" class="primary">Выпустить токен / промокод</button>
|
|
182
|
-
</form>
|
|
183
|
-
<div id="promos-list"></div>
|
|
184
|
-
</div>
|
|
185
|
-
|
|
186
|
-
<div class="card">
|
|
187
|
-
<h2>Заявки на согласование тарифа VIP</h2>
|
|
188
|
-
<div id="vip-reqs-wrap"></div>
|
|
189
|
-
</div>
|
|
190
|
-
|
|
191
|
-
<div class="card">
|
|
192
|
-
<h2>История платежей ЮKassa</h2>
|
|
193
|
-
<div id="payments-wrap"></div>
|
|
194
|
-
</div>
|
|
195
|
-
</section>
|
|
196
|
-
|
|
197
|
-
<!-- Вкладка 3: OAuth -->
|
|
198
|
-
<section data-pane="oauth" hidden>
|
|
199
|
-
<div class="card">
|
|
200
|
-
<h2>Провайдеры быстрого входа (OAuth 2.0)</h2>
|
|
201
|
-
<p class="sub">Настройка кнопок входа в 1 клик на витрине <code>/landing</code>. Иностранные провайдеры (Google) автоматически скрываются для пользователей из российского сегмента в соответствии с ФЗ № 406-ФЗ / 149-ФЗ. Скопируйте Callback URL в настройки соответствующего провайдера.</p>
|
|
202
|
-
<form id="form-oauth" class="grid-form">
|
|
203
|
-
<div id="oauth-rows" class="grid-form"></div>
|
|
204
|
-
<div><button type="submit" class="primary">Сохранить OAuth-провайдеров</button></div>
|
|
205
|
-
</form>
|
|
206
|
-
</div>
|
|
207
|
-
</section>
|
|
208
|
-
|
|
209
|
-
<!-- Вкладка 4: Боты и Реквизиты -->
|
|
210
|
-
<section data-pane="bots" hidden>
|
|
211
|
-
<div class="card">
|
|
212
|
-
<h2>Серверные боты уведомлений Telegram и MAX</h2>
|
|
213
|
-
<p class="sub">От имени этих ботов пользователям приходят карточки подтверждения брокерских заявок и коды быстрого входа.</p>
|
|
214
|
-
<form id="form-notify" class="grid-form">
|
|
215
|
-
<div class="field-row">
|
|
216
|
-
<span>Имя бота Telegram (без @)</span>
|
|
217
|
-
<input name="tgBotName" placeholder="neuroalpha_trade_bot">
|
|
218
|
-
</div>
|
|
219
|
-
<div class="field-row">
|
|
220
|
-
<span>Токен Telegram Bot API</span>
|
|
221
|
-
<input name="tgToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="Токен от @BotFather">
|
|
222
|
-
</div>
|
|
223
|
-
<div class="field-row">
|
|
224
|
-
<span>Имя бота в мессенджере MAX</span>
|
|
225
|
-
<input name="maxBotName" placeholder="neuroalpha_bot">
|
|
226
|
-
</div>
|
|
227
|
-
<div class="field-row">
|
|
228
|
-
<span>Токен MAX Bot API</span>
|
|
229
|
-
<input name="maxToken" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="Токен бота MAX">
|
|
230
|
-
</div>
|
|
231
|
-
<div><button type="submit" class="primary">Сохранить настройки ботов</button></div>
|
|
232
|
-
</form>
|
|
233
|
-
</div>
|
|
234
|
-
|
|
235
|
-
<div class="card">
|
|
236
|
-
<h2>Юридические реквизиты оферты (для модерации ЮKassa и <code>/legal</code>)</h2>
|
|
237
|
-
<p class="sub">Автоматически подставляются в Публичную оферту и подвал лендинга.</p>
|
|
238
|
-
<form id="form-legal" class="grid-form">
|
|
239
|
-
<div class="field-row"><span>ФИО / Наименование</span><input name="ownerName" placeholder="ИП Иванов И.И."></div>
|
|
240
|
-
<div class="field-row"><span>Правовой статус</span><input name="status" placeholder="Индивидуальный предприниматель / Плательщик НПД"></div>
|
|
241
|
-
<div class="field-row"><span>ИНН</span><input name="inn" placeholder="10 или 12 цифр"></div>
|
|
242
|
-
<div class="field-row"><span>ОГРНИП / ОГРН</span><input name="ogrn" placeholder="Необязательно для самозанятых"></div>
|
|
243
|
-
<div class="field-row"><span>Email поддержки</span><input name="email" placeholder="support@neuroalpha.ru"></div>
|
|
244
|
-
<div class="field-row"><span>Телефон / Контакт</span><input name="phone" placeholder="+7 …"></div>
|
|
245
|
-
<div class="field-row"><span>Адрес / Регион</span><input name="address" placeholder="Российская Федерация"></div>
|
|
246
|
-
<div><button type="submit" class="primary">Сохранить реквизиты</button></div>
|
|
247
|
-
</form>
|
|
248
|
-
</div>
|
|
249
|
-
</section>
|
|
250
|
-
</div>
|
|
251
|
-
|
|
252
|
-
<div id="toast"></div>
|
|
253
|
-
|
|
254
|
-
<script>
|
|
255
|
-
const $ = (s) => document.querySelector(s);
|
|
256
|
-
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
257
|
-
const fmtNum = (n) => Number(n || 0).toLocaleString('ru-RU');
|
|
258
|
-
const fmtTs = (ts) => ts ? new Date(ts).toLocaleString('ru-RU', { day: '2-digit', month: '2-digit', year: '2-digit', hour: '2-digit', minute: '2-digit' }) : '—';
|
|
259
|
-
|
|
260
|
-
function toast(msg) {
|
|
261
|
-
const el = $('#toast');
|
|
262
|
-
el.textContent = msg;
|
|
263
|
-
el.style.display = 'block';
|
|
264
|
-
clearTimeout(toast._t);
|
|
265
|
-
toast._t = setTimeout(() => { el.style.display = 'none'; }, 3000);
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
async function api(url, opts = {}) {
|
|
269
|
-
const res = await fetch(url, {
|
|
270
|
-
method: opts.method || 'GET',
|
|
271
|
-
headers: { 'Content-Type': 'application/json' },
|
|
272
|
-
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
273
|
-
});
|
|
274
|
-
const j = await res.json().catch(() => ({}));
|
|
275
|
-
if (!res.ok) throw new Error(j.error || `HTTP ${res.status}`);
|
|
276
|
-
return j;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
let admState = null;
|
|
280
|
-
|
|
281
|
-
$('#adm-tabs').addEventListener('click', (e) => {
|
|
282
|
-
const b = e.target.closest('button[data-tab]');
|
|
283
|
-
if (!b) return;
|
|
284
|
-
const tab = b.dataset.tab;
|
|
285
|
-
document.querySelectorAll('#adm-tabs button').forEach((x) => x.classList.toggle('active', x === b));
|
|
286
|
-
document.querySelectorAll('section[data-pane]').forEach((p) => { p.hidden = p.dataset.pane !== tab; });
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
function renderUsers() {
|
|
290
|
-
const q = ($('#usr-search').value || '').trim().toLowerCase();
|
|
291
|
-
const list = (admState?.users || []).filter((u) => !q || String(u.name || '').toLowerCase().includes(q) || String(u.email || '').toLowerCase().includes(q));
|
|
292
|
-
if (!list.length) {
|
|
293
|
-
$('#users-table-wrap').innerHTML = '<p class="muted small">Пользователи не найдены.</p>';
|
|
294
|
-
return;
|
|
295
|
-
}
|
|
296
|
-
$('#users-table-wrap').innerHTML = `<table>
|
|
297
|
-
<thead><tr><th>ID</th><th>Имя и Email</th><th>Роль</th><th>Тариф</th><th>Оферта</th><th>Оплачен до</th><th>Мессенджеры</th><th>Сменить тариф</th></tr></thead>
|
|
298
|
-
<tbody>${list.map((u) => `<tr>
|
|
299
|
-
<td>${u.id}</td>
|
|
300
|
-
<td><b>${esc(u.name)}</b><div class="muted small">${esc(u.email)}</div></td>
|
|
301
|
-
<td><span class="tag ${u.role === 'admin' ? 'mid' : ''}">${esc(u.role)}</span></td>
|
|
302
|
-
<td><span class="tag ${u.plan === 'vip' ? 'mid' : u.plan !== 'free' ? 'ok' : ''}">${esc(u.planName)}</span></td>
|
|
303
|
-
<td class="muted small">${u.offerAcceptedAt ? `<span class="pos" title="${fmtTs(u.offerAcceptedAt)}">подписана ✓</span>` : '—'}</td>
|
|
304
|
-
<td class="muted small">${u.planUntil > Date.now() ? fmtTs(u.planUntil) : 'бессрочно'}</td>
|
|
305
|
-
<td class="small">${u.tgConnected ? '<span class="pos">TG ✓</span> ' : ''}${u.maxConnected ? '<span class="pos">MAX ✓</span>' : (!u.tgConnected ? '<span class="muted">—</span>' : '')}</td>
|
|
306
|
-
<td>
|
|
307
|
-
<select data-user-plan="${u.id}">
|
|
308
|
-
<option value="none"${u.plan === 'none' || u.plan === 'free' ? ' selected' : ''}>Без подписки</option>
|
|
309
|
-
<option value="investor"${u.plan === 'investor' ? ' selected' : ''}>Инвестор (1 счёт)</option>
|
|
310
|
-
<option value="pro"${u.plan === 'pro' ? ' selected' : ''}>Профи (5 счетов)</option>
|
|
311
|
-
<option value="vip"${u.plan === 'vip' ? ' selected' : ''}>👑 VIP</option>
|
|
312
|
-
<option value="owner"${u.plan === 'owner' ? ' selected' : ''}>⚡ Хозяин (Администратор)</option>
|
|
313
|
-
</select>
|
|
314
|
-
</td>
|
|
315
|
-
</tr>`).join('')}</tbody>
|
|
316
|
-
</table>`;
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
$('#usr-search').addEventListener('input', renderUsers);
|
|
320
|
-
$('#form-create-admin').addEventListener('submit', async (e) => {
|
|
321
|
-
e.preventDefault();
|
|
322
|
-
const f = e.target;
|
|
323
|
-
try {
|
|
324
|
-
const r = await api('/api/admin/create-admin', {
|
|
325
|
-
method: 'POST',
|
|
326
|
-
body: {
|
|
327
|
-
email: f.email.value.trim(),
|
|
328
|
-
name: f.name.value.trim(),
|
|
329
|
-
password: f.password.value,
|
|
330
|
-
},
|
|
331
|
-
});
|
|
332
|
-
admState.users = r.users || admState.users;
|
|
333
|
-
f.reset();
|
|
334
|
-
updateKpis();
|
|
335
|
-
renderUsers();
|
|
336
|
-
toast(`Администратор ${r.user?.email || ''} назначен`);
|
|
337
|
-
} catch (err) { toast(err.message); }
|
|
338
|
-
});
|
|
339
|
-
$('#users-table-wrap').addEventListener('change', async (e) => {
|
|
340
|
-
const sel = e.target.closest('select[data-user-plan]');
|
|
341
|
-
if (!sel) return;
|
|
342
|
-
try {
|
|
343
|
-
const r = await api('/api/admin/user-plan', { method: 'POST', body: { userId: Number(sel.dataset.userPlan), plan: sel.value, days: 30 } });
|
|
344
|
-
admState.users = r.users || admState.users;
|
|
345
|
-
updateKpis();
|
|
346
|
-
renderUsers();
|
|
347
|
-
loadInstances();
|
|
348
|
-
toast('Тариф пользователя обновлён');
|
|
349
|
-
} catch (err) { toast(err.message); }
|
|
350
|
-
});
|
|
351
|
-
$('#btn-adm-logout')?.addEventListener('click', async () => {
|
|
352
|
-
await api('/api/auth/logout', { method: 'POST' }).catch(() => {});
|
|
353
|
-
location.href = '/landing';
|
|
354
|
-
});
|
|
355
|
-
|
|
356
|
-
function updateKpis() {
|
|
357
|
-
const users = admState?.users || [];
|
|
358
|
-
const payments = admState?.payments || [];
|
|
359
|
-
$('#kpi-users').textContent = fmtNum(users.length);
|
|
360
|
-
$('#kpi-paid').textContent = fmtNum(users.filter((u) => u.plan === 'investor' || u.plan === 'pro' || u.plan === 'vip').length);
|
|
361
|
-
const rev = payments.filter((p) => p.status === 'succeeded').reduce((s, p) => s + (Number(p.amount_rub) || 0), 0);
|
|
362
|
-
$('#kpi-revenue').textContent = `${fmtNum(rev)} ₽`;
|
|
363
|
-
$('#kpi-bots').textContent = fmtNum(users.filter((u) => u.tgConnected || u.maxConnected).length);
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
// ---------- машины клиентов ----------
|
|
367
|
-
let instances = [];
|
|
368
|
-
const tradingLabel = { active: '<span class="pos">торгует</span>', paused: '<span style="color:#d29922">пауза</span>', halted: '<span class="neg">аварийная остановка</span>' };
|
|
369
|
-
function renderInstances() {
|
|
370
|
-
const sel = $('#form-instance').elements.userId;
|
|
371
|
-
sel.innerHTML = (admState?.users || []).map((u) => `<option value="${u.id}">#${u.id} · ${esc(u.name)} (${esc(u.email)}) · ${esc(u.planName)}</option>`).join('');
|
|
372
|
-
if (!instances.length) {
|
|
373
|
-
$('#instances-wrap').innerHTML = '<p class="muted small">Машин пока нет.</p>';
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
$('#instances-wrap').innerHTML = `<table>
|
|
377
|
-
<thead><tr><th>Машина</th><th>Клиент</th><th>Связь</th><th>Версия</th><th>Состояние</th><th>Торговля</th><th>Действия</th></tr></thead>
|
|
378
|
-
<tbody>${instances.map((i) => {
|
|
379
|
-
const s = i.status || {};
|
|
380
|
-
const ago = i.last_seen ? Math.round((Date.now() - i.last_seen) / 1000) : null;
|
|
381
|
-
const prov = { pending: 'ожидает развёртывания', deploying: 'разворачивается…', deployed: 'развёрнута, ждём связь', running: '', error: 'ошибка развёртывания' }[i.provision_state] || '';
|
|
382
|
-
const where = i.kind === 'desktop'
|
|
383
|
-
? `💻 ${i.installBound ? esc(i.hostname || 'компьютер') : '<span style="color:#d29922">не активирована</span>'}`
|
|
384
|
-
: `☁ <a href="${esc(i.url)}" target="_blank" rel="noopener">${esc(i.host)}</a>`;
|
|
385
|
-
return `<tr>
|
|
386
|
-
<td><b>${esc(i.id)}</b><div class="muted small">${where}${i.note ? ` · ${esc(i.note)}` : ''}</div>${prov ? `<div class="small ${i.provision_state === 'error' ? 'neg' : 'muted'}" title="${esc(i.provision_error || '')}">${prov}</div>` : ''}</td>
|
|
387
|
-
<td>${esc(i.userName)}<div class="muted small">${esc(i.userEmail)}</div></td>
|
|
388
|
-
<td class="small">${i.revoked ? '<span class="neg">аренда завершена</span>' : i.online ? '<span class="pos">онлайн</span>' : '<span class="neg">нет связи</span>'}<div class="muted small">${ago === null ? 'ни разу' : `${ago < 120 ? `${ago} с` : `${Math.round(ago / 60)} мин`} назад`}</div></td>
|
|
389
|
-
<td class="small">${esc(i.version || '—')}</td>
|
|
390
|
-
<td class="small">счетов: <b>${s.accounts ?? '—'}</b>${s.autoAccounts ? ` (авто ${s.autoAccounts})` : ''}${s.haltedAccounts ? ` · <span class="neg">стоп ${s.haltedAccounts}</span>` : ''}<div class="muted small">${s.tradingAllowed === false ? esc(s.tradingReason || 'заявки приостановлены') : s.tradingAllowed ? 'заявки разрешены' : ''}${s.rssMb ? ` · RAM ${s.rssMb} МБ` : ''}${s.dbMb ? ` · БД ${s.dbMb} МБ` : ''}</div>${(s.errors || []).length ? `<div class="neg small" title="${esc(s.errors.join('\n'))}">ошибки: ${s.errors.length}</div>` : ''}</td>
|
|
391
|
-
<td class="small">${tradingLabel[i.trading] || esc(i.trading)}</td>
|
|
392
|
-
<td class="small" style="white-space:nowrap">
|
|
393
|
-
${i.trading !== 'active' ? `<button type="button" class="btn" data-inst-trading="active" data-inst="${esc(i.id)}">▶ Возобновить</button>` : ''}
|
|
394
|
-
${i.trading !== 'paused' ? `<button type="button" class="btn" data-inst-trading="paused" data-inst="${esc(i.id)}">⏸ Пауза</button>` : ''}
|
|
395
|
-
${i.trading !== 'halted' ? `<button type="button" class="btn" data-inst-trading="halted" data-inst="${esc(i.id)}">⛔ Стоп</button>` : ''}
|
|
396
|
-
${i.kind === 'desktop'
|
|
397
|
-
? (i.installBound ? `<button type="button" class="btn" data-inst-unbind="${esc(i.id)}">Отвязать компьютер</button>` : '')
|
|
398
|
-
: `<button type="button" class="btn" data-inst-rotate="${esc(i.id)}">🔑 Новый ключ</button>`}
|
|
399
|
-
<button type="button" class="btn" data-inst-revoke="${esc(i.id)}" data-revoked="${i.revoked ? 1 : 0}">${i.revoked ? 'Восстановить' : 'Завершить аренду'}</button>
|
|
400
|
-
</td>
|
|
401
|
-
</tr>`;
|
|
402
|
-
}).join('')}</tbody>
|
|
403
|
-
</table>`;
|
|
404
|
-
}
|
|
405
|
-
async function loadInstances() {
|
|
406
|
-
try { instances = (await api('/api/admin/instances')).instances || []; } catch (e) { instances = []; $('#instances-wrap').textContent = e.message; return; }
|
|
407
|
-
renderInstances();
|
|
408
|
-
}
|
|
409
|
-
function showInstanceSecret(r) {
|
|
410
|
-
if (!r.env) {
|
|
411
|
-
$('#instance-secret').hidden = true;
|
|
412
|
-
if (r.provisioned) toast('Ключ передан в кластер автоматически — машина перезапустится с новым ключом');
|
|
413
|
-
return;
|
|
414
|
-
}
|
|
415
|
-
$('#instance-secret').hidden = false;
|
|
416
|
-
$('#instance-env').value = r.env;
|
|
417
|
-
}
|
|
418
|
-
$('#btn-copy-env').addEventListener('click', async () => {
|
|
419
|
-
try { await navigator.clipboard.writeText($('#instance-env').value); toast('Скопировано'); } catch { $('#instance-env').select(); }
|
|
420
|
-
});
|
|
421
|
-
$('#form-instance').addEventListener('submit', async (e) => {
|
|
422
|
-
e.preventDefault();
|
|
423
|
-
const f = e.target.elements;
|
|
424
|
-
try {
|
|
425
|
-
const kind = f.kind.value;
|
|
426
|
-
const r = await api('/api/admin/instances', { method: 'POST', body: { userId: Number(f.userId.value), id: f.id.value.trim(), note: f.note.value.trim(), kind } });
|
|
427
|
-
// для компьютера ключ не нужен: клиент активирует установку кодом из кабинета
|
|
428
|
-
if (kind === 'cloud') showInstanceSecret(r); else $('#instance-secret').hidden = true;
|
|
429
|
-
f.id.value = ''; f.note.value = '';
|
|
430
|
-
await loadInstances();
|
|
431
|
-
toast(kind === 'desktop' ? `Установка ${r.instance.id} создана — клиент активирует её кодом из кабинета` : r.provisioned ? `Машина ${r.instance.id} разворачивается в кластере` : `Машина ${r.instance.id} создана`);
|
|
432
|
-
} catch (err) { toast(err.message); }
|
|
433
|
-
});
|
|
434
|
-
$('#instances-wrap').addEventListener('click', async (e) => {
|
|
435
|
-
const b = e.target.closest('button');
|
|
436
|
-
if (!b) return;
|
|
437
|
-
try {
|
|
438
|
-
if (b.dataset.instTrading) {
|
|
439
|
-
const t = b.dataset.instTrading;
|
|
440
|
-
if (t === 'halted' && !confirm(`Аварийно остановить ВСЮ торговлю на машине ${b.dataset.inst}, включая защитные стопы? Применится в течение минуты.`)) return;
|
|
441
|
-
await api(`/api/admin/instances/${encodeURIComponent(b.dataset.inst)}/trading`, { method: 'POST', body: { trading: t } });
|
|
442
|
-
toast('Команда отправится машине при следующей связи (до 1 мин)');
|
|
443
|
-
} else if (b.dataset.instRotate) {
|
|
444
|
-
if (!confirm(`Выпустить новый ключ для ${b.dataset.instRotate}? Старый перестанет работать сразу — обновите Secret в Kubernetes.`)) return;
|
|
445
|
-
showInstanceSecret(await api(`/api/admin/instances/${encodeURIComponent(b.dataset.instRotate)}/rotate-key`, { method: 'POST' }));
|
|
446
|
-
} else if (b.dataset.instUnbind) {
|
|
447
|
-
if (!confirm(`Отвязать компьютер от ${b.dataset.instUnbind}? Установка на нём сразу перестанет получать лицензию.`)) return;
|
|
448
|
-
await api(`/api/admin/instances/${encodeURIComponent(b.dataset.instUnbind)}/unbind`, { method: 'POST' });
|
|
449
|
-
} else if (b.dataset.instRevoke) {
|
|
450
|
-
const revoke = b.dataset.revoked !== '1';
|
|
451
|
-
if (revoke && !confirm(`Завершить аренду ${b.dataset.instRevoke}? Новые заявки будут остановлены, защитные стопы останутся.`)) return;
|
|
452
|
-
await api(`/api/admin/instances/${encodeURIComponent(b.dataset.instRevoke)}/revoke`, { method: 'POST', body: { revoked: revoke } });
|
|
453
|
-
}
|
|
454
|
-
await loadInstances();
|
|
455
|
-
} catch (err) { toast(err.message); }
|
|
456
|
-
});
|
|
457
|
-
setInterval(() => { if (!document.querySelector('section[data-pane="instances"]').hidden) loadInstances(); }, 30000);
|
|
458
|
-
|
|
459
|
-
async function loadAdminState() {
|
|
460
|
-
admState = await api('/api/admin/state');
|
|
461
|
-
updateKpis();
|
|
462
|
-
renderUsers();
|
|
463
|
-
loadInstances();
|
|
464
|
-
|
|
465
|
-
// Billing
|
|
466
|
-
$('#yoo-webhook').textContent = `${location.origin}/api/billing/yookassa/webhook`;
|
|
467
|
-
const bf = $('#form-billing').elements;
|
|
468
|
-
bf.shopId.value = admState.billing?.shopId || '';
|
|
469
|
-
bf.secretKey.value = '';
|
|
470
|
-
bf.secretKey.placeholder = admState.billing?.hasSecret ? 'Секретный ключ задан (оставьте пустым, чтобы не менять)' : 'Секретный ключ ЮKassa';
|
|
471
|
-
bf.sendReceipt.checked = Boolean(admState.billing?.sendReceipt);
|
|
472
|
-
|
|
473
|
-
// Promos & VIP tokens
|
|
474
|
-
const promos = admState.promos || [];
|
|
475
|
-
$('#promos-list').innerHTML = promos.length
|
|
476
|
-
? `<table><thead><tr><th>Токен / Код</th><th>Тариф</th><th>Дней</th><th>Использовано</th><th>Создан</th></tr></thead><tbody>${promos.map((p) => `<tr><td><code>${esc(p.code)}</code></td><td><span class="tag ${p.plan === 'vip' ? 'mid' : 'ok'}">${esc(p.plan.toUpperCase())}</span></td><td>${p.days} дн.</td><td>${p.used_count} / ${p.max_uses}</td><td class="muted small">${fmtTs(p.created_at)}</td></tr>`).join('')}</tbody></table>`
|
|
477
|
-
: '<p class="muted small">Активных VIP-токенов и промокодов пока нет.</p>';
|
|
478
|
-
|
|
479
|
-
// VIP requests
|
|
480
|
-
const vreqs = admState.vipRequests || [];
|
|
481
|
-
$('#vip-reqs-wrap').innerHTML = vreqs.length
|
|
482
|
-
? `<table><thead><tr><th>Дата</th><th>Имя</th><th>Контакт / Email</th><th>Планируемый капитал</th><th>Пользователь</th></tr></thead><tbody>${vreqs.map((v) => `<tr><td class="muted small">${fmtTs(v.createdAt)}</td><td><b>${esc(v.name)}</b></td><td><code>${esc(v.contact || v.email)}</code></td><td>${esc(v.capitalRub || '—')}</td><td>${v.userId ? `#${v.userId}` : 'гость'}</td></tr>`).join('')}</tbody></table>`
|
|
483
|
-
: '<p class="muted small">Новых заявок на согласование тарифа VIP пока нет.</p>';
|
|
484
|
-
|
|
485
|
-
// Payments
|
|
486
|
-
const pays = admState.payments || [];
|
|
487
|
-
$('#payments-wrap').innerHTML = pays.length
|
|
488
|
-
? `<table><thead><tr><th>Дата</th><th>ID платежа</th><th>Пользователь</th><th>Тариф</th><th class="num">Сумма</th><th>Статус</th></tr></thead><tbody>${pays.map((p) => `<tr><td class="muted small">${fmtTs(p.created_at)}</td><td><code>${esc(p.id)}</code></td><td>#${p.user_id}</td><td>${esc(p.plan)} (${p.months} мес.)</td><td class="num"><b>${fmtNum(p.amount_rub)} ₽</b></td><td><span class="tag ${p.status === 'succeeded' ? 'ok' : p.status === 'pending' ? 'mid' : 'no'}">${esc(p.status)}</span></td></tr>`).join('')}</tbody></table>`
|
|
489
|
-
: '<p class="muted small">Платежей через ЮKassa пока не было.</p>';
|
|
490
|
-
|
|
491
|
-
// OAuth
|
|
492
|
-
const oa = admState.oauth || {};
|
|
493
|
-
const provs = [
|
|
494
|
-
['yandex', 'Яндекс ID (oauth.yandex.ru)'],
|
|
495
|
-
['sber', 'Сбер ID (developers.sber.ru)'],
|
|
496
|
-
['tbank', 'T-ID Т-Банк (tbank.ru)'],
|
|
497
|
-
['google', 'Google OAuth (console.cloud.google.com)'],
|
|
498
|
-
['vk', 'VK ID (id.vk.com)'],
|
|
499
|
-
];
|
|
500
|
-
$('#oauth-rows').innerHTML = provs.map(([pid, lbl]) => {
|
|
501
|
-
const cur = oa[pid] || {};
|
|
502
|
-
return `<div style="display:grid;grid-template-columns:220px 200px 1fr auto;gap:10px;align-items:center">
|
|
503
|
-
<b>${esc(lbl)}</b>
|
|
504
|
-
<input name="${pid}_id" value="${esc(cur.clientId || '')}" placeholder="Client ID">
|
|
505
|
-
<input name="${pid}_secret" type="text" class="secret-input" autocomplete="off" spellcheck="false" data-lpignore="true" placeholder="${cur.hasSecret ? 'Client Secret задан (оставьте пустым)' : 'Client Secret'}">
|
|
506
|
-
<code>${location.origin}/api/auth/oauth/${pid}/callback</code>
|
|
507
|
-
</div>`;
|
|
508
|
-
}).join('');
|
|
509
|
-
|
|
510
|
-
// Bots
|
|
511
|
-
const nf = $('#form-notify').elements;
|
|
512
|
-
nf.tgBotName.value = admState.notify?.tgBotName || '';
|
|
513
|
-
nf.tgToken.value = '';
|
|
514
|
-
nf.tgToken.placeholder = admState.notify?.hasTgToken ? 'Токен Telegram задан (оставьте пустым)' : 'Токен Telegram Bot API';
|
|
515
|
-
nf.maxBotName.value = admState.notify?.maxBotName || '';
|
|
516
|
-
nf.maxToken.value = '';
|
|
517
|
-
nf.maxToken.placeholder = admState.notify?.hasMaxToken ? 'Токен MAX задан (оставьте пустым)' : 'Токен MAX Bot API';
|
|
518
|
-
|
|
519
|
-
// Legal
|
|
520
|
-
const lf = $('#form-legal').elements;
|
|
521
|
-
const lg = admState.legal || {};
|
|
522
|
-
for (const k of ['ownerName', 'status', 'inn', 'ogrn', 'email', 'phone', 'address']) {
|
|
523
|
-
if (lf[k]) lf[k].value = lg[k] || '';
|
|
524
|
-
}
|
|
525
|
-
}
|
|
526
|
-
|
|
527
|
-
$('#form-billing').addEventListener('submit', async (e) => {
|
|
528
|
-
e.preventDefault();
|
|
529
|
-
const f = e.target.elements;
|
|
530
|
-
try {
|
|
531
|
-
await api('/api/admin/billing', { method: 'POST', body: { shopId: f.shopId.value, secretKey: f.secretKey.value, sendReceipt: f.sendReceipt.checked } });
|
|
532
|
-
toast('Настройки ЮKassa сохранены');
|
|
533
|
-
loadAdminState();
|
|
534
|
-
} catch (err) { toast(err.message); }
|
|
535
|
-
});
|
|
536
|
-
|
|
537
|
-
$('#form-promo').addEventListener('submit', async (e) => {
|
|
538
|
-
e.preventDefault();
|
|
539
|
-
const f = e.target.elements;
|
|
540
|
-
try {
|
|
541
|
-
await api('/api/admin/promo', {
|
|
542
|
-
method: 'POST',
|
|
543
|
-
body: {
|
|
544
|
-
code: f.code.value,
|
|
545
|
-
plan: f.plan.value,
|
|
546
|
-
days: f.days.value,
|
|
547
|
-
maxUses: f.maxUses.value,
|
|
548
|
-
generateVip: f.plan.value === 'vip' && !f.code.value.trim(),
|
|
549
|
-
},
|
|
550
|
-
});
|
|
551
|
-
f.code.value = '';
|
|
552
|
-
toast('Токен / промокод выпущен');
|
|
553
|
-
loadAdminState();
|
|
554
|
-
} catch (err) { toast(err.message); }
|
|
555
|
-
});
|
|
556
|
-
|
|
557
|
-
$('#form-oauth').addEventListener('submit', async (e) => {
|
|
558
|
-
e.preventDefault();
|
|
559
|
-
const f = e.target.elements;
|
|
560
|
-
const body = {};
|
|
561
|
-
for (const pid of ['yandex', 'sber', 'tbank', 'google', 'vk']) {
|
|
562
|
-
body[pid] = { clientId: f[`${pid}_id`]?.value || '', clientSecret: f[`${pid}_secret`]?.value || '' };
|
|
563
|
-
}
|
|
564
|
-
try {
|
|
565
|
-
await api('/api/admin/oauth', { method: 'POST', body });
|
|
566
|
-
toast('Настройки OAuth-провайдеров сохранены');
|
|
567
|
-
loadAdminState();
|
|
568
|
-
} catch (err) { toast(err.message); }
|
|
569
|
-
});
|
|
570
|
-
|
|
571
|
-
$('#form-notify').addEventListener('submit', async (e) => {
|
|
572
|
-
e.preventDefault();
|
|
573
|
-
const f = e.target.elements;
|
|
574
|
-
try {
|
|
575
|
-
await api('/api/admin/notify', { method: 'POST', body: { tgBotName: f.tgBotName.value, tgToken: f.tgToken.value, maxBotName: f.maxBotName.value, maxToken: f.maxToken.value } });
|
|
576
|
-
toast('Настройки ботов сохранены');
|
|
577
|
-
loadAdminState();
|
|
578
|
-
} catch (err) { toast(err.message); }
|
|
579
|
-
});
|
|
580
|
-
|
|
581
|
-
$('#form-legal').addEventListener('submit', async (e) => {
|
|
582
|
-
e.preventDefault();
|
|
583
|
-
const f = e.target.elements;
|
|
584
|
-
try {
|
|
585
|
-
await api('/api/admin/legal', {
|
|
586
|
-
method: 'POST',
|
|
587
|
-
body: {
|
|
588
|
-
ownerName: f.ownerName.value,
|
|
589
|
-
status: f.status.value,
|
|
590
|
-
inn: f.inn.value,
|
|
591
|
-
ogrn: f.ogrn.value,
|
|
592
|
-
email: f.email.value,
|
|
593
|
-
phone: f.phone.value,
|
|
594
|
-
address: f.address.value,
|
|
595
|
-
},
|
|
596
|
-
});
|
|
597
|
-
toast('Юридические реквизиты оферты сохранены');
|
|
598
|
-
} catch (err) { toast(err.message); }
|
|
599
|
-
});
|
|
600
|
-
|
|
601
|
-
loadAdminState().catch((err) => toast(err.message));
|
|
602
|
-
</script>
|
|
603
|
-
</body>
|
|
604
|
-
</html>
|