work-license-server 1.0.16 → 1.0.18

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
@@ -16,28 +16,48 @@
16
16
  let activeTab = 'servers';
17
17
  let admCountdownTimer = null;
18
18
 
19
+ function readCookieSession() {
20
+ try {
21
+ const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
22
+ return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
23
+ }
24
+ catch { return ''; }
25
+ }
26
+
19
27
  function getStoredToken() {
20
28
  try {
21
- return sessionStorage.getItem(TOKEN_KEY)
29
+ return localStorage.getItem('odant_work_session')
22
30
  || localStorage.getItem(TOKEN_KEY)
23
- || localStorage.getItem('odant_work_session')
31
+ || sessionStorage.getItem(TOKEN_KEY)
32
+ || sessionStorage.getItem('odant_work_session')
33
+ || readCookieSession()
24
34
  || '';
25
35
  }
26
- catch { return ''; }
36
+ catch { return readCookieSession(); }
27
37
  }
28
- function setStoredToken(tok) {
38
+ function setStoredToken(tok, email = '', userObj = null) {
29
39
  adminToken = tok || '';
30
40
  try {
31
41
  if (tok) {
32
42
  sessionStorage.setItem(TOKEN_KEY, tok);
33
43
  localStorage.setItem(TOKEN_KEY, tok);
34
- if (tok.startsWith('wks_')) {
35
- localStorage.setItem('odant_work_session', tok);
36
- }
44
+ localStorage.setItem('odant_work_session', tok);
45
+ sessionStorage.setItem('odant_work_session', tok);
46
+ document.cookie = `odant_work_session=${encodeURIComponent(tok)}; path=/; max-age=31536000; SameSite=Lax`;
37
47
  }
38
48
  else {
39
49
  sessionStorage.removeItem(TOKEN_KEY);
40
50
  localStorage.removeItem(TOKEN_KEY);
51
+ localStorage.removeItem('odant_work_session');
52
+ localStorage.removeItem('odant_work_user_cache');
53
+ sessionStorage.removeItem('odant_work_session');
54
+ document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
55
+ }
56
+ if (email) {
57
+ localStorage.setItem('odant_work_email', email);
58
+ }
59
+ if (userObj && typeof userObj === 'object') {
60
+ localStorage.setItem('odant_work_user_cache', JSON.stringify(userObj));
41
61
  }
42
62
  }
43
63
  catch { /* приватный режим */ }
@@ -78,6 +98,13 @@
78
98
  $('#adminLoginScreen').style.display = '';
79
99
  $('#adminAppShell').style.display = 'none';
80
100
  if ($('#admLoginError')) $('#admLoginError').textContent = errMsg;
101
+ try {
102
+ const savedEmail = localStorage.getItem('odant_work_email') || '';
103
+ if (savedEmail && $('#admLoginEmail') && !$('#admLoginEmail').value) {
104
+ $('#admLoginEmail').value = savedEmail;
105
+ }
106
+ }
107
+ catch { /* ignore */ }
81
108
  setTimeout(() => $('#admLoginEmail')?.focus(), 50);
82
109
  }
83
110
 
@@ -102,6 +129,7 @@
102
129
  $('#adminLoginScreen').style.display = 'none';
103
130
  $('#adminAppShell').style.display = '';
104
131
  if ($('#admUserLabel')) $('#admUserLabel').textContent = login;
132
+ if ($('#admProfileEmail')) $('#admProfileEmail').textContent = login;
105
133
  requestAnimationFrame(moveIndicator);
106
134
  }
107
135
 
@@ -165,7 +193,7 @@
165
193
  if (!user.isAdmin) {
166
194
  throw new Error(`Аккаунт ${user.email} не обладает правами администратора (BOSS) сервера лицензирования.`);
167
195
  }
168
- setStoredToken(user.sessionKey);
196
+ setStoredToken(user.sessionKey, user.email, user);
169
197
  resetAdmStep();
170
198
  showAdminApp(user.email);
171
199
  await refreshRegistry();
@@ -179,9 +207,9 @@
179
207
  }
180
208
 
181
209
  async function logoutAdmin() {
210
+ $('#admProfileModal')?.classList.remove('open');
182
211
  try { await api('/api/admin/logout', {}); } catch { /* ignore */ }
183
212
  setStoredToken('');
184
- try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
185
213
  resetAdmStep();
186
214
  showLoginGate('');
187
215
  }
@@ -196,6 +224,7 @@
196
224
  try {
197
225
  const me = await api('/api/admin/me');
198
226
  if (me.authenticated) {
227
+ setStoredToken(saved, me.email || me.login || '');
199
228
  showAdminApp(me.login || 'admin');
200
229
  await refreshRegistry();
201
230
  }
@@ -900,6 +929,8 @@
900
929
  case 'adm-send-code': sendAdminOtpCode(); break;
901
930
  case 'adm-verify-code': verifyAdminOtpCode(); break;
902
931
  case 'adm-reset-step': resetAdmStep(); break;
932
+ case 'open-adm-profile': $('#admProfileModal')?.classList.add('open'); break;
933
+ case 'close-adm-profile': $('#admProfileModal')?.classList.remove('open'); break;
903
934
  case 'admin-logout': logoutAdmin(); break;
904
935
  case 'registry': refreshRegistry(); break;
905
936
  case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
package/assets/landing.js CHANGED
@@ -14,6 +14,7 @@
14
14
  let REGIONS = (D.regions || []).slice();
15
15
  let currentUser = null;
16
16
  let currentStage = 'product';
17
+ let requestedInitialStage = '';
17
18
  let selectedCode = null;
18
19
  let pendingAuthAction = null;
19
20
 
@@ -129,12 +130,95 @@
129
130
  card.style.setProperty('--my', (e.clientY - r.top) + 'px');
130
131
  }, { passive: true });
131
132
 
132
- /* ================= Вход / Регистрация (Email, Мессенджеры, OAuth) ================= */
133
+ /* ================= Вход / Регистрация и постоянная сессия в браузере ================= */
134
+ function readCookieSession() {
135
+ try {
136
+ const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
137
+ return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
138
+ }
139
+ catch { return ''; }
140
+ }
141
+ function persistSessionInBrowser(sessionKey = '', email = '', isAdmin = false, userObj = null) {
142
+ try {
143
+ if (sessionKey) {
144
+ localStorage.setItem('odant_work_session', sessionKey);
145
+ sessionStorage.setItem('odant_work_session', sessionKey);
146
+ if (isAdmin) {
147
+ localStorage.setItem('odant_work_admin_token', sessionKey);
148
+ sessionStorage.setItem('odant_work_admin_token', sessionKey);
149
+ }
150
+ document.cookie = `odant_work_session=${encodeURIComponent(sessionKey)}; path=/; max-age=31536000; SameSite=Lax`;
151
+ }
152
+ if (email) {
153
+ localStorage.setItem('odant_work_email', email);
154
+ }
155
+ if (userObj && typeof userObj === 'object') {
156
+ localStorage.setItem('odant_work_user_cache', JSON.stringify(userObj));
157
+ }
158
+ }
159
+ catch { /* приватный режим */ }
160
+ }
161
+ function clearSessionFromBrowser() {
162
+ try {
163
+ localStorage.removeItem('odant_work_session');
164
+ localStorage.removeItem('odant_work_admin_token');
165
+ localStorage.removeItem('odant_work_user_cache');
166
+ sessionStorage.removeItem('odant_work_session');
167
+ sessionStorage.removeItem('odant_work_admin_token');
168
+ document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
169
+ }
170
+ catch { /* ignore */ }
171
+ }
172
+ function hydrateCachedUser() {
173
+ try {
174
+ const key = localStorage.getItem('odant_work_session')
175
+ || sessionStorage.getItem('odant_work_session')
176
+ || localStorage.getItem('odant_work_admin_token')
177
+ || readCookieSession()
178
+ || '';
179
+ if (!key) return;
180
+ const raw = localStorage.getItem('odant_work_user_cache');
181
+ if (raw) {
182
+ const cached = JSON.parse(raw);
183
+ if (cached?.email) {
184
+ currentUser = { ...cached, sessionKey: key, ofertaAccepted: cached.ofertaAccepted !== false };
185
+ onAuthenticated();
186
+ return;
187
+ }
188
+ }
189
+ // Если есть подписанный токен wks_1.<payload>.<sig>, декодируем payload синхронно до ответа /api/auth/me
190
+ if (key.startsWith('wks_1.')) {
191
+ const parts = key.split('.');
192
+ if (parts[1]) {
193
+ const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
194
+ const json = decodeURIComponent(escape(atob(b64)));
195
+ const payload = JSON.parse(json);
196
+ if (payload?.email) {
197
+ currentUser = {
198
+ userId: payload.userId || '',
199
+ email: payload.email,
200
+ name: payload.name || payload.email,
201
+ companyName: payload.companyName || '',
202
+ inn: payload.inn || '',
203
+ isAdmin: Boolean(payload.isAdmin),
204
+ ofertaAccepted: true,
205
+ sessionKey: key,
206
+ servers: [],
207
+ franchises: [],
208
+ };
209
+ onAuthenticated();
210
+ }
211
+ }
212
+ }
213
+ }
214
+ catch { /* ignore */ }
215
+ }
216
+
133
217
  function openAuth(hintMsg = '', intentTitle = '') {
134
218
  if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
135
219
  const intentBox = $('#authIntentNotice');
136
220
  if (intentBox) {
137
- if (intentTitle) {
221
+ if (intentTitle && !currentUser) {
138
222
  intentBox.style.display = '';
139
223
  intentBox.innerHTML = `Выбранная позиция: <b>${esc(intentTitle)}</b>.<br>После входа она автоматически появится как заказ в вашем Личном кабинете.`;
140
224
  }
@@ -143,9 +227,36 @@
143
227
  intentBox.textContent = '';
144
228
  }
145
229
  }
146
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = currentUser ? '' : 'none';
230
+ if (currentUser) {
231
+ if ($('#authTitle')) $('#authTitle').textContent = 'Ваш аккаунт';
232
+ if ($('#authSub')) $('#authSub').textContent = 'Сессия сохранена в браузере';
233
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = '';
234
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = 'none';
235
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
236
+ if ($('#authLoggedEmail')) $('#authLoggedEmail').textContent = currentUser.email || '';
237
+ if ($('#authLoggedRoleBadge')) {
238
+ $('#authLoggedRoleBadge').innerHTML = currentUser.isAdmin
239
+ ? '<span class="tag ok">BOSS · Главный администратор</span>'
240
+ : '<span class="tag">Клиент ODANT-WORK</span>';
241
+ }
242
+ if ($('#btnGoAdminModal')) $('#btnGoAdminModal').style.display = currentUser.isAdmin ? '' : 'none';
243
+ }
244
+ else {
245
+ if ($('#authTitle')) $('#authTitle').textContent = 'Вход / Регистрация';
246
+ if ($('#authSub')) $('#authSub').textContent = 'На ваш Email придёт одноразовый 6-значный код подтверждения';
247
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = 'none';
248
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = '';
249
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
250
+ try {
251
+ const savedEmail = localStorage.getItem('odant_work_email') || '';
252
+ if (savedEmail && $('#authEmail') && !$('#authEmail').value) {
253
+ $('#authEmail').value = savedEmail;
254
+ }
255
+ }
256
+ catch { /* ignore */ }
257
+ setTimeout(() => $('#authEmail')?.focus(), 50);
258
+ }
147
259
  $('#authModal')?.classList.add('open');
148
- setTimeout(() => $('#authEmail')?.focus(), 50);
149
260
  }
150
261
  function closeAuth() {
151
262
  $('#authModal')?.classList.remove('open');
@@ -224,7 +335,7 @@
224
335
  try {
225
336
  const data = await api('/api/auth/verify-code', { email, code });
226
337
  currentUser = data;
227
- try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
338
+ persistSessionInBrowser(data.sessionKey, data.email, data.isAdmin, data);
228
339
  resetAuthStep();
229
340
  onAuthenticated();
230
341
  closeAuth();
@@ -245,18 +356,14 @@
245
356
  }
246
357
  }
247
358
 
248
- function performLogout() {
359
+ async function performLogout() {
360
+ try { await api('/api/auth/logout', {}); } catch { /* ignore */ }
249
361
  currentUser = null;
250
362
  pendingAuthAction = null;
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 */ }
363
+ clearSessionFromBrowser();
256
364
  if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
257
365
  if ($('#tileAdmin')) $('#tileAdmin').style.display = 'none';
258
366
  if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
259
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
260
367
  closeAuth();
261
368
  if (currentStage === 'addons') switchStage('product');
262
369
  moveIndicator();
@@ -264,17 +371,28 @@
264
371
 
265
372
  async function restoreSession() {
266
373
  let key = '';
267
- try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
374
+ try {
375
+ key = localStorage.getItem('odant_work_session')
376
+ || sessionStorage.getItem('odant_work_session')
377
+ || localStorage.getItem('odant_work_admin_token')
378
+ || readCookieSession()
379
+ || '';
380
+ }
381
+ catch { key = readCookieSession(); }
268
382
  if (!key) return;
269
383
  try {
270
384
  const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
271
385
  if (data.authenticated && data.user) {
272
- currentUser = { ...data.user, sessionKey: key };
386
+ currentUser = { ...data.user, sessionKey: data.user.sessionKey || key };
387
+ persistSessionInBrowser(currentUser.sessionKey, currentUser.email, currentUser.isAdmin, currentUser);
273
388
  onAuthenticated();
274
- if (location.hash === '#addons' && isUserReadyForOrders()) switchStage('addons', { scroll: false });
389
+ if ((requestedInitialStage === 'addons' || location.hash === '#addons') && isUserReadyForOrders()) {
390
+ requestedInitialStage = '';
391
+ switchStage('addons', { scroll: false });
392
+ }
275
393
  }
276
394
  }
277
- catch { /* сессия устарела */ }
395
+ catch { /* сессия отозвана */ }
278
396
  }
279
397
 
280
398
  let franchiseCountdownTimer = null;
@@ -1688,14 +1806,12 @@
1688
1806
  if (el.dataset.stage && el !== document.body) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1689
1807
  switch (el.dataset.action) {
1690
1808
  case 'auth':
1691
- if (isUserReadyForOrders() && currentStage !== 'addons') {
1692
- switchStage('addons');
1693
- }
1694
- else {
1695
- pendingAuthAction = null;
1696
- resetAuthStep();
1697
- openAuth();
1698
- }
1809
+ pendingAuthAction = null;
1810
+ openAuth();
1811
+ break;
1812
+ case 'go-cabinet':
1813
+ closeAuth();
1814
+ switchStage('addons');
1699
1815
  break;
1700
1816
  case 'auth-close': closeAuth(); break;
1701
1817
  case 'send-code': sendAuthCode(); break;
@@ -1770,14 +1886,18 @@
1770
1886
 
1771
1887
  function start() {
1772
1888
  applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
1889
+ requestedInitialStage = D.initialStage || location.hash.slice(1);
1890
+ hydrateCachedUser();
1773
1891
  bind();
1774
1892
  renderOrbit();
1775
1893
  recalcBaseOrder();
1776
1894
  recalcPartner();
1777
1895
  renderRegionsTable();
1778
1896
  initBackdrop();
1779
- const initial = D.initialStage || location.hash.slice(1);
1780
- switchStage(initial && initial !== 'addons' && initial !== 'admin' ? initial : 'product', { scroll: false });
1897
+ const targetStage = (requestedInitialStage === 'addons' && isUserReadyForOrders())
1898
+ ? 'addons'
1899
+ : (requestedInitialStage && requestedInitialStage !== 'addons' && requestedInitialStage !== 'admin' ? requestedInitialStage : 'product');
1900
+ switchStage(targetStage, { scroll: false });
1781
1901
  if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
1782
1902
  initMap().then(() => selectRegion(selectedCode || 'RU-SVE'));
1783
1903
  restoreSession();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.16",
3
+ "version": "1.0.18",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/admin.js CHANGED
@@ -115,7 +115,7 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
115
115
 
116
116
  <div class="top-actions">
117
117
  <button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
118
- <button class="btn btn-primary btn-sm" data-action="admin-logout" title="Нажмите, чтобы выйти из сессии">${icon('user')}<span id="admUserLabel">BOSS</span></button>
118
+ <button class="btn btn-primary btn-sm" data-action="open-adm-profile" title="Аккаунт администратора">${icon('user')}<span id="admUserLabel">BOSS</span></button>
119
119
  </div>
120
120
  </header>
121
121
 
@@ -402,6 +402,30 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
402
402
  </div>
403
403
  </div>
404
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
+
405
429
  <script>window.__ODANT_ADMIN__ = ${jsonForScript(adminData)};</script>
406
430
  <script src="/assets/admin.js${v}" defer></script>
407
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
+ }
package/src/landing.js CHANGED
@@ -498,6 +498,20 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
498
498
 
499
499
  <div id="authIntentNotice" class="small" style="display:none;padding:10px 12px;border-radius:10px;background:rgba(0,163,224,.12);border:1px solid rgba(0,163,224,.35);margin-bottom:4px"></div>
500
500
 
501
+ <!-- Карточка активной сессии (показывается, когда пользователь уже вошёл) -->
502
+ <div id="authStepLoggedIn" style="display:none">
503
+ <div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
504
+ <div class="small muted">Активная сессия в браузере</div>
505
+ <div style="font-size:16px;font-weight:700;margin-top:2px" id="authLoggedEmail">—</div>
506
+ <div style="margin-top:6px" id="authLoggedRoleBadge"></div>
507
+ </div>
508
+ <div style="display:flex;flex-direction:column;gap:8px">
509
+ <button type="button" class="btn btn-primary" style="width:100%" data-action="go-cabinet">Перейти в Личный кабинет</button>
510
+ <a class="btn btn-ghost" id="btnGoAdminModal" href="/admin" style="width:100%;display:none;text-decoration:none">Открыть Админ-панель</a>
511
+ <button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="logout">Выйти из аккаунта</button>
512
+ </div>
513
+ </div>
514
+
501
515
  <div id="authStepEmail">
502
516
  <div class="field">
503
517
  <label for="authEmail">Ваш Email *</label>
@@ -531,7 +545,6 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
531
545
 
532
546
  <div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:6px"></div>
533
547
  <div style="display:flex;gap:10px;margin-top:6px;justify-content:flex-end;flex-wrap:wrap">
534
- <button class="btn btn-ghost btn-sm" id="btnLogoutModal" data-action="logout" style="display:none">Выйти из аккаунта</button>
535
548
  <button class="btn btn-ghost btn-sm" data-action="auth-close">Закрыть</button>
536
549
  </div>
537
550
  </div>
package/src/server.js CHANGED
@@ -316,7 +316,15 @@ export function createLicenseServerApp(options = {}) {
316
316
  function extractBearerToken(req, body = null, url = null) {
317
317
  const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
318
318
  const xAdmin = String(req.headers['x-admin-token'] || req.headers['x-admin-key'] || '').trim();
319
- return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || '';
319
+ let cookieTok = '';
320
+ const cookieHdr = String(req.headers.cookie || '');
321
+ if (cookieHdr) {
322
+ const m = cookieHdr.match(/(?:^|;\s*)odant_work_session=([^;]+)/);
323
+ if (m?.[1]) {
324
+ try { cookieTok = decodeURIComponent(m[1].trim()); } catch { cookieTok = m[1].trim(); }
325
+ }
326
+ }
327
+ return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || cookieTok || '';
320
328
  }
321
329
 
322
330
  function ensureCustomerOferta(req, body = {}) {
@@ -466,6 +474,7 @@ export function createLicenseServerApp(options = {}) {
466
474
  ofertaAccepted: true,
467
475
  hubUrl: body.hubUrl || reqOrigin,
468
476
  });
477
+ res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
469
478
  return sendJson(res, 200, user);
470
479
  }
471
480
 
@@ -476,9 +485,18 @@ export function createLicenseServerApp(options = {}) {
476
485
  ...body,
477
486
  hubUrl: body.hubUrl || reqOrigin,
478
487
  });
488
+ res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
479
489
  return sendJson(res, 200, user);
480
490
  }
481
491
 
492
+ if (req.method === 'POST' && pathname === '/api/auth/logout') {
493
+ const body = await readJsonBody(req);
494
+ const tok = extractBearerToken(req, body, url);
495
+ store.logoutAdmin(tok);
496
+ res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
497
+ return sendJson(res, 200, { ok: true });
498
+ }
499
+
482
500
  // 2.0.1 Генерация или перевыпуск токена владельца/сервера (с зашитым URL сервера поддержки и licenseId) в личном кабинете
483
501
  if (req.method === 'POST' && pathname === '/api/cabinet/token') {
484
502
  const body = await readJsonBody(req);
@@ -906,6 +924,7 @@ export function createLicenseServerApp(options = {}) {
906
924
  if (req.method === 'POST' && pathname === '/api/admin/logout') {
907
925
  const body = await readJsonBody(req);
908
926
  const tok = extractBearerToken(req, body, url);
927
+ res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
909
928
  return sendJson(res, 200, store.logoutAdmin(tok));
910
929
  }
911
930
 
package/src/store.js CHANGED
@@ -29,6 +29,8 @@ import {
29
29
  createOwnerAccessToken,
30
30
  parseOwnerAccessToken,
31
31
  verifyOwnerAccessToken,
32
+ createSessionToken,
33
+ verifySessionToken,
32
34
  } from './crypto.js';
33
35
 
34
36
  const ONE_YEAR_MS = 365 * 24 * 3600_000;
@@ -82,6 +84,7 @@ export class LicenseStore {
82
84
  this.adminLogin = String(adminLogin || process.env.WORK_ADMIN_LOGIN || 'admin').trim();
83
85
  this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
84
86
  this.adminSessions = new Map();
87
+ this.revokedSessions = new Set();
85
88
 
86
89
  this.state = {
87
90
  licenses: {},
@@ -124,7 +127,10 @@ export class LicenseStore {
124
127
 
125
128
  logoutAdmin(token = '') {
126
129
  const clean = String(token || '').trim();
127
- if (clean) this.adminSessions.delete(clean);
130
+ if (clean) {
131
+ this.adminSessions.delete(clean);
132
+ this.revokedSessions.add(clean);
133
+ }
128
134
  return { ok: true };
129
135
  }
130
136
 
@@ -134,15 +140,15 @@ export class LicenseStore {
134
140
 
135
141
  getAdminIdentity(token = '') {
136
142
  const clean = String(token || '').trim();
137
- if (!clean)
143
+ if (!clean || this.revokedSessions.has(clean))
138
144
  return null;
139
- const adminUser = Object.values(this.state.users || {}).find(u => u.sessionKey === clean && u.isAdmin);
140
- if (adminUser) {
145
+ const userFromTok = this._findUser(clean);
146
+ if (userFromTok && userFromTok.isAdmin) {
141
147
  return {
142
148
  authenticated: true,
143
- login: adminUser.email,
144
- email: adminUser.email,
145
- userId: adminUser.userId,
149
+ login: userFromTok.email,
150
+ email: userFromTok.email,
151
+ userId: userFromTok.userId,
146
152
  role: 'BOSS',
147
153
  };
148
154
  }
@@ -1197,11 +1203,40 @@ export class LicenseStore {
1197
1203
 
1198
1204
  _findUser(sessionKeyOrEmail = '') {
1199
1205
  const key = String(sessionKeyOrEmail || '').trim();
1200
- if (!key)
1206
+ if (!key || this.revokedSessions.has(key))
1201
1207
  return null;
1202
- return Object.values(this.state.users || {}).find(
1208
+ let found = Object.values(this.state.users || {}).find(
1203
1209
  u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
1204
1210
  ) || null;
1211
+ if (found)
1212
+ return found;
1213
+
1214
+ // Если контейнер перезапускался, проверяем криптографическую подпись бессрочного токена сессии wks_1...
1215
+ const verifiedPayload = verifySessionToken(key, this.authorityKeys.publicKey);
1216
+ if (verifiedPayload?.email) {
1217
+ const cleanEmail = String(verifiedPayload.email).toLowerCase();
1218
+ let user = this.state.users[cleanEmail];
1219
+ if (!user) {
1220
+ this.registerOrLoginUser({
1221
+ email: cleanEmail,
1222
+ name: verifiedPayload.name || '',
1223
+ companyName: verifiedPayload.companyName || '',
1224
+ inn: verifiedPayload.inn || '',
1225
+ ofertaAccepted: true,
1226
+ });
1227
+ user = this.state.users[cleanEmail];
1228
+ }
1229
+ if (user) {
1230
+ if (verifiedPayload.isAdmin) {
1231
+ user.isAdmin = true;
1232
+ user.role = 'BOSS';
1233
+ }
1234
+ user.sessionKey = key;
1235
+ try { this._save(); } catch { /* ignore */ }
1236
+ return user;
1237
+ }
1238
+ }
1239
+ return null;
1205
1240
  }
1206
1241
 
1207
1242
  /**
@@ -1558,6 +1593,17 @@ export class LicenseStore {
1558
1593
  user.role = 'BOSS';
1559
1594
  }
1560
1595
 
1596
+ // Выпускаем криптографически подписанный бессрочный токен сессии (wks_1...), устойчивый к перезапускам сервера
1597
+ user.sessionKey = createSessionToken({
1598
+ userId: user.userId,
1599
+ email: user.email,
1600
+ name: user.name,
1601
+ companyName: user.companyName,
1602
+ inn: user.inn,
1603
+ isAdmin: Boolean(user.isAdmin),
1604
+ issuedAt: now,
1605
+ }, this.authorityKeys);
1606
+
1561
1607
  if (ofertaAccepted !== false && !user.ofertaAccepted) {
1562
1608
  user.ofertaAccepted = true;
1563
1609
  user.ofertaAcceptedAt = now;
@@ -1784,7 +1830,7 @@ export class LicenseStore {
1784
1830
  const key = String(sessionKey || '').trim();
1785
1831
  if (!key)
1786
1832
  return null;
1787
- const user = Object.values(this.state.users).find(u => u.sessionKey === key);
1833
+ const user = this._findUser(key);
1788
1834
  if (!user)
1789
1835
  return null;
1790
1836
  return this._formatUserResponse(user);