work-license-server 1.0.10 → 1.0.11
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/assets/admin.js +109 -21
- package/assets/landing.js +15 -2
- package/bin/server.mjs +1 -1
- package/package.json +1 -1
- package/src/admin.js +33 -19
- package/src/landing.js +1 -0
- package/src/server.js +12 -2
- package/src/store.js +81 -6
package/assets/admin.js
CHANGED
|
@@ -14,7 +14,12 @@
|
|
|
14
14
|
let currentCertLic = null;
|
|
15
15
|
|
|
16
16
|
function getStoredToken() {
|
|
17
|
-
try {
|
|
17
|
+
try {
|
|
18
|
+
return sessionStorage.getItem(TOKEN_KEY)
|
|
19
|
+
|| localStorage.getItem(TOKEN_KEY)
|
|
20
|
+
|| localStorage.getItem('odant_work_session')
|
|
21
|
+
|| '';
|
|
22
|
+
}
|
|
18
23
|
catch { return ''; }
|
|
19
24
|
}
|
|
20
25
|
function setStoredToken(tok) {
|
|
@@ -23,6 +28,9 @@
|
|
|
23
28
|
if (tok) {
|
|
24
29
|
sessionStorage.setItem(TOKEN_KEY, tok);
|
|
25
30
|
localStorage.setItem(TOKEN_KEY, tok);
|
|
31
|
+
if (tok.startsWith('wks_')) {
|
|
32
|
+
localStorage.setItem('odant_work_session', tok);
|
|
33
|
+
}
|
|
26
34
|
}
|
|
27
35
|
else {
|
|
28
36
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
@@ -62,48 +70,106 @@
|
|
|
62
70
|
applyTheme(document.documentElement.getAttribute('data-theme') === 'light' ? 'dark' : 'light');
|
|
63
71
|
}
|
|
64
72
|
|
|
65
|
-
/* ================= Авторизация администратора ================= */
|
|
73
|
+
/* ================= Авторизация администратора (по Email и 6-значному коду из письма) ================= */
|
|
66
74
|
function showLoginGate(errMsg = '') {
|
|
67
75
|
$('#adminLoginScreen').style.display = '';
|
|
68
76
|
$('#adminAppShell').style.display = 'none';
|
|
69
77
|
if ($('#admLoginError')) $('#admLoginError').textContent = errMsg;
|
|
70
|
-
setTimeout(() => $('#
|
|
78
|
+
setTimeout(() => $('#admLoginEmail')?.focus(), 50);
|
|
71
79
|
}
|
|
72
80
|
|
|
73
|
-
function
|
|
81
|
+
function resetAdmStep() {
|
|
82
|
+
if ($('#admStepEmail')) $('#admStepEmail').style.display = '';
|
|
83
|
+
if ($('#admStepCode')) $('#admStepCode').style.display = 'none';
|
|
84
|
+
if ($('#admLoginCode')) $('#admLoginCode').value = '';
|
|
85
|
+
if ($('#admLoginError')) $('#admLoginError').textContent = '';
|
|
86
|
+
setTimeout(() => $('#admLoginEmail')?.focus(), 50);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function showAdminApp(login = 'BOSS') {
|
|
74
90
|
$('#adminLoginScreen').style.display = 'none';
|
|
75
91
|
$('#adminAppShell').style.display = '';
|
|
76
92
|
if ($('#admUserLabel')) $('#admUserLabel').textContent = `Выйти (${login})`;
|
|
77
93
|
}
|
|
78
94
|
|
|
79
|
-
async function
|
|
80
|
-
const
|
|
81
|
-
const password = $('#admLoginPass')?.value || '';
|
|
95
|
+
async function sendAdminOtpCode() {
|
|
96
|
+
const email = $('#admLoginEmail')?.value.trim() || '';
|
|
82
97
|
const errBox = $('#admLoginError');
|
|
83
98
|
if (errBox) errBox.textContent = '';
|
|
99
|
+
if (!email || !email.includes('@')) {
|
|
100
|
+
if (errBox) errBox.textContent = 'Введите ваш корректный email адрес.';
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
const btn = $('#btnAdmSendCode');
|
|
104
|
+
if (btn) btn.disabled = true;
|
|
84
105
|
try {
|
|
85
|
-
const res = await fetch('/api/
|
|
106
|
+
const res = await fetch('/api/auth/send-code', {
|
|
86
107
|
method: 'POST',
|
|
87
108
|
headers: { 'Content-Type': 'application/json' },
|
|
88
|
-
body: JSON.stringify({
|
|
109
|
+
body: JSON.stringify({ email }),
|
|
89
110
|
});
|
|
90
111
|
const data = await res.json().catch(() => ({}));
|
|
91
|
-
if (!res.ok
|
|
92
|
-
|
|
112
|
+
if (!res.ok) throw new Error(data.error || 'Ошибка отправки кода');
|
|
113
|
+
if ($('#admStepEmail')) $('#admStepEmail').style.display = 'none';
|
|
114
|
+
if ($('#admStepCode')) $('#admStepCode').style.display = '';
|
|
115
|
+
if ($('#admCodeSentNotice')) {
|
|
116
|
+
$('#admCodeSentNotice').textContent = data.message || `Код отправлен на ${email}. Введите его ниже.`;
|
|
117
|
+
}
|
|
118
|
+
if ($('#admLoginCode')) {
|
|
119
|
+
$('#admLoginCode').value = '';
|
|
120
|
+
setTimeout(() => $('#admLoginCode')?.focus(), 50);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
catch (err) {
|
|
124
|
+
if (errBox) errBox.textContent = err.message || String(err);
|
|
125
|
+
}
|
|
126
|
+
finally {
|
|
127
|
+
if (btn) btn.disabled = false;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async function verifyAdminOtpCode() {
|
|
132
|
+
const email = $('#admLoginEmail')?.value.trim() || '';
|
|
133
|
+
const code = $('#admLoginCode')?.value.trim() || '';
|
|
134
|
+
const errBox = $('#admLoginError');
|
|
135
|
+
if (errBox) errBox.textContent = '';
|
|
136
|
+
if (!code || code.length < 6) {
|
|
137
|
+
if (errBox) errBox.textContent = 'Введите 6-значный код из письма.';
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const btn = $('#btnAdmVerifyCode');
|
|
141
|
+
if (btn) btn.disabled = true;
|
|
142
|
+
try {
|
|
143
|
+
const res = await fetch('/api/auth/verify-code', {
|
|
144
|
+
method: 'POST',
|
|
145
|
+
headers: { 'Content-Type': 'application/json' },
|
|
146
|
+
body: JSON.stringify({ email, code }),
|
|
147
|
+
});
|
|
148
|
+
const user = await res.json().catch(() => ({}));
|
|
149
|
+
if (!res.ok || !user.sessionKey) {
|
|
150
|
+
throw new Error(user.error || 'Неверный код подтверждения');
|
|
151
|
+
}
|
|
152
|
+
if (!user.isAdmin) {
|
|
153
|
+
throw new Error(`Аккаунт ${user.email} не обладает правами администратора (BOSS) сервера лицензирования.`);
|
|
93
154
|
}
|
|
94
|
-
setStoredToken(
|
|
95
|
-
|
|
96
|
-
showAdminApp(
|
|
155
|
+
setStoredToken(user.sessionKey);
|
|
156
|
+
resetAdmStep();
|
|
157
|
+
showAdminApp(user.email);
|
|
97
158
|
await refreshRegistry();
|
|
98
159
|
}
|
|
99
160
|
catch (err) {
|
|
100
161
|
if (errBox) errBox.textContent = err.message || String(err);
|
|
101
162
|
}
|
|
163
|
+
finally {
|
|
164
|
+
if (btn) btn.disabled = false;
|
|
165
|
+
}
|
|
102
166
|
}
|
|
103
167
|
|
|
104
168
|
async function logoutAdmin() {
|
|
105
169
|
try { await api('/api/admin/logout', {}); } catch { /* ignore */ }
|
|
106
170
|
setStoredToken('');
|
|
171
|
+
try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
|
|
172
|
+
resetAdmStep();
|
|
107
173
|
showLoginGate('');
|
|
108
174
|
}
|
|
109
175
|
|
|
@@ -293,12 +359,16 @@
|
|
|
293
359
|
}).join('') || '<tr><td colspan="5" class="muted">Обращений пока нет</td></tr>';
|
|
294
360
|
}
|
|
295
361
|
|
|
296
|
-
// 5.
|
|
362
|
+
// 5. Клиенты, роли BOSS и оферты (ПЭП)
|
|
297
363
|
if ($('#admUsersBody')) {
|
|
298
364
|
$('#admUsersBody').innerHTML = users.map(u => `<tr>
|
|
299
365
|
<td><b>${esc(u.userId)}</b><div class="small muted">${esc(u.email)}</div></td>
|
|
366
|
+
<td>
|
|
367
|
+
${u.isAdmin
|
|
368
|
+
? `<span class="tag ok">BOSS · Администратор</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="0">Снять права</button>`
|
|
369
|
+
: `<span class="tag">Клиент</span> <button class="btn btn-ghost btn-sm" style="margin-left:6px" data-toggle-admin="${esc(u.userId)}" data-make-admin="1">Сделать админом</button>`}
|
|
370
|
+
</td>
|
|
300
371
|
<td><b>${esc(u.companyName || '—')}</b><div class="small muted">ИНН ${esc(u.inn || '—')}</div></td>
|
|
301
|
-
<td><b>${esc(u.name || '—')}</b><div class="small muted">${esc(u.position || '')} ${esc(u.phone || '')}</div></td>
|
|
302
372
|
<td><code>${esc(u.licenseId || '—')}</code></td>
|
|
303
373
|
<td>
|
|
304
374
|
${u.ofertaAccepted
|
|
@@ -310,6 +380,17 @@
|
|
|
310
380
|
}
|
|
311
381
|
}
|
|
312
382
|
|
|
383
|
+
async function adminToggleUserRole(userId, makeAdmin) {
|
|
384
|
+
try {
|
|
385
|
+
await api('/api/admin/users/role', {
|
|
386
|
+
userId,
|
|
387
|
+
isAdmin: Boolean(makeAdmin),
|
|
388
|
+
});
|
|
389
|
+
refreshRegistry();
|
|
390
|
+
}
|
|
391
|
+
catch (err) { alert(err.message); }
|
|
392
|
+
}
|
|
393
|
+
|
|
313
394
|
/* ================= Действия администратора ================= */
|
|
314
395
|
async function adminIssueLicense() {
|
|
315
396
|
const st = $('#admIssueStatus');
|
|
@@ -518,14 +599,10 @@
|
|
|
518
599
|
}
|
|
519
600
|
|
|
520
601
|
function bind() {
|
|
521
|
-
$('#adminLoginForm')?.addEventListener('submit', (e) => {
|
|
522
|
-
e.preventDefault();
|
|
523
|
-
submitAdminLogin();
|
|
524
|
-
});
|
|
525
602
|
$('#btnTheme')?.addEventListener('click', toggleTheme);
|
|
526
603
|
|
|
527
604
|
document.addEventListener('click', (e) => {
|
|
528
|
-
const el = e.target.closest('[data-action],[data-adm-tab],[data-adm-ask],[data-cert],[data-pay-order],[data-cancel-order],[data-activate-res],[data-certify-partner],[data-partner-issue],[data-reply-ticket],[data-close-ticket]');
|
|
605
|
+
const el = e.target.closest('[data-action],[data-adm-tab],[data-adm-ask],[data-cert],[data-pay-order],[data-cancel-order],[data-activate-res],[data-certify-partner],[data-partner-issue],[data-reply-ticket],[data-close-ticket],[data-toggle-admin]');
|
|
529
606
|
if (!el) return;
|
|
530
607
|
if (el.matches('[data-adm-tab]')) { switchAdminTab(el.dataset.admTab); return; }
|
|
531
608
|
if (el.matches('[data-adm-ask]')) { sendAdminAi(el.dataset.admAsk); return; }
|
|
@@ -537,8 +614,12 @@
|
|
|
537
614
|
if (el.matches('[data-partner-issue]')) { adminPartnerIssueLicense(el.dataset.partnerIssue); return; }
|
|
538
615
|
if (el.matches('[data-reply-ticket]')) { adminReplyTicket(el.dataset.replyTicket); return; }
|
|
539
616
|
if (el.matches('[data-close-ticket]')) { adminCloseTicket(el.dataset.closeTicket); return; }
|
|
617
|
+
if (el.matches('[data-toggle-admin]')) { adminToggleUserRole(el.dataset.toggleAdmin, el.dataset.makeAdmin === '1'); return; }
|
|
540
618
|
|
|
541
619
|
switch (el.dataset.action) {
|
|
620
|
+
case 'adm-send-code': sendAdminOtpCode(); break;
|
|
621
|
+
case 'adm-verify-code': verifyAdminOtpCode(); break;
|
|
622
|
+
case 'adm-reset-step': resetAdmStep(); break;
|
|
542
623
|
case 'registry': refreshRegistry(); break;
|
|
543
624
|
case 'demo-pak': createDemoPak(); break;
|
|
544
625
|
case 'admin-logout': logoutAdmin(); break;
|
|
@@ -551,6 +632,13 @@
|
|
|
551
632
|
}
|
|
552
633
|
});
|
|
553
634
|
|
|
635
|
+
$('#adminLoginScreen')?.addEventListener('keydown', (e) => {
|
|
636
|
+
if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
|
|
637
|
+
if ($('#admStepCode')?.style.display !== 'none') verifyAdminOtpCode();
|
|
638
|
+
else sendAdminOtpCode();
|
|
639
|
+
}
|
|
640
|
+
});
|
|
641
|
+
|
|
554
642
|
$('#certModal')?.addEventListener('click', (e) => { if (e.target.id === 'certModal') $('#certModal').classList.remove('open'); });
|
|
555
643
|
document.addEventListener('keydown', (e) => {
|
|
556
644
|
if (e.key === 'Escape') $('#certModal')?.classList.remove('open');
|
package/assets/landing.js
CHANGED
|
@@ -248,8 +248,13 @@
|
|
|
248
248
|
function performLogout() {
|
|
249
249
|
currentUser = null;
|
|
250
250
|
pendingAuthAction = null;
|
|
251
|
-
try {
|
|
251
|
+
try {
|
|
252
|
+
localStorage.removeItem('odant_work_session');
|
|
253
|
+
localStorage.removeItem('odant_work_admin_token');
|
|
254
|
+
sessionStorage.removeItem('odant_work_admin_token');
|
|
255
|
+
} catch { /* ignore */ }
|
|
252
256
|
if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
|
|
257
|
+
if ($('#tileAdmin')) $('#tileAdmin').style.display = 'none';
|
|
253
258
|
if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
|
|
254
259
|
if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
|
|
255
260
|
closeAuth();
|
|
@@ -275,13 +280,21 @@
|
|
|
275
280
|
function onAuthenticated() {
|
|
276
281
|
if (!currentUser) return;
|
|
277
282
|
if ($('#tileAddons')) $('#tileAddons').style.display = isUserReadyForOrders() ? '' : 'none';
|
|
283
|
+
if ($('#tileAdmin')) $('#tileAdmin').style.display = currentUser.isAdmin ? '' : 'none';
|
|
284
|
+
if (currentUser.isAdmin && currentUser.sessionKey) {
|
|
285
|
+
try {
|
|
286
|
+
localStorage.setItem('odant_work_admin_token', currentUser.sessionKey);
|
|
287
|
+
sessionStorage.setItem('odant_work_admin_token', currentUser.sessionKey);
|
|
288
|
+
} catch { /* ignore */ }
|
|
289
|
+
}
|
|
278
290
|
if ($('#btnAuthText')) $('#btnAuthText').textContent = currentUser.companyName || currentUser.email;
|
|
279
291
|
if ($('#inpCabinetHubToken')) $('#inpCabinetHubToken').value = currentUser.hubToken || '';
|
|
280
292
|
if ($('#authEmail')) $('#authEmail').value = currentUser.email || '';
|
|
281
293
|
if ($('#authOfertaCheck')) $('#authOfertaCheck').checked = Boolean(currentUser.ofertaAccepted);
|
|
282
294
|
if ($('#userBadgeBox')) {
|
|
283
295
|
const orgInfo = currentUser.companyName ? `${esc(currentUser.companyName)}${currentUser.inn ? ' (ИНН ' + esc(currentUser.inn) + ')' : ''} · ` : '';
|
|
284
|
-
|
|
296
|
+
const bossBadge = currentUser.isAdmin ? `<span class="tag ok" style="margin-right:6px">BOSS · Администратор</span> <a href="/admin" style="font-weight:700;margin-right:8px">Открыть Админ-панель →</a> · ` : '';
|
|
297
|
+
$('#userBadgeBox').innerHTML = `${bossBadge}${orgInfo}Аккаунт: <b>${esc(currentUser.email)}</b> · <a href="#" data-action="logout" style="color:var(--muted)">Выйти</a>`;
|
|
285
298
|
}
|
|
286
299
|
renderCabinetOrders();
|
|
287
300
|
renderCabinetLicenses();
|
package/bin/server.mjs
CHANGED
|
@@ -14,4 +14,4 @@ const server = await app.listen(port);
|
|
|
14
14
|
const addr = server.address();
|
|
15
15
|
const actualPort = typeof addr === 'object' && addr ? addr.port : port;
|
|
16
16
|
console.log(`[work.odant.org] Витрина ODANT-WORK: http://localhost:${actualPort}/`);
|
|
17
|
-
console.log(`[work.odant.org] Панель администратора (
|
|
17
|
+
console.log(`[work.odant.org] Панель администратора (BOSS): http://localhost:${actualPort}/admin (первый вошедший по Email становится BOSS)`);
|
package/package.json
CHANGED
package/src/admin.js
CHANGED
|
@@ -53,31 +53,44 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
53
53
|
<div class="bg-grid" aria-hidden="true"></div>
|
|
54
54
|
<div class="bg-vignette" aria-hidden="true"></div>
|
|
55
55
|
|
|
56
|
-
<!-- Экран защищённого входа администратора -->
|
|
56
|
+
<!-- Экран защищённого входа администратора (по Email и одноразовому коду из письма) -->
|
|
57
57
|
<div class="admin-auth-gate" id="adminLoginScreen">
|
|
58
58
|
<div class="card card-glow admin-login-card">
|
|
59
59
|
<div class="modal-head" style="margin-bottom:18px">
|
|
60
60
|
<img src="/assets/odant-logo.png" alt="ODANT">
|
|
61
61
|
<div>
|
|
62
|
-
<span class="kicker">Защищённый
|
|
62
|
+
<span class="kicker">Защищённый контур BOSS</span>
|
|
63
63
|
<h1 class="h3" style="margin:4px 0 2px;font-size:21px">Админ-панель ODANT-WORK</h1>
|
|
64
|
-
<div class="small muted"
|
|
64
|
+
<div class="small muted">Вход по вашему Email администратора с 6-значным кодом из письма</div>
|
|
65
65
|
</div>
|
|
66
66
|
</div>
|
|
67
|
-
<
|
|
67
|
+
<div id="admStepEmail">
|
|
68
68
|
<div class="field">
|
|
69
|
-
<label for="
|
|
70
|
-
<input type="
|
|
69
|
+
<label for="admLoginEmail">Email администратора (BOSS)</label>
|
|
70
|
+
<input type="email" id="admLoginEmail" value="" placeholder="Введите ваш email…" autocomplete="email" required>
|
|
71
71
|
</div>
|
|
72
|
+
<button type="button" class="btn btn-primary btn-lg" style="width:100%" id="btnAdmSendCode" data-action="adm-send-code">
|
|
73
|
+
Получить 6-значный код на Email
|
|
74
|
+
</button>
|
|
75
|
+
</div>
|
|
76
|
+
<div id="admStepCode" style="display:none">
|
|
77
|
+
<div class="small" id="admCodeSentNotice" style="padding:10px 12px;border-radius:10px;background:rgba(16,185,129,.12);color:var(--ok);margin-bottom:10px"></div>
|
|
72
78
|
<div class="field">
|
|
73
|
-
<label for="
|
|
74
|
-
<input type="
|
|
79
|
+
<label for="admLoginCode">Одноразовый код из письма (6 цифр)</label>
|
|
80
|
+
<input type="text" id="admLoginCode" value="" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" style="font-size:20px;letter-spacing:6px;text-align:center;font-family:monospace">
|
|
75
81
|
</div>
|
|
76
|
-
<
|
|
77
|
-
|
|
78
|
-
${icon('lock')} Войти в панель администратора
|
|
82
|
+
<button type="button" class="btn btn-primary btn-lg" style="width:100%" id="btnAdmVerifyCode" data-action="adm-verify-code">
|
|
83
|
+
${icon('lock')} Подтвердить код и войти в админ-панель
|
|
79
84
|
</button>
|
|
80
|
-
|
|
85
|
+
<div style="display:flex;justify-content:space-between;margin-top:8px">
|
|
86
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="adm-reset-step">← Изменить Email</button>
|
|
87
|
+
<button type="button" class="btn btn-ghost btn-sm" data-action="adm-send-code">Отправить код повторно</button>
|
|
88
|
+
</div>
|
|
89
|
+
</div>
|
|
90
|
+
<div class="status" id="admLoginError" style="color:var(--danger);margin:8px 0 6px;min-height:18px"></div>
|
|
91
|
+
<div style="margin-top:8px;text-align:center">
|
|
92
|
+
<a href="/" class="small muted">← Вернуться на главную страницу work.odant.org</a>
|
|
93
|
+
</div>
|
|
81
94
|
<div class="small muted" style="margin-top:14px;padding-top:12px;border-top:1px solid var(--line)">
|
|
82
95
|
Ключ ЦС Ed25519: <code>${esc(publicKey.slice(0, 26))}…</code>
|
|
83
96
|
</div>
|
|
@@ -87,15 +100,16 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
87
100
|
<!-- Основная консоль администратора (скрыта до успешной авторизации) -->
|
|
88
101
|
<div class="shell" id="adminAppShell" style="display:none">
|
|
89
102
|
<header class="topbar" style="grid-template-columns: auto 1fr auto">
|
|
90
|
-
<
|
|
103
|
+
<a href="/" class="brand" style="text-decoration:none">
|
|
91
104
|
<img src="/assets/odant-logo.png" alt="ODANT">
|
|
92
105
|
<span>
|
|
93
|
-
<span class="brand-name">ODANT<b>·</b>WORK <span class="tag" style="margin-left:6px;vertical-align:middle">ADMIN</span></span><br>
|
|
106
|
+
<span class="brand-name">ODANT<b>·</b>WORK <span class="tag ok" style="margin-left:6px;vertical-align:middle">BOSS · ADMIN</span></span><br>
|
|
94
107
|
<span class="brand-sub">центр сертификации Ed25519 · биллинг ЮKassa · франшизы · SLA</span>
|
|
95
108
|
</span>
|
|
96
|
-
</
|
|
109
|
+
</a>
|
|
97
110
|
<div></div>
|
|
98
111
|
<div class="top-actions">
|
|
112
|
+
<a href="/#addons" class="btn btn-ghost btn-sm" style="text-decoration:none">← Личный кабинет</a>
|
|
99
113
|
<button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
|
|
100
114
|
<button class="btn btn-ghost btn-sm" data-action="registry">${icon('server')}<span>Обновить реестр</span></button>
|
|
101
115
|
<button class="btn btn-ok btn-sm" data-action="demo-pak">+ Демо-лицензия ПАК</button>
|
|
@@ -267,18 +281,18 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
267
281
|
</div>
|
|
268
282
|
</div>
|
|
269
283
|
|
|
270
|
-
<!-- 5. КЛИЕНТЫ И
|
|
284
|
+
<!-- 5. КЛИЕНТЫ И РОЛИ BOSS -->
|
|
271
285
|
<div class="card" data-adm-sec="users">
|
|
272
|
-
<h3 class="h3" style="margin:0 0 12px">Зарегистрированные
|
|
286
|
+
<h3 class="h3" style="margin:0 0 12px">Зарегистрированные клиенты, администраторы (BOSS) и договоры-оферты</h3>
|
|
273
287
|
<div class="table-wrap">
|
|
274
288
|
<table class="t">
|
|
275
289
|
<thead><tr>
|
|
276
290
|
<th>ID / Email</th>
|
|
291
|
+
<th>Роль на сервере</th>
|
|
277
292
|
<th>Организация / ИНН</th>
|
|
278
|
-
<th>Представитель</th>
|
|
279
293
|
<th>Лицензия комплекса</th>
|
|
280
294
|
<th>Статус оферты (ПЭП)</th>
|
|
281
|
-
<th>Дата
|
|
295
|
+
<th>Дата регистрации</th>
|
|
282
296
|
</tr></thead>
|
|
283
297
|
<tbody id="admUsersBody"><tr><td colspan="6" class="muted">Загрузка…</td></tr></tbody>
|
|
284
298
|
</table>
|
package/src/landing.js
CHANGED
|
@@ -184,6 +184,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
184
184
|
<button class="tab" data-stage="clients">Клиентам</button>
|
|
185
185
|
<button class="tab" data-stage="franchise">Франшиза</button>
|
|
186
186
|
<button class="tab" id="tileAddons" data-stage="addons" style="display:none;">Личный кабинет</button>
|
|
187
|
+
<a class="tab" id="tileAdmin" href="/admin" style="display:none;text-decoration:none;">Админ-панель</a>
|
|
187
188
|
</nav>
|
|
188
189
|
|
|
189
190
|
<div class="top-actions">
|
package/src/server.js
CHANGED
|
@@ -753,9 +753,19 @@ export function createLicenseServerApp(options = {}) {
|
|
|
753
753
|
|
|
754
754
|
if (req.method === 'GET' && pathname === '/api/admin/me') {
|
|
755
755
|
const tok = extractBearerToken(req, null, url);
|
|
756
|
-
|
|
756
|
+
const identity = store.getAdminIdentity(tok);
|
|
757
|
+
if (!identity?.authenticated)
|
|
757
758
|
return sendJson(res, 401, { authenticated: false });
|
|
758
|
-
return sendJson(res, 200,
|
|
759
|
+
return sendJson(res, 200, identity);
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
if (req.method === 'POST' && pathname === '/api/admin/users/role') {
|
|
763
|
+
const body = await readJsonBody(req);
|
|
764
|
+
const tok = extractBearerToken(req, body, url);
|
|
765
|
+
if (!store.verifyAdminToken(tok))
|
|
766
|
+
return sendJson(res, 401, { error: 'Требуется авторизация администратора (BOSS)' });
|
|
767
|
+
const updated = store.setUserAdminRole(body.userId || body.email, body.isAdmin !== false);
|
|
768
|
+
return sendJson(res, 200, { ok: true, user: updated });
|
|
759
769
|
}
|
|
760
770
|
|
|
761
771
|
if (req.method === 'GET' && pathname === '/api/admin/overview') {
|
package/src/store.js
CHANGED
|
@@ -90,19 +90,85 @@ export class LicenseStore {
|
|
|
90
90
|
}
|
|
91
91
|
|
|
92
92
|
verifyAdminToken(token = '') {
|
|
93
|
+
return Boolean(this.getAdminIdentity(token)?.authenticated);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
getAdminIdentity(token = '') {
|
|
93
97
|
const clean = String(token || '').trim();
|
|
94
98
|
if (!clean)
|
|
95
|
-
return
|
|
96
|
-
|
|
97
|
-
|
|
99
|
+
return null;
|
|
100
|
+
const adminUser = Object.values(this.state.users || {}).find(u => u.sessionKey === clean && u.isAdmin);
|
|
101
|
+
if (adminUser) {
|
|
102
|
+
return {
|
|
103
|
+
authenticated: true,
|
|
104
|
+
login: adminUser.email,
|
|
105
|
+
email: adminUser.email,
|
|
106
|
+
userId: adminUser.userId,
|
|
107
|
+
role: 'BOSS',
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
if (clean === this.adminPassword && this.adminPassword.length >= 6) {
|
|
111
|
+
return {
|
|
112
|
+
authenticated: true,
|
|
113
|
+
login: this.adminLogin,
|
|
114
|
+
role: 'BOSS',
|
|
115
|
+
};
|
|
116
|
+
}
|
|
98
117
|
const sess = this.adminSessions.get(clean);
|
|
99
118
|
if (!sess)
|
|
100
|
-
return
|
|
119
|
+
return null;
|
|
101
120
|
if (sess.expiresAt < Date.now()) {
|
|
102
121
|
this.adminSessions.delete(clean);
|
|
103
|
-
return
|
|
122
|
+
return null;
|
|
104
123
|
}
|
|
105
|
-
return
|
|
124
|
+
return {
|
|
125
|
+
authenticated: true,
|
|
126
|
+
login: sess.login || this.adminLogin,
|
|
127
|
+
role: 'BOSS',
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Гарантировать, что первый зарегистрированный на сервере пользователь является главным Администратором (BOSS).
|
|
133
|
+
*/
|
|
134
|
+
_ensureBootstrapAdmin() {
|
|
135
|
+
const allUsers = Object.values(this.state.users || {});
|
|
136
|
+
if (!allUsers.length)
|
|
137
|
+
return null;
|
|
138
|
+
const existingAdmin = allUsers.find(u => u.isAdmin);
|
|
139
|
+
if (existingAdmin) {
|
|
140
|
+
existingAdmin.role = 'BOSS';
|
|
141
|
+
return existingAdmin;
|
|
142
|
+
}
|
|
143
|
+
const firstUser = [...allUsers].sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0))[0];
|
|
144
|
+
if (firstUser) {
|
|
145
|
+
firstUser.isAdmin = true;
|
|
146
|
+
firstUser.role = 'BOSS';
|
|
147
|
+
}
|
|
148
|
+
return firstUser || null;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Назначить или снять права администратора (BOSS) у пользователя по Email или userId.
|
|
153
|
+
*/
|
|
154
|
+
setUserAdminRole(emailOrUserId = '', isAdmin = true) {
|
|
155
|
+
const key = String(emailOrUserId || '').trim();
|
|
156
|
+
const user = Object.values(this.state.users || {}).find(
|
|
157
|
+
u => u.userId === key || String(u.email || '').toLowerCase() === key.toLowerCase()
|
|
158
|
+
);
|
|
159
|
+
if (!user)
|
|
160
|
+
throw new Error('Пользователь не найден');
|
|
161
|
+
const nextAdmin = Boolean(isAdmin);
|
|
162
|
+
if (!nextAdmin && user.isAdmin) {
|
|
163
|
+
const otherAdmins = Object.values(this.state.users || {}).filter(u => u.isAdmin && u.userId !== user.userId);
|
|
164
|
+
if (!otherAdmins.length) {
|
|
165
|
+
throw new Error('Нельзя снять права BOSS у единственного администратора сервера');
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
user.isAdmin = nextAdmin;
|
|
169
|
+
user.role = nextAdmin ? 'BOSS' : 'USER';
|
|
170
|
+
this._save();
|
|
171
|
+
return this._formatUserResponse(user);
|
|
106
172
|
}
|
|
107
173
|
|
|
108
174
|
_load() {
|
|
@@ -119,6 +185,7 @@ export class LicenseStore {
|
|
|
119
185
|
this.state.reservations = raw.reservations || {};
|
|
120
186
|
this.state.certificates = raw.certificates || {};
|
|
121
187
|
this.state.users = raw.users || {};
|
|
188
|
+
this._ensureBootstrapAdmin();
|
|
122
189
|
}
|
|
123
190
|
}
|
|
124
191
|
catch { /* начинаем с чистого состояния */ }
|
|
@@ -956,6 +1023,8 @@ export class LicenseStore {
|
|
|
956
1023
|
return {
|
|
957
1024
|
userId: user.userId,
|
|
958
1025
|
email: user.email,
|
|
1026
|
+
isAdmin: Boolean(user.isAdmin),
|
|
1027
|
+
role: user.isAdmin ? 'BOSS' : 'USER',
|
|
959
1028
|
authProvider: user.authProvider || 'email',
|
|
960
1029
|
name: user.name,
|
|
961
1030
|
position: user.position || '',
|
|
@@ -1084,6 +1153,12 @@ export class LicenseStore {
|
|
|
1084
1153
|
}
|
|
1085
1154
|
}
|
|
1086
1155
|
|
|
1156
|
+
const isFirstAdmin = !Object.values(this.state.users || {}).some(u => u.isAdmin);
|
|
1157
|
+
if (isFirstAdmin) {
|
|
1158
|
+
user.isAdmin = true;
|
|
1159
|
+
user.role = 'BOSS';
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1087
1162
|
if (ofertaAccepted !== false && !user.ofertaAccepted) {
|
|
1088
1163
|
user.ofertaAccepted = true;
|
|
1089
1164
|
user.ofertaAcceptedAt = now;
|