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/landing.js CHANGED
@@ -337,50 +337,38 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
337
337
  <div class="tiers">${renderTiers()}</div>
338
338
  </section>
339
339
 
340
- <!-- ============ ЛИЧНЫЙ КАБИНЕТ: 2 ОСНОВНЫХ РЕЕСТРА (СЕРВЕРЫ И ФРАНШИЗЫ) ============ -->
340
+ <!-- ============ ЛИЧНЫЙ КАБИНЕТ: ЛЕВОЕ МЕНЮ РАЗДЕЛОВ + ЧИСТЫЕ РЕЕСТРЫ ============ -->
341
341
  <section class="stage" id="stage-addons">
342
- <div class="sec-head">
343
- <div>
344
- <span class="kicker">Личный кабинет владельца</span>
345
- <h2 class="h2">Реестр серверов (устройств) и реестр франшиз</h2>
346
- </div>
347
- <div style="display:flex;flex-direction:column;align-items:flex-end;gap:8px">
348
- <p id="userBadgeBox" style="margin:0"></p>
349
- <div style="display:flex;gap:8px;flex-wrap:wrap">
350
- <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить данные</button>
351
- </div>
352
- </div>
353
- </div>
342
+ <div class="sec-layout">
343
+ <aside class="sec-nav" aria-label="Разделы Личного кабинета">
344
+ <button type="button" class="sec-nav-item active" data-cab-tab="servers" id="navCabServers">Серверы и устройства (0)</button>
345
+ <button type="button" class="sec-nav-item" data-cab-tab="franchises" id="navCabFranchises">Региональные франшизы (0)</button>
346
+ </aside>
354
347
 
355
- <!-- 1.1 РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) WORK -->
356
- <div class="card" style="margin-bottom:24px">
357
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
358
- <div>
359
- <span class="kicker">1. Реестр серверов (устройств) WORK</span>
360
- <h3 class="h3" style="margin:4px 0 4px">Ваши лицензируемые серверы и устройства <span class="tag" id="badgeServersCount" style="margin-left:6px">0</span></h3>
361
- <p class="small muted" style="margin:0">Вы можете добавить любое количество серверов WORK. Для каждого сервера выпускается свой собственный токен привязки (<code>owk1...</code>) и свой набор лицензий (базовая вечная на 10 мест + дополнительные годовые пакеты).</p>
362
- </div>
363
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
364
- <input type="text" id="inpNewServerName" placeholder="Название сервера (напр., Центральный офис)…" style="width:280px;padding:8px 12px;font-size:13.5px">
365
- <button class="btn btn-primary btn-sm" data-action="create-server">${icon('plus')} Добавить сервер (устройство)</button>
348
+ <div class="sec-main">
349
+ <!-- 1.1 РЕЕСТР СЕРВЕРОВ (УСТРОЙСТВ) WORK -->
350
+ <div id="cabSecServers">
351
+ <div class="sec-bar" style="margin-bottom:12px">
352
+ <h2 class="sec-title">Серверы и устройства WORK</h2>
353
+ <div class="sec-controls">
354
+ <input type="text" id="inpNewServerName" placeholder="Название нового сервера…">
355
+ <button type="button" class="btn btn-primary btn-sm" data-action="create-server">+ Добавить сервер</button>
356
+ </div>
357
+ </div>
358
+ <div class="sec-box" id="cabinetServersRegistry"></div>
366
359
  </div>
367
- </div>
368
- <div id="cabinetServersRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
369
- </div>
370
360
 
371
- <!-- 1.2 РЕЕСТР ФРАНШИЗ -->
372
- <div class="card">
373
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:16px;flex-wrap:wrap;padding-bottom:14px;border-bottom:1px solid var(--border)">
374
- <div>
375
- <span class="kicker">2. Реестр региональных франшиз</span>
376
- <h3 class="h3" style="margin:4px 0 4px">Зарезервированные и действующие франшизы <span class="tag" id="badgeFranchisesCount" style="margin-left:6px">0</span></h3>
377
- <p class="small muted" style="margin:0">Можно резервировать франшизы в нескольких разных субъектах РФ (не более 1 франшизы в одном регионе). После резервирования необходимо оплатить начальный взнос <b>${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</b> через ЮKassa в течение <b>15 минут</b>, иначе резерв автоматически отменяется. Остаток паушального взноса погашается по графику равными платежами раз в месяц в течение года.</p>
378
- </div>
379
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
380
- <button class="btn btn-primary btn-sm" data-stage="franchise">${icon('map')} Зарезервировать франшизу на карте РФ</button>
361
+ <!-- 1.2 РЕЕСТР РЕГИОНАЛЬНЫХ ФРАНШИЗ -->
362
+ <div id="cabSecFranchises" style="display:none">
363
+ <div class="sec-bar" style="margin-bottom:12px">
364
+ <h2 class="sec-title">Региональные франшизы</h2>
365
+ <div class="sec-controls">
366
+ <button type="button" class="btn btn-primary btn-sm" data-stage="franchise">+ Выбрать регион на карте РФ</button>
367
+ </div>
368
+ </div>
369
+ <div class="sec-box" id="cabinetFranchisesRegistry"></div>
381
370
  </div>
382
371
  </div>
383
- <div id="cabinetFranchisesRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
384
372
  </div>
385
373
  </section>
386
374
 
@@ -510,6 +498,20 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
510
498
 
511
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>
512
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
+
513
515
  <div id="authStepEmail">
514
516
  <div class="field">
515
517
  <label for="authEmail">Ваш Email *</label>
@@ -543,7 +545,6 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
543
545
 
544
546
  <div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:6px"></div>
545
547
  <div style="display:flex;gap:10px;margin-top:6px;justify-content:flex-end;flex-wrap:wrap">
546
- <button class="btn btn-ghost btn-sm" id="btnLogoutModal" data-action="logout" style="display:none">Выйти из аккаунта</button>
547
548
  <button class="btn btn-ghost btn-sm" data-action="auth-close">Закрыть</button>
548
549
  </div>
549
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);