work-license-server 1.0.10 → 1.0.12

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 CHANGED
@@ -14,7 +14,12 @@
14
14
  let currentCertLic = null;
15
15
 
16
16
  function getStoredToken() {
17
- try { return sessionStorage.getItem(TOKEN_KEY) || localStorage.getItem(TOKEN_KEY) || ''; }
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(() => $('#admLoginPass')?.focus(), 50);
78
+ setTimeout(() => $('#admLoginEmail')?.focus(), 50);
71
79
  }
72
80
 
73
- function showAdminApp(login = 'admin') {
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 submitAdminLogin() {
80
- const login = $('#admLoginUser')?.value.trim() || 'admin';
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/admin/login', {
106
+ const res = await fetch('/api/auth/send-code', {
86
107
  method: 'POST',
87
108
  headers: { 'Content-Type': 'application/json' },
88
- body: JSON.stringify({ login, password }),
109
+ body: JSON.stringify({ email }),
89
110
  });
90
111
  const data = await res.json().catch(() => ({}));
91
- if (!res.ok || !data.token) {
92
- throw new Error(data.error || 'Неверный логин или ключ администратора');
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(data.token);
95
- if ($('#admLoginPass')) $('#admLoginPass').value = '';
96
- showAdminApp(data.login || login);
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 { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
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
- $('#userBadgeBox').innerHTML = `${orgInfo}Аккаунт: <b>${esc(currentUser.email)}</b> · <a href="#" data-action="logout" style="color:var(--muted)">Выйти</a>`;
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] Панель администратора (отдельная страница): http://localhost:${actualPort}/admin (логин: ${app.store.adminLogin})`);
17
+ console.log(`[work.odant.org] Панель администратора (BOSS): http://localhost:${actualPort}/admin (первый вошедший по Email становится BOSS)`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.10",
3
+ "version": "1.0.12",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,5 +28,8 @@
28
28
  "license": "SEE LICENSE IN README.md",
29
29
  "engines": {
30
30
  "node": ">=22.0.0"
31
+ },
32
+ "dependencies": {
33
+ "nodemailer": "^8.0.4"
31
34
  }
32
35
  }
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">Защищённый контур</span>
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">Центр сертификации Ed25519 и биллинга ЮKassa</div>
64
+ <div class="small muted">Вход по вашему Email администратора с 6-значным кодом из письма</div>
65
65
  </div>
66
66
  </div>
67
- <form id="adminLoginForm" onsubmit="return false;">
67
+ <div id="admStepEmail">
68
68
  <div class="field">
69
- <label for="admLoginUser">Логин администратора</label>
70
- <input type="text" id="admLoginUser" value="admin" autocomplete="username" required>
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="admLoginPass">Пароль / Ключ администратора (WORK_ADMIN_PASSWORD)</label>
74
- <input type="password" id="admLoginPass" placeholder="Введите ключ администратора…" autocomplete="current-password" required>
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
- <div class="status" id="admLoginError" style="color:var(--danger);margin:6px 0 12px;min-height:18px"></div>
77
- <button type="submit" class="btn btn-primary btn-lg" style="width:100%" id="btnAdminLoginSubmit">
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
- </form>
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
- <div class="brand" style="cursor:default">
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
- </div>
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">Зарегистрированные клиенты и подписанные договоры-оферты (ПЭП)</h3>
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>Дата акцепта</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
@@ -157,28 +157,90 @@ function loadSmtpConfig() {
157
157
  }
158
158
 
159
159
  /**
160
- * Отправка реального письма с 6-значным кодом подтверждения по SMTPS (TLS 465, без внешних зависимостей).
160
+ * Отправка реального письма с 6-значным кодом подтверждения (через nodemailer — точно как в sources/host/auth-methods.js,
161
+ * с полноценными RFC 5322 заголовками Date, Message-ID и text+html, чтобы Gmail доставлял письмо мгновенно).
161
162
  */
163
+ let _cachedTransporter = null;
162
164
  async function sendOtpEmail({ to, code }) {
163
165
  const cfg = loadSmtpConfig();
164
- const subjectB64 = Buffer.from(`Код подтверждения ODANT-WORK: ${code}`, 'utf8').toString('base64');
165
- const htmlBody = [
166
- '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px;">',
167
- '<h2 style="margin:0 0 12px;color:#0f172a;">Вход в Личный кабинет ODANT-WORK</h2>',
168
- '<p style="color:#475569;font-size:15px;">Ваш одноразовый код подтверждения:</p>',
169
- `<div style="font-size:36px;font-weight:800;letter-spacing:8px;text-align:center;padding:16px;background:#f8fafc;border-radius:10px;color:#0284c7;">${code}</div>`,
170
- '<p style="color:#64748b;font-size:13px;margin-top:16px;">Код действителен 10 минут. Если вы не запрашивали вход на work.odant.org, просто проигнорируйте это письмо.</p>',
171
- '</div>',
172
- ].join('\r\n');
166
+ const subject = 'Код подтверждения ODANT-WORK';
167
+ const textBody = `Ваш одноразовый код: ${code}\n\nДействует 10 минут.`;
168
+ const htmlBody = `
169
+ <h2>Код подтверждения</h2>
170
+ <p>Ваш код для входа/регистрации на work.odant.org:</p>
171
+ <h1 style="font-size: 48px; letter-spacing: 10px; font-weight: bold; text-align: center;">
172
+ ${code}
173
+ </h1>
174
+ <p>Код действителен <strong>10 минут</strong>. Ни с кем не делитесь.</p>
175
+ <p>Если это не вы — просто проигнорируйте письмо.</p>
176
+ <p>С уважением,<br>ODANT-WORK</p>
177
+ `;
178
+
179
+ try {
180
+ let nm;
181
+ try {
182
+ nm = await import('nodemailer');
183
+ }
184
+ catch {
185
+ nm = await import(new URL('../../node_modules/nodemailer/lib/nodemailer.js', import.meta.url).href);
186
+ }
187
+ const nodemailer = nm.default || nm;
188
+ if (!_cachedTransporter) {
189
+ _cachedTransporter = nodemailer.createTransport({
190
+ host: cfg.host,
191
+ port: cfg.port,
192
+ secure: cfg.port === 465,
193
+ auth: {
194
+ user: cfg.user,
195
+ pass: cfg.pass,
196
+ },
197
+ });
198
+ }
199
+ const info = await _cachedTransporter.sendMail({
200
+ from: `"ODANT-WORK" <${cfg.user}>`,
201
+ to,
202
+ subject,
203
+ text: textBody,
204
+ html: htmlBody,
205
+ });
206
+ console.log('[work.odant.org] SMTP delivered to', to, 'messageId:', info?.messageId, 'response:', info?.response);
207
+ return { ok: true, messageId: info?.messageId };
208
+ }
209
+ catch (nmErr) {
210
+ if (nmErr?.code !== 'ERR_MODULE_NOT_FOUND') {
211
+ console.error('[work.odant.org] SMTP error via nodemailer:', nmErr?.message || nmErr);
212
+ throw nmErr;
213
+ }
214
+ }
215
+
216
+ // Резервный нативный SMTPS-клиент с полными RFC 5322 заголовками (Date + Message-ID + multipart/alternative text+html)
217
+ const domain = (cfg.user.split('@')[1] || 'yandex.ru').trim();
218
+ const msgId = `<${crypto.randomUUID()}@${domain}>`;
219
+ const dateHdr = new Date().toUTCString();
220
+ const boundary = `----=_Part_${crypto.randomBytes(8).toString('hex')}`;
221
+ const subjectB64 = Buffer.from(subject, 'utf8').toString('base64');
222
+ const b64Text = (Buffer.from(textBody, 'utf8').toString('base64').match(/.{1,76}/g) || []).join('\r\n');
223
+ const b64Html = (Buffer.from(htmlBody, 'utf8').toString('base64').match(/.{1,76}/g) || []).join('\r\n');
173
224
  const mimeMsg = [
225
+ `Date: ${dateHdr}`,
226
+ `Message-ID: ${msgId}`,
174
227
  `From: "ODANT-WORK" <${cfg.user}>`,
175
228
  `To: <${to}>`,
176
229
  `Subject: =?UTF-8?B?${subjectB64}?=`,
177
230
  'MIME-Version: 1.0',
231
+ `Content-Type: multipart/alternative; boundary="${boundary}"`,
232
+ '',
233
+ `--${boundary}`,
234
+ 'Content-Type: text/plain; charset=utf-8',
235
+ 'Content-Transfer-Encoding: base64',
236
+ '',
237
+ b64Text,
238
+ `--${boundary}`,
178
239
  'Content-Type: text/html; charset=utf-8',
179
240
  'Content-Transfer-Encoding: base64',
180
241
  '',
181
- Buffer.from(htmlBody, 'utf8').toString('base64'),
242
+ b64Html,
243
+ `--${boundary}--`,
182
244
  '.',
183
245
  '',
184
246
  ].join('\r\n');
@@ -202,7 +264,7 @@ async function sendOtpEmail({ to, code }) {
202
264
  reject(new Error('Ошибка SMTP: ' + line));
203
265
  return;
204
266
  }
205
- if (step === 0) { step = 1; sendCmd('EHLO work.odant.org'); }
267
+ if (step === 0) { step = 1; sendCmd(`EHLO ${domain}`); }
206
268
  else if (step === 1) { step = 2; sendCmd('AUTH LOGIN'); }
207
269
  else if (step === 2) { step = 3; sendCmd(Buffer.from(cfg.user, 'utf8').toString('base64')); }
208
270
  else if (step === 3) { step = 4; sendCmd(Buffer.from(cfg.pass, 'utf8').toString('base64')); }
@@ -210,7 +272,7 @@ async function sendOtpEmail({ to, code }) {
210
272
  else if (step === 5) { step = 6; sendCmd(`RCPT TO:<${to}>`); }
211
273
  else if (step === 6) { step = 7; sendCmd('DATA'); }
212
274
  else if (step === 7) { step = 8; sock.write(mimeMsg); }
213
- else if (step === 8) { step = 9; sendCmd('QUIT'); sock.end(); resolve({ ok: true }); }
275
+ else if (step === 8) { step = 9; sendCmd('QUIT'); sock.end(); resolve({ ok: true, messageId: msgId }); }
214
276
  }
215
277
  });
216
278
  });
@@ -360,6 +422,7 @@ export function createLicenseServerApp(options = {}) {
360
422
  expiresAt: Date.now() + 10 * 60_000,
361
423
  attempts: 0,
362
424
  });
425
+ console.log('[work.odant.org] Registration/Login code for', cleanEmail, ':', code);
363
426
  if (!options.skipEmailSend) {
364
427
  await sendOtpEmail({ to: cleanEmail, code });
365
428
  }
@@ -753,9 +816,19 @@ export function createLicenseServerApp(options = {}) {
753
816
 
754
817
  if (req.method === 'GET' && pathname === '/api/admin/me') {
755
818
  const tok = extractBearerToken(req, null, url);
756
- if (!store.verifyAdminToken(tok))
819
+ const identity = store.getAdminIdentity(tok);
820
+ if (!identity?.authenticated)
757
821
  return sendJson(res, 401, { authenticated: false });
758
- return sendJson(res, 200, { authenticated: true, login: store.adminLogin });
822
+ return sendJson(res, 200, identity);
823
+ }
824
+
825
+ if (req.method === 'POST' && pathname === '/api/admin/users/role') {
826
+ const body = await readJsonBody(req);
827
+ const tok = extractBearerToken(req, body, url);
828
+ if (!store.verifyAdminToken(tok))
829
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора (BOSS)' });
830
+ const updated = store.setUserAdminRole(body.userId || body.email, body.isAdmin !== false);
831
+ return sendJson(res, 200, { ok: true, user: updated });
759
832
  }
760
833
 
761
834
  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 false;
96
- if (clean === this.adminPassword && this.adminPassword.length >= 6)
97
- return true;
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 false;
119
+ return null;
101
120
  if (sess.expiresAt < Date.now()) {
102
121
  this.adminSessions.delete(clean);
103
- return false;
122
+ return null;
104
123
  }
105
- return true;
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;