work-license-server 1.0.5 → 1.0.7

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/landing.js CHANGED
@@ -320,12 +320,67 @@
320
320
  $('#botText').value = '/bind ' + currentUser.license.support.bindCode;
321
321
  }
322
322
  $('#userBadgeBox').innerHTML = `${esc(currentUser.companyName)} (ИНН ${esc(currentUser.inn || '—')}) · лицензия <b>${esc(currentUser.licenseId)}</b> · ПЭП <code>${esc(currentUser.ofertaSignatureId || '—')}</code>`;
323
+ if (currentUser.license?.baseActivated && $('#segCabinetBase')) {
324
+ $$('#segCabinetBase button').forEach(b => b.classList.toggle('on', b.dataset.base === 'none'));
325
+ if (Number($('#inpSeats')?.value) === 0 && $('#inpSeats')) {
326
+ $('#inpSeats').value = 5;
327
+ if ($('#rngAddonSeats')) $('#rngAddonSeats').value = 5;
328
+ }
329
+ }
330
+ renderCabinetLicenses();
323
331
  updateOfertaBanners();
324
332
  updateClientSupportCard();
325
333
  moveIndicator();
326
334
  recalcAddons();
327
335
  }
328
336
 
337
+ function renderCabinetLicenses() {
338
+ const box = $('#cabinetLicensesBox');
339
+ if (!box) return;
340
+ const lic = currentUser?.license;
341
+ const files = lic?.licenseFiles || [];
342
+ if (!files.length) {
343
+ box.innerHTML = `<div style="padding:12px 14px;border-radius:10px;border:1px dashed var(--border);color:var(--muted);">
344
+ У вас пока нет приобретённых лицензий. Оформите базовую постоянную лицензию (или комплект с машиной) в Шаге 3 ниже — после оплаты файлы лицензий сразу появятся здесь и будут доступны для автоматической загрузки по вашему токену.
345
+ </div>`;
346
+ return;
347
+ }
348
+ const rows = files.map(f => {
349
+ const lf = f.licenseFile || f;
350
+ const isBase = lf.kind === 'base' || lf.perpetual;
351
+ const expText = isBase ? 'Бессрочно (постоянная)' : (lf.expiresAt ? 'до ' + new Date(lf.expiresAt).toLocaleDateString('ru-RU') + ` (${lf.years || 1} г.)` : '—');
352
+ const title = isBase ? 'Первичная постоянная лицензия (10 вечных мест + ИИ-агенты)' : `Временная лицензия (+${lf.seats} конк. мест · ${String(lf.tier || '').toUpperCase()})`;
353
+ return `<div style="display:flex;justify-content:space-between;gap:12px;padding:8px 0;border-bottom:1px solid var(--border);align-items:center;">
354
+ <div><b>${esc(title)}</b><br><code style="font-size:11.5px;color:var(--muted)">${esc(f.fileName || lf.fileId)}</code></div>
355
+ <div style="text-align:right"><span class="pill" style="padding:3px 10px;font-size:11.5px">${esc(expText)}</span></div>
356
+ </div>`;
357
+ }).join('');
358
+ box.innerHTML = `<div>Всего доступно одновременных сессий: <b style="color:var(--text)">${lic.totalSeats}</b> (${lic.baseSeats} вечных + ${lic.addonSeats} временных)</div>` + rows;
359
+ }
360
+
361
+ async function refreshCabinetUser() {
362
+ if (!currentUser?.sessionKey) return;
363
+ try {
364
+ const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(currentUser.sessionKey));
365
+ if (data.authenticated && data.user) {
366
+ currentUser = { ...data.user, sessionKey: currentUser.sessionKey };
367
+ onAuthenticated();
368
+ }
369
+ }
370
+ catch { /* ignore */ }
371
+ }
372
+
373
+ function copyCabinetToken() {
374
+ const val = $('#inpCabinetHubToken')?.value || '';
375
+ if (!val) return;
376
+ navigator.clipboard?.writeText(val);
377
+ const st = $('#tokenCopyStatus');
378
+ if (st) {
379
+ st.textContent = 'Токен скопирован в буфер обмена! Вставьте его в локальном WORK → Лицензия.';
380
+ setTimeout(() => { if (st.textContent.includes('скопирован')) st.textContent = ''; }, 5000);
381
+ }
382
+ }
383
+
329
384
  /* ================= Калькуляторы ================= */
330
385
  function volumeDiscount(seats) {
331
386
  const s = Math.max(0, Math.floor(Number(seats) || 0));
@@ -346,28 +401,43 @@
346
401
  const lines = (rows) => rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join('');
347
402
 
348
403
  function recalcAddons() {
349
- const seats = Math.max(1, Number($('#inpSeats').value) || 1);
404
+ const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
405
+ const includeBasePak = baseMode === 'pak';
406
+ const includeBaseLicenseOnly = baseMode === 'license_only';
407
+ const basePrice = includeBasePak
408
+ ? (D.product?.basePriceRub || 5_000_000)
409
+ : (includeBaseLicenseOnly ? (D.product?.baseLicensePriceRub || 4_000_000) : 0);
410
+
411
+ const minSeats = (includeBasePak || includeBaseLicenseOnly) ? 0 : 1;
412
+ const seats = Math.max(minSeats, Number($('#inpSeats').value) || 0);
350
413
  const years = Math.min(5, Math.max(1, Number($('#inpYears').value) || 1));
351
414
  const tier = tierById(segValue('segTier'));
352
415
  const disc = volumeDiscount(seats);
353
416
  const gross = seats * tier.pricePerUserYearRub * years;
354
417
  const saved = Math.round(gross * disc.discountPercent / 100);
355
- $('#lblTotal').textContent = rub(gross - saved);
356
- $('#lblDiscountBadge').textContent = disc.discountPercent
418
+ const addonsNet = gross - saved;
419
+ const total = basePrice + addonsNet;
420
+
421
+ $('#lblTotal').textContent = rub(total);
422
+ $('#lblDiscountBadge').textContent = seats > 0 && disc.discountPercent
357
423
  ? `Скидка за количество −${disc.discountPercent}% · экономия ${rub(saved)}`
358
- : 'Скидка от количества начинается с 5 мест';
359
- $('#lblBreakdown').innerHTML = lines([
360
- ['Уровень поддержки', `${esc(tier.name)} · ${esc(tier.reactionLabel)}`],
361
- ['Цена места в год', rub(tier.pricePerUserYearRub)],
362
- ['Мест × лет', `${seats} × ${years}`],
363
- ['Без скидки', rub(gross)],
364
- ['Скидка', `−${disc.discountPercent}%`],
365
- ['Всего мест после докупки', `${(D.product?.baseSeats || 10) + seats}`],
366
- ]);
424
+ : (includeBasePak || includeBaseLicenseOnly ? 'Включает 10 вечных мест и активацию ИИ-агентов' : 'Скидка от количества начинается с 5 мест');
425
+
426
+ const breakdownRows = [];
427
+ if (includeBasePak) breakdownRows.push(['Базовый комплект (с машиной)', rub(basePrice)]);
428
+ if (includeBaseLicenseOnly) breakdownRows.push(['Базовая лицензия (без железа)', rub(basePrice)]);
429
+ if (seats > 0) {
430
+ breakdownRows.push(['Уровень поддержки доп. мест', `${esc(tier.name)} · ${esc(tier.reactionLabel)}`]);
431
+ breakdownRows.push(['Доп. мест × лет', `${seats} × ${years} (${rub(addonsNet)})`]);
432
+ }
433
+ breakdownRows.push(['Итого мест после покупки', `${(D.product?.baseSeats || 10) + (currentUser?.license?.addonSeats || 0) + seats}`]);
434
+ $('#lblBreakdown').innerHTML = lines(breakdownRows);
367
435
  markLadder('ladderAddons', seats);
368
436
  }
369
437
  function syncSeats(v) {
370
- const n = Math.max(1, Math.min(2000, Number(v) || 1));
438
+ const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
439
+ const minSeats = baseMode === 'none' ? 1 : 0;
440
+ const n = Math.max(minSeats, Math.min(2000, Number(v) || 0));
371
441
  $('#inpSeats').value = n;
372
442
  $('#rngAddonSeats').value = Math.min(150, n);
373
443
  recalcAddons();
@@ -430,15 +500,22 @@
430
500
  requireAuthAndOferta(() => submitAddonOrder(paymentMethod));
431
501
  return;
432
502
  }
503
+ const baseMode = $('#segCabinetBase button.on')?.dataset.base || 'license_only';
504
+ const includeBasePak = baseMode === 'pak';
505
+ const includeBaseLicenseOnly = baseMode === 'license_only';
506
+ const minSeats = (includeBasePak || includeBaseLicenseOnly) ? 0 : 1;
507
+ const addonSeats = Math.max(minSeats, Number($('#inpSeats').value) || 0);
508
+
433
509
  const st = $('#orderStatus');
434
510
  st.textContent = paymentMethod === 'invoice' ? 'Формируем счёт-договор для юр. лица…' : 'Формируем счёт ЮKassa…';
435
511
  try {
436
512
  const data = await api('/api/orders/checkout', {
437
513
  sessionKey: currentUser?.sessionKey || '',
438
- includeBasePak: false,
514
+ includeBasePak,
515
+ includeBaseLicenseOnly,
439
516
  paymentMethod,
440
517
  tier: segValue('segTier'),
441
- addonSeats: Number($('#inpSeats').value) || 1,
518
+ addonSeats,
442
519
  years: Number($('#inpYears').value) || 1,
443
520
  licenseId: $('#inpLicId').value.trim(),
444
521
  customer: { name: currentUser?.companyName || '', inn: currentUser?.inn || '', email: currentUser?.email || '' },
@@ -1165,6 +1242,8 @@
1165
1242
  case 'order-addons': submitAddonOrder('yookassa'); break;
1166
1243
  case 'order-addons-invoice': submitAddonOrder('invoice'); break;
1167
1244
  case 'gen-hub-token': generateCabinetToken(); break;
1245
+ case 'copy-hub-token': copyCabinetToken(); break;
1246
+ case 'refresh-cabinet': refreshCabinetUser(); break;
1168
1247
  case 'reserve-selected': reserveRegionFranchise(selectedCode); break;
1169
1248
  case 'bot-send': sendBot(); break;
1170
1249
  case 'cert-close': $('#certModal')?.classList.remove('open'); break;
@@ -1191,8 +1270,14 @@
1191
1270
  $('#rngPartnerSales')?.addEventListener('input', recalcPartner);
1192
1271
  $('#inpRegionFilter')?.addEventListener('input', renderRegionsTable);
1193
1272
  bindSeg('segTier', recalcAddons);
1273
+ bindSeg('segCabinetBase', () => { syncSeats($('#inpSeats')?.value || 0); });
1194
1274
  bindSeg('segPakTier', recalcBaseOrder);
1195
1275
  bindSeg('segPakVariant', recalcBaseOrder);
1276
+ window.addEventListener('message', (e) => {
1277
+ if (e.data?.type === 'odant:order-paid') {
1278
+ refreshCabinetUser();
1279
+ }
1280
+ });
1196
1281
  window.addEventListener('resize', moveIndicator);
1197
1282
  window.addEventListener('hashchange', () => { const id = location.hash.slice(1); if (id && id !== currentStage) switchStage(id, { scroll: false }); });
1198
1283
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/crypto.js CHANGED
@@ -15,8 +15,8 @@ export const LICENSE_PROTOCOL = 'WORK-LIC-v1';
15
15
  * Вшиты в клиентский код odant-work; закрытые ключи хранятся исключительно на боевом сервере work.odant.org
16
16
  * и холодном резервном носителе Правообладателя (никогда не публикуются в npm и репозиториях).
17
17
  */
18
- export const ODANT_AUTHORITY_PUBLIC_KEY = 'MCowBQYDK2VwAyEAdlQSj+pC3IOt60vo3lXkzpd0DFU4ZmADqEYQsW1+mRA=';
19
- export const ODANT_BACKUP_PUBLIC_KEY = 'MCowBQYDK2VwAyEA8xJ9r7V4kM2pN6qL1wZ5tB3cF0yH8gD4sE7uA9vK2mQ=';
18
+ export const ODANT_AUTHORITY_PUBLIC_KEY = 'MCowBQYDK2VwAyEApVwPnoJcmUKLq4CLh7KdVHg1o5hfPiM7Lj9Eyq/VRdU=';
19
+ export const ODANT_BACKUP_PUBLIC_KEY = 'MCowBQYDK2VwAyEAdlQSj+pC3IOt60vo3lXkzpd0DFU4ZmADqEYQsW1+mRA=';
20
20
  export const TRUSTED_AUTHORITY_PUBLIC_KEYS = Object.freeze([
21
21
  ODANT_AUTHORITY_PUBLIC_KEY,
22
22
  ODANT_BACKUP_PUBLIC_KEY,
package/src/landing.js CHANGED
@@ -184,7 +184,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
184
184
  <button class="tab" data-stage="agents">ИИ-агенты</button>
185
185
  <button class="tab" data-stage="pricing">Тарифы</button>
186
186
  <button class="tab" data-stage="order">Комплекс</button>
187
- <button class="tab" id="tileAddons" data-stage="addons" style="display:none;">Докупка лицензий</button>
187
+ <button class="tab" id="tileAddons" data-stage="addons" style="display:none;">Личный кабинет</button>
188
188
  <button class="tab" data-stage="franchise">Франшиза</button>
189
189
  <button class="tab" data-stage="support">Поддержка</button>
190
190
  </nav>
@@ -403,37 +403,63 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
403
403
  <!-- ============ ДОКУПКА ЛИЦЕНЗИЙ И ЛИЧНЫЙ КАБИНЕТ (только после входа и подписания оферты) ============ -->
404
404
  <section class="stage" id="stage-addons">
405
405
  <div class="sec-head">
406
- <div><span class="kicker">Личный кабинет владельца</span><h2 class="h2">Токен привязки локальной системы и покупка лицензий</h2></div>
406
+ <div><span class="kicker">Личный кабинет владельца</span><h2 class="h2">Токен привязки, ваши лицензии и покупка</h2></div>
407
407
  <p id="userBadgeBox"></p>
408
408
  </div>
409
409
  <div class="card oferta-banner" data-oferta-banner></div>
410
- <div class="card" style="margin-bottom:20px">
411
- <span class="kicker">Ключ доступа для локального сервера WORK</span>
412
- <h3 class="h3" style="margin:4px 0 8px">Токен владельца (содержит URL сервера поддержки и ключ синхронизации лицензий)</h3>
413
- <p class="small" style="margin:0 0 10px">Скопируйте этот токен и вставьте его в раздел «Лицензирование» вашей локальной системы ODANT-WORK. Система автоматически привяжется к личному кабинету и загрузит файлы лицензий в папку <code>#system</code>.</p>
414
- <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
415
- <input type="text" id="inpCabinetHubToken" readonly placeholder="owk1..." style="flex:1;min-width:260px;font-family:monospace">
416
- <button class="btn btn-ghost btn-sm" data-action="gen-hub-token">Сгенерировать / Обновить токен</button>
410
+
411
+ <div class="split" style="margin-bottom:20px">
412
+ <div class="card">
413
+ <span class="kicker">Шаг 1 · Привязка вашего локального сервера WORK</span>
414
+ <h3 class="h3" style="margin:4px 0 8px">Токен владельца (содержит URL сервера поддержки)</h3>
415
+ <p class="small" style="margin:0 0 12px">Скопируйте этот токен и вставьте его в разделе <b>WORK → Лицензия</b> вашей локальной системы. По этому токену система автоматически скачивает и обновляет ваши лицензии в папку <code>#system</code>.</p>
416
+ <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
417
+ <input type="text" id="inpCabinetHubToken" readonly placeholder="owk1..." style="flex:1;min-width:220px;font-family:monospace">
418
+ <button class="btn btn-primary btn-sm" data-action="copy-hub-token">Скопировать токен</button>
419
+ <button class="btn btn-ghost btn-sm" data-action="gen-hub-token">Перевыпустить</button>
420
+ </div>
421
+ <div class="small" id="tokenCopyStatus" style="color:var(--ok);min-height:18px;margin-top:6px"></div>
422
+ </div>
423
+
424
+ <div class="card">
425
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap">
426
+ <div>
427
+ <span class="kicker">Шаг 2 · Ваши выпущенные лицензии</span>
428
+ <h3 class="h3" style="margin:4px 0 6px">Файлы лицензий в личном кабинете</h3>
429
+ </div>
430
+ <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить</button>
431
+ </div>
432
+ <div id="cabinetLicensesBox" class="small" style="margin-top:8px;line-height:1.55"></div>
417
433
  </div>
418
434
  </div>
435
+
419
436
  <div class="split">
420
437
  <div class="card">
438
+ <span class="kicker">Шаг 3 · Приобретение базовой и дополнительных лицензий</span>
439
+ <div class="field" style="margin-top:8px">
440
+ <label>Базовая постоянная лицензия (10 вечных пользователей + включение ИИ-агентов)</label>
441
+ <div class="seg" id="segCabinetBase">
442
+ <button data-base="license_only" class="on">Базовая лицензия (без железа)<small>${rub(P.baseLicensePriceRub || 4_000_000)} · 10 вечных мест</small></button>
443
+ <button data-base="pak">Комплект с машиной<small>${rub(P.basePriceRub)} · сервер + 10 мест</small></button>
444
+ <button data-base="none">Только доп. временные<small>0 ₽ · базовая уже куплена</small></button>
445
+ </div>
446
+ </div>
421
447
  <div class="field">
422
- <label>Количество дополнительных мест</label>
448
+ <label>Дополнительные временные места (сверх 10 вечных)</label>
423
449
  <div style="display:flex;align-items:center;gap:16px">
424
- <input type="range" id="rngAddonSeats" min="1" max="150" value="10" style="flex:1">
425
- <input type="number" id="inpSeats" min="1" max="2000" value="10" style="width:110px">
450
+ <input type="range" id="rngAddonSeats" min="0" max="150" value="0" style="flex:1">
451
+ <input type="number" id="inpSeats" min="0" max="2000" value="0" style="width:110px">
426
452
  </div>
427
453
  </div>
428
454
  <div class="field">
429
- <label>Уровень поддержки</label>
455
+ <label>Уровень поддержки дополнительных мест</label>
430
456
  <div class="seg" id="segTier">${Object.values(SLA_TIERS).map(t => `<button data-tier="${t.id}" class="${t.id === 'business' ? 'on' : ''}">${esc(t.name)}<small>${rub(t.pricePerUserYearRub)} · ${esc(t.reactionLabel)}</small></button>`).join('')}</div>
431
457
  </div>
432
458
  <div class="row2">
433
- <div class="field"><label for="inpYears">Срок, лет (кратно годам)</label><input type="number" id="inpYears" min="1" max="5" value="1"></div>
434
- <div class="field"><label for="inpLicId">Лицензия комплекса</label><input type="text" id="inpLicId" readonly></div>
459
+ <div class="field"><label for="inpYears">Срок временных лицензий, лет (кратно годам)</label><input type="number" id="inpYears" min="1" max="5" value="1"></div>
460
+ <div class="field"><label for="inpLicId">Идентификатор лицензии</label><input type="text" id="inpLicId" readonly></div>
435
461
  </div>
436
- <div class="field" style="margin-top:6px"><label>Скидка от количества</label>${renderLadder('ladderAddons')}</div>
462
+ <div class="field" style="margin-top:6px"><label>Скидка от количества доп. мест</label>${renderLadder('ladderAddons')}</div>
437
463
  </div>
438
464
  <div class="card summary card-glow">
439
465
  <span class="kicker">К оплате</span>
@@ -809,7 +835,12 @@ export function renderSandboxPaymentPage(order) {
809
835
  });
810
836
  const data = await res.json();
811
837
  document.getElementById('st').textContent = data.status || event;
812
- document.getElementById('msg').textContent = data.status === 'paid' ? 'Оплата проведена. Лицензия обновлена.' : 'Платёж отменён.';
838
+ document.getElementById('msg').innerHTML = data.status === 'paid'
839
+ ? 'Оплата успешно проведена! Файлы лицензий выпущены в вашем Личном кабинете.<br><button class="btn cancel" style="margin-top:10px" onclick="if(window.opener){window.opener.postMessage({type:\\'odant:order-paid\\'},\\'*\\');window.close();}else{location.href=\\'/#addons\\';}">Вернуться в Личный кабинет →</button>'
840
+ : 'Платёж отменён.';
841
+ if (data.status === 'paid' && window.opener) {
842
+ try { window.opener.postMessage({ type: 'odant:order-paid', orderId: ORDER.orderId }, '*'); } catch {}
843
+ }
813
844
  }
814
845
  </script>
815
846
  </body>
package/src/store.js CHANGED
@@ -993,6 +993,7 @@ export class LicenseStore {
993
993
  const summary = this.issueOrUpdateLicense({
994
994
  ownerAccountId: userId,
995
995
  ownerEmail: cleanEmail,
996
+ baseActivated: false,
996
997
  customer: {
997
998
  name: companyName || name || 'Организация ODANT-WORK',
998
999
  inn: inn || '',