work-license-server 1.0.15 → 1.0.17

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/src/admin.js CHANGED
@@ -1,15 +1,8 @@
1
1
  /**
2
- * Рабочая консоль администратора центра лицензирования и биллинга ODANT-WORK (/admin).
2
+ * Админ-панель центра лицензирования и франшиз ODANT-WORK (/admin).
3
3
  *
4
- * Доступна пользователям с ролью BOSS (первый зарегистрированный по Email пользователь
5
- * или назначенный администратор) по их штатной сессии без отдельных паролей.
6
- *
7
- * Разделы:
8
- * 1. Реестр серверов (устройств) и лицензий клиентов
9
- * 2. Реестр региональных франшиз и 12-месячных графиков погашения
10
- * 3. Заказы ЮKassa и безналичные счета-договоры
11
- * 4. Зарегистрированные клиенты, оферты (ПЭП) и роли BOSS
12
- * 5. Тикеты техподдержки SLA (MAX · Telegram · VK)
4
+ * Единая шапка с основным сайтом (О платформе · Клиентам · Франшиза · Личный кабинет · Админ-панель)
5
+ * и двухколоночная навигация со счётчиками в скобках (по эталону Скрина 1).
13
6
  */
14
7
  import {
15
8
  PAK_PRODUCT,
@@ -44,7 +37,7 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
44
37
  <head>
45
38
  <meta charset="utf-8">
46
39
  <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
47
- <title>ODANT-WORK · Админ-панель центра лицензирования и франшиз</title>
40
+ <title>ODANT-WORK · Админ-панель</title>
48
41
  <meta name="robots" content="noindex, nofollow">
49
42
  <meta name="theme-color" content="#04060b">
50
43
  <link rel="icon" type="image/png" href="/favicon.ico">
@@ -97,210 +90,196 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
97
90
  <div style="margin-top:8px;text-align:center">
98
91
  <a href="/" class="small muted">← Вернуться на главную страницу work.odant.org</a>
99
92
  </div>
100
- <div class="small muted" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
101
- Ключ ЦС Ed25519: <code>${esc(publicKey.slice(0, 26))}…</code>
102
- </div>
103
93
  </div>
104
94
  </div>
105
95
 
106
- <!-- Основная операционная консоль администратора -->
96
+ <!-- Основная оболочка с единой шапкой сайта и двухколоночным меню разделов -->
107
97
  <div class="shell" id="adminAppShell" style="display:none">
108
98
  <header class="topbar">
109
- <a href="/" class="brand" style="text-decoration:none">
99
+ <a href="/" class="brand" style="text-decoration:none" aria-label="ODANT-WORK — на главную">
110
100
  <img src="/assets/odant-logo.png" alt="ODANT">
111
101
  <span>
112
- <span class="brand-name">ODANT<b>·</b>WORK <span class="tag ok" style="margin-left:6px;vertical-align:middle">BOSS · АДМИН</span></span><br>
113
- <span class="brand-sub">реестр серверов · реестр франшиз · платежи и счета</span>
102
+ <span class="brand-name">ODANT<b>·</b>WORK</span><br>
103
+ <span class="brand-sub">учёт · документооборот · процессы · приватный ИИ</span>
114
104
  </span>
115
105
  </a>
116
106
 
117
- <nav class="tabs" id="admTopTabs" aria-label="Реестры администратора">
118
- <button class="tab active" data-adm-tab="servers">Серверы и лицензии <span class="tag" id="tabCntServers" style="margin-left:4px">0</span></button>
119
- <button class="tab" data-adm-tab="franchises">Франшизы и графики <span class="tag" id="tabCntFranchises" style="margin-left:4px">0</span></button>
120
- <button class="tab" data-adm-tab="orders">Заказы и счета <span class="tag" id="tabCntOrders" style="margin-left:4px">0</span></button>
121
- <button class="tab" data-adm-tab="users">Клиенты и роли BOSS <span class="tag" id="tabCntUsers" style="margin-left:4px">0</span></button>
122
- <button class="tab" data-adm-tab="tickets">Тикеты SLA <span class="tag" id="tabCntTickets" style="margin-left:4px">0</span></button>
107
+ <nav class="tabs" id="tabs" aria-label="Разделы">
108
+ <span class="tab-ind" id="tabInd"></span>
109
+ <a class="tab" href="/#product" style="text-decoration:none">О платформе</a>
110
+ <a class="tab" href="/#clients" style="text-decoration:none">Клиентам</a>
111
+ <a class="tab" href="/#franchise" style="text-decoration:none">Франшиза</a>
112
+ <a class="tab" href="/#addons" style="text-decoration:none">Личный кабинет</a>
113
+ <a class="tab active" href="/admin" id="tileAdmin" style="text-decoration:none">Админ-панель</a>
123
114
  </nav>
124
115
 
125
116
  <div class="top-actions">
126
- <a href="/#addons" class="btn btn-ghost btn-sm" style="text-decoration:none">← Личный кабинет</a>
127
117
  <button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
128
- <button class="btn btn-ghost btn-sm" data-action="registry">${icon('server')}<span>Обновить</span></button>
129
- <button class="btn btn-ghost btn-sm" data-action="admin-logout" title="Завершить сессию">${icon('lock')}<span id="admUserLabel">Выйти</span></button>
118
+ <button class="btn btn-primary btn-sm" data-action="open-adm-profile" title="Аккаунт администратора">${icon('user')}<span id="admUserLabel">BOSS</span></button>
130
119
  </div>
131
120
  </header>
132
121
 
133
- <section class="stage active" style="padding-top:20px">
134
- <!-- Сводные показатели -->
135
- <div class="stats" style="grid-template-columns:repeat(6,1fr);margin-bottom:20px">
136
- <div class="stat"><b id="admStatLic">0</b><span id="admStatLicSub">серверов · 0 мест</span></div>
137
- <div class="stat"><b id="admStatFranchises">0</b><span id="admStatFranchisesSub">активных франшиз</span></div>
138
- <div class="stat"><b id="admStatPaid">0 ₽</b><span id="admStatPaidSub">оплачено заказов: 0</span></div>
139
- <div class="stat"><b id="admStatPending">0 ₽</b><span id="admStatPendingSub">ожидают оплаты: 0</span></div>
140
- <div class="stat"><b id="admStatUsers">0</b><span id="admStatUsersSub">клиентов в базе</span></div>
141
- <div class="stat"><b id="admStatTickets">0</b><span id="admStatTicketsSub">открытых тикетов SLA</span></div>
142
- </div>
122
+ <section class="stage active" style="padding-top:24px">
123
+ <div class="sec-layout">
124
+ <!-- Левое вертикальное меню разделов со счётчиками в скобках (как на Скрине 1) -->
125
+ <aside class="sec-nav" aria-label="Разделы администрирования">
126
+ <button type="button" class="sec-nav-item active" data-adm-tab="servers" id="navAdmServers">Серверы и лицензии (0)</button>
127
+ <button type="button" class="sec-nav-item" data-adm-tab="franchises" id="navAdmFranchises">Франшизы (0)</button>
128
+ <div class="sec-nav-sep"></div>
129
+ <button type="button" class="sec-nav-item" data-adm-tab="orders" id="navAdmOrders">Заказы и счета (0)</button>
130
+ <button type="button" class="sec-nav-item" data-adm-tab="users" id="navAdmUsers">Клиенты и роли (0)</button>
131
+ <button type="button" class="sec-nav-item" data-adm-tab="tickets" id="navAdmTickets">Тикеты поддержки (0)</button>
132
+ </aside>
143
133
 
144
- <!-- 1. РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) И ЛИЦЕНЗИЙ -->
145
- <div class="card adm-section" data-adm-sec="servers">
146
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
147
- <div>
148
- <span class="kicker">Реестр 1 · Устройства и лицензии</span>
149
- <h3 class="h3" style="margin:2px 0 0">Серверы (устройства) WORK и лицензии клиентов</h3>
150
- </div>
151
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
152
- <input type="search" id="inpFilterServers" placeholder="Поиск по клиенту, Email, ИНН, серверу, HWID…" style="width:280px;padding:8px 12px;font-size:13px">
153
- <select id="selFilterServers" style="padding:8px 12px;font-size:13px">
154
- <option value="all">Все серверы</option>
155
- <option value="base_active">Базовая активирована</option>
156
- <option value="base_inactive">Без базовой лицензии</option>
157
- <option value="hwid_bound">Железо (HWID) привязано</option>
158
- </select>
159
- <button class="btn btn-primary btn-sm" data-action="open-create-server-modal">${icon('plus')} Добавить сервер / лицензию</button>
134
+ <!-- Правая область выбранного раздела -->
135
+ <div class="sec-main">
136
+ <!-- 1. СЕРВЕРЫ И ЛИЦЕНЗИИ -->
137
+ <div data-adm-sec="servers">
138
+ <div class="sec-bar" style="margin-bottom:12px">
139
+ <h2 class="sec-title">Серверы (устройства) WORK и лицензии клиентов</h2>
140
+ <div class="sec-controls">
141
+ <input type="search" id="inpFilterServers" placeholder="Поиск по клиенту, Email, HWID…">
142
+ <select id="selFilterServers">
143
+ <option value="all">Все серверы</option>
144
+ <option value="base_active">Базовая активна</option>
145
+ <option value="base_inactive">Без базовой лицензии</option>
146
+ <option value="hwid_bound">HWID привязан</option>
147
+ </select>
148
+ <button type="button" class="btn btn-primary btn-sm" data-action="open-create-server-modal">+ Добавить сервер</button>
149
+ </div>
150
+ </div>
151
+ <div class="sec-box">
152
+ <table class="sec-table">
153
+ <thead><tr>
154
+ <th>Сервер / ID</th>
155
+ <th>Владелец / Организация</th>
156
+ <th>Железо (HWID)</th>
157
+ <th>Базовая лицензия</th>
158
+ <th>Доп. места и SLA</th>
159
+ <th style="text-align:right">Файлы</th>
160
+ </tr></thead>
161
+ <tbody id="admLicensesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
162
+ </table>
163
+ </div>
160
164
  </div>
161
- </div>
162
- <div class="table-wrap">
163
- <table class="t">
164
- <thead><tr>
165
- <th>Сервер / ID</th>
166
- <th>Владелец / Организация</th>
167
- <th>Привязка железа (HWID)</th>
168
- <th>Базовая лицензия (10 вечн.)</th>
169
- <th>Доп. места и SLA</th>
170
- <th>Управление</th>
171
- </tr></thead>
172
- <tbody id="admLicensesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
173
- </table>
174
- </div>
175
- </div>
176
165
 
177
- <!-- 2. РЕЕСТР ФРАНШИЗ И ГРАФИКОВ ПОГАШЕНИЯ (12 МЕС.) -->
178
- <div class="card adm-section" data-adm-sec="franchises" style="display:none">
179
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
180
- <div>
181
- <span class="kicker">Реестр 2 · Региональная партнёрская сеть</span>
182
- <h3 class="h3" style="margin:2px 0 0">Региональные франшизы, резервы (15 мин) и годовые графики погашения</h3>
183
- </div>
184
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
185
- <input type="search" id="inpFilterFranchises" placeholder="Поиск по региону, компании, ИНН, Email…" style="width:280px;padding:8px 12px;font-size:13px">
186
- <select id="selFilterFranchises" style="padding:8px 12px;font-size:13px">
187
- <option value="all">Все активные франшизы и резервы</option>
188
- <option value="pending_deposit">Ожидают залога (таймер 15 мин)</option>
189
- <option value="deposit_paid">Погашение по графику (12 мес.)</option>
190
- <option value="overdue">С просрочкой ежемесячного платежа</option>
191
- <option value="paid_in_full">Полностью погашены</option>
192
- <option value="closed">Отменённые / Истёкшие</option>
193
- </select>
166
+ <!-- 2. ФРАНШИЗЫ И ГРАФИКИ ПОГАШЕНИЯ (12 МЕС.) -->
167
+ <div data-adm-sec="franchises" style="display:none">
168
+ <div class="sec-bar" style="margin-bottom:12px">
169
+ <h2 class="sec-title">Региональные франшизы и графики погашения (12 мес.)</h2>
170
+ <div class="sec-controls">
171
+ <input type="search" id="inpFilterFranchises" placeholder="Поиск по региону, компании, ИНН…">
172
+ <select id="selFilterFranchises">
173
+ <option value="all">Все активные</option>
174
+ <option value="pending_deposit">Ожидают залога (15 мин)</option>
175
+ <option value="deposit_paid">Погашение по графику</option>
176
+ <option value="overdue">С просрочкой</option>
177
+ <option value="paid_in_full">Полностью погашены</option>
178
+ <option value="closed">Отменённые / Истёкшие</option>
179
+ </select>
180
+ </div>
181
+ </div>
182
+ <div class="sec-box">
183
+ <table class="sec-table">
184
+ <thead><tr>
185
+ <th>Субъект РФ / ID</th>
186
+ <th>Франчайзи</th>
187
+ <th>Залог (100 тыс. ₽)</th>
188
+ <th>График (12 мес.)</th>
189
+ <th>Оплачено / Остаток</th>
190
+ <th style="text-align:right">Управление</th>
191
+ </tr></thead>
192
+ <tbody id="admFranchisesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
193
+ </table>
194
+ </div>
194
195
  </div>
195
- </div>
196
- <div class="table-wrap">
197
- <table class="t">
198
- <thead><tr>
199
- <th>Субъект РФ / ID</th>
200
- <th>Франчайзи / Контакты</th>
201
- <th>Начальный взнос (залог 100 тыс. ₽)</th>
202
- <th>График погашения (12 мес.)</th>
203
- <th>Финансовая сводка</th>
204
- <th>Управление</th>
205
- </tr></thead>
206
- <tbody id="admFranchisesBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
207
- </table>
208
- </div>
209
- </div>
210
196
 
211
- <!-- 3. ЗАКАЗЫ ЮKASSA И БЕЗНАЛИЧНЫЕ СЧЕТА-ДОГОВОРЫ -->
212
- <div class="card adm-section" data-adm-sec="orders" style="display:none">
213
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
214
- <div>
215
- <span class="kicker">Реестр 3 · Биллинг и расчёты</span>
216
- <h3 class="h3" style="margin:2px 0 0">Заказы ЮKassa, счета-договоры юрлиц и взносы по франшизам</h3>
217
- </div>
218
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
219
- <input type="search" id="inpFilterOrders" placeholder="Поиск по номеру заказа, счёту, плательщику, ИНН…" style="width:280px;padding:8px 12px;font-size:13px">
220
- <select id="selFilterOrders" style="padding:8px 12px;font-size:13px">
221
- <option value="all">Все заказы</option>
222
- <option value="pending">Ожидают оплаты</option>
223
- <option value="paid">Оплачены</option>
224
- <option value="canceled">Отменены</option>
225
- </select>
197
+ <!-- 3. ЗАКАЗЫ И СЧЕТА -->
198
+ <div data-adm-sec="orders" style="display:none">
199
+ <div class="sec-bar" style="margin-bottom:12px">
200
+ <h2 class="sec-title">Заказы ЮKassa и безналичные счета-договоры</h2>
201
+ <div class="sec-controls">
202
+ <input type="search" id="inpFilterOrders" placeholder="Поиск по заказу, счёту, ИНН…">
203
+ <select id="selFilterOrders">
204
+ <option value="all">Все заказы</option>
205
+ <option value="pending">Ожидают оплаты</option>
206
+ <option value="paid">Оплачены</option>
207
+ <option value="canceled">Отменены</option>
208
+ </select>
209
+ </div>
210
+ </div>
211
+ <div class="sec-box">
212
+ <table class="sec-table">
213
+ <thead><tr>
214
+ <th>Заказ / Счёт</th>
215
+ <th>Назначение</th>
216
+ <th>Плательщик</th>
217
+ <th>Сумма</th>
218
+ <th>Статус</th>
219
+ <th style="text-align:right">Действия</th>
220
+ </tr></thead>
221
+ <tbody id="admOrdersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
222
+ </table>
223
+ </div>
226
224
  </div>
227
- </div>
228
- <div class="table-wrap">
229
- <table class="t">
230
- <thead><tr>
231
- <th>Заказ / Счёт / Дата</th>
232
- <th>Назначение и объект</th>
233
- <th>Плательщик / ИНН</th>
234
- <th class="num">Сумма</th>
235
- <th>Статус</th>
236
- <th>Действия</th>
237
- </tr></thead>
238
- <tbody id="admOrdersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
239
- </table>
240
- </div>
241
- </div>
242
225
 
243
- <!-- 4. КЛИЕНТЫ, ОФЕРТЫ ПЭП И РОЛИ BOSS -->
244
- <div class="card adm-section" data-adm-sec="users" style="display:none">
245
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
246
- <div>
247
- <span class="kicker">Реестр 4 · Аккаунты владельцев и права доступа</span>
248
- <h3 class="h3" style="margin:2px 0 0">Зарегистрированные клиенты, подписанные оферты (ПЭП) и администраторы (BOSS)</h3>
249
- </div>
250
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
251
- <input type="search" id="inpFilterUsers" placeholder="Поиск по Email, организации, ИНН…" style="width:280px;padding:8px 12px;font-size:13px">
226
+ <!-- 4. КЛИЕНТЫ И РОЛИ BOSS -->
227
+ <div data-adm-sec="users" style="display:none">
228
+ <div class="sec-bar" style="margin-bottom:12px">
229
+ <h2 class="sec-title">Зарегистрированные клиенты и права администраторов (BOSS)</h2>
230
+ <div class="sec-controls">
231
+ <input type="search" id="inpFilterUsers" placeholder="Поиск по Email, организации, ИНН…">
232
+ </div>
233
+ </div>
234
+ <div class="sec-box">
235
+ <table class="sec-table">
236
+ <thead><tr>
237
+ <th>Email / ID</th>
238
+ <th>Роль</th>
239
+ <th>Организация / ИНН</th>
240
+ <th>Реестры клиента</th>
241
+ <th>Оферта (ПЭП)</th>
242
+ <th style="text-align:right">Вход</th>
243
+ </tr></thead>
244
+ <tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
245
+ </table>
246
+ </div>
252
247
  </div>
253
- </div>
254
- <div class="table-wrap">
255
- <table class="t">
256
- <thead><tr>
257
- <th>Email / ID</th>
258
- <th>Роль на сервере</th>
259
- <th>Организация / ИНН</th>
260
- <th>Серверов / Франшиз</th>
261
- <th>Договор-оферта (ПЭП)</th>
262
- <th>Регистрация / Вход</th>
263
- </tr></thead>
264
- <tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
265
- </table>
266
- </div>
267
- </div>
268
248
 
269
- <!-- 5. ТИКЕТЫ SLA -->
270
- <div class="card adm-section" data-adm-sec="tickets" style="display:none">
271
- <div style="display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:14px">
272
- <div>
273
- <span class="kicker">Реестр 5 · Служба поддержки</span>
274
- <h3 class="h3" style="margin:2px 0 0">Обращения техподдержки SLA (MAX · Telegram · VK)</h3>
275
- </div>
276
- <div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
277
- <select id="selFilterTickets" style="padding:8px 12px;font-size:13px">
278
- <option value="all">Все обращения</option>
279
- <option value="open">Открытые (ожидают ответа)</option>
280
- <option value="answered">Отвеченные</option>
281
- <option value="closed">Закрытые</option>
282
- </select>
249
+ <!-- 5. ТИКЕТЫ ПОДДЕРЖКИ SLA -->
250
+ <div data-adm-sec="tickets" style="display:none">
251
+ <div class="sec-bar" style="margin-bottom:12px">
252
+ <h2 class="sec-title">Тикеты техподдержки SLA (MAX · Telegram · VK)</h2>
253
+ <div class="sec-controls">
254
+ <select id="selFilterTickets">
255
+ <option value="all">Все обращения</option>
256
+ <option value="open">Открытые</option>
257
+ <option value="answered">Отвеченные</option>
258
+ <option value="closed">Закрытые</option>
259
+ </select>
260
+ </div>
261
+ </div>
262
+ <div class="sec-box">
263
+ <table class="sec-table">
264
+ <thead><tr>
265
+ <th>Тикет / Канал</th>
266
+ <th>Клиент / Сервер</th>
267
+ <th>SLA</th>
268
+ <th>Переписка</th>
269
+ <th style="text-align:right">Ответ инженера</th>
270
+ </tr></thead>
271
+ <tbody id="admTicketsBody"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody>
272
+ </table>
273
+ </div>
283
274
  </div>
284
275
  </div>
285
- <div class="table-wrap">
286
- <table class="t">
287
- <thead><tr>
288
- <th>Тикет / Канал</th>
289
- <th>Клиент / Сервер</th>
290
- <th>Тариф SLA</th>
291
- <th>Переписка</th>
292
- <th>Ответ инженера</th>
293
- </tr></thead>
294
- <tbody id="admTicketsBody"><tr><td colspan="5" class="muted">Загрузка…</td></tr></tbody>
295
- </table>
296
- </div>
297
276
  </div>
298
277
  </section>
299
278
 
300
279
  <footer>
301
280
  <div class="footer-bottom">
302
- <span>© ODANT · Консоль администратора центра лицензирования ${esc(P.domain)}</span>
303
- <span>Ключ центра сертификации Ed25519: <code>${esc(publicKey)}</code></span>
281
+ <span>© ODANT · ${esc(P.domain)}</span>
282
+ <span>Ключ центра сертификации Ed25519: <code>${esc(publicKey.slice(0, 28))}…</code></span>
304
283
  </div>
305
284
  </footer>
306
285
  </div>
@@ -386,14 +365,14 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
386
365
  <button class="icon-btn" data-action="close-sched-modal" aria-label="Закрыть">${icon('close')}</button>
387
366
  </div>
388
367
  <div id="admSchedSummary" style="margin-bottom:12px"></div>
389
- <div class="table-wrap" style="max-height:420px">
390
- <table class="t">
368
+ <div style="max-height:420px;overflow-y:auto">
369
+ <table class="sec-table">
391
370
  <thead><tr>
392
371
  <th>Месяц</th>
393
372
  <th>Дата платежа</th>
394
- <th class="num">Сумма</th>
373
+ <th>Сумма</th>
395
374
  <th>Статус</th>
396
- <th style="text-align:right">Действие администратора</th>
375
+ <th style="text-align:right">Действие</th>
397
376
  </tr></thead>
398
377
  <tbody id="admSchedBody"></tbody>
399
378
  </table>
@@ -423,6 +402,30 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
423
402
  </div>
424
403
  </div>
425
404
 
405
+ <!-- Модальное окно: Аккаунт администратора (защита от случайного выхода) -->
406
+ <div class="modal" id="admProfileModal" role="dialog" aria-modal="true">
407
+ <div class="card" style="max-width:440px">
408
+ <div class="modal-head">
409
+ <img src="/assets/odant-logo.png" alt="">
410
+ <div>
411
+ <h3 class="h3" style="margin:0">Аккаунт администратора</h3>
412
+ <div class="small muted">Сессия активна и сохранена в браузере</div>
413
+ </div>
414
+ <button class="icon-btn" data-action="close-adm-profile" aria-label="Закрыть">${icon('close')}</button>
415
+ </div>
416
+ <div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
417
+ <div class="small muted">Вы вошли как</div>
418
+ <div style="font-size:16px;font-weight:700;margin-top:2px" id="admProfileEmail">—</div>
419
+ <div style="margin-top:6px"><span class="tag ok">BOSS · Главный администратор</span></div>
420
+ </div>
421
+ <div style="display:flex;flex-direction:column;gap:8px">
422
+ <a class="btn btn-primary" href="/#addons" style="width:100%;text-decoration:none">Перейти в Личный кабинет</a>
423
+ <button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="admin-logout">Выйти из аккаунта</button>
424
+ <button type="button" class="btn btn-ghost btn-sm" style="width:100%" data-action="close-adm-profile">Закрыть</button>
425
+ </div>
426
+ </div>
427
+ </div>
428
+
426
429
  <script>window.__ODANT_ADMIN__ = ${jsonForScript(adminData)};</script>
427
430
  <script src="/assets/admin.js${v}" defer></script>
428
431
  </body>
package/src/crypto.js CHANGED
@@ -381,3 +381,75 @@ export function verifyOwnerAccessToken(tokenStr = '', trustedPublicKeyB64 = '')
381
381
  return false;
382
382
  }
383
383
  }
384
+
385
+ export const SESSION_TOKEN_PREFIX = 'wks_1';
386
+
387
+ /**
388
+ * Сгенерировать бессрочный подписанный токен сессии браузера (wks_1.<payload_b64url>.<sig_b64url>),
389
+ * который не сбрасывается при перезапуске/обновлении контейнера сервера, пока пользователь сам не нажал «Выйти».
390
+ */
391
+ export function createSessionToken({
392
+ userId = '',
393
+ email = '',
394
+ name = '',
395
+ companyName = '',
396
+ inn = '',
397
+ isAdmin = false,
398
+ issuedAt = Date.now(),
399
+ } = {}, authorityKeys) {
400
+ const payloadObj = {
401
+ v: 1,
402
+ userId: String(userId || ''),
403
+ email: String(email || '').toLowerCase(),
404
+ ...(name ? { name: String(name) } : {}),
405
+ ...(companyName ? { companyName: String(companyName) } : {}),
406
+ ...(inn ? { inn: String(inn) } : {}),
407
+ isAdmin: Boolean(isAdmin),
408
+ issuedAt: Number(issuedAt || Date.now()),
409
+ };
410
+ const payloadB64 = Buffer.from(JSON.stringify(payloadObj), 'utf8').toString('base64url');
411
+ let sigB64 = '';
412
+ if (authorityKeys?.privateKey) {
413
+ const priv = crypto.createPrivateKey({
414
+ key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
415
+ format: 'der',
416
+ type: 'pkcs8',
417
+ });
418
+ sigB64 = crypto.sign(null, Buffer.from(`${SESSION_TOKEN_PREFIX}.${payloadB64}`, 'utf8'), priv).toString('base64url');
419
+ }
420
+ return `${SESSION_TOKEN_PREFIX}.${payloadB64}.${sigB64}`;
421
+ }
422
+
423
+ /**
424
+ * Проверить подпись Ed25519 сессионного токена (wks_1.<payload>.<sig>) и вернуть payload.
425
+ */
426
+ export function verifySessionToken(tokenStr = '', trustedPublicKeyB64 = '') {
427
+ try {
428
+ const raw = String(tokenStr || '').trim();
429
+ const parts = raw.split('.');
430
+ if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX || !parts[2])
431
+ return null;
432
+ const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
433
+ if (!payload?.email)
434
+ return null;
435
+ const trustedKeys = resolveTrustedAuthorityKeys(trustedPublicKeyB64);
436
+ const signedData = Buffer.from(`${SESSION_TOKEN_PREFIX}.${parts[1]}`, 'utf8');
437
+ const sigBuf = Buffer.from(parts[2], 'base64url');
438
+ for (const keyB64 of trustedKeys) {
439
+ try {
440
+ const pub = crypto.createPublicKey({
441
+ key: Buffer.from(String(keyB64), 'base64'),
442
+ format: 'der',
443
+ type: 'spki',
444
+ });
445
+ if (crypto.verify(null, signedData, pub, sigBuf))
446
+ return payload;
447
+ }
448
+ catch { /* try next */ }
449
+ }
450
+ return null;
451
+ }
452
+ catch {
453
+ return null;
454
+ }
455
+ }