work-license-server 1.0.14 → 1.0.15

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
@@ -317,8 +317,12 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
317
317
  <div class="sum-total" id="lblPakTotal">${rub(P.basePriceRub)}</div>
318
318
  <div class="small" id="lblPakDiscount" style="color:var(--ok);font-weight:650">Включает 10 вечных мест и 1 год поддержки Enterprise</div>
319
319
  <div class="sum-lines" id="lblPakBreakdown"></div>
320
+ <div class="field" id="clientServerPickerWrap" style="display:none;margin:10px 0 4px">
321
+ <label for="selClientTargetServer">Целевой сервер (устройство) в вашем Личном кабинете</label>
322
+ <select id="selClientTargetServer"></select>
323
+ </div>
320
324
  <button class="btn btn-primary btn-lg" style="width:100%" data-action="buy-client-config">Купить</button>
321
- <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Купить» заказ добавится в ваш Личный кабинет, где вы сможете оплатить его через ЮKassa или выставить счёт-договор для юр. лица.</p>
325
+ <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Купить» заказ привяжется к выбранному серверу в вашем Реестре серверов Личного кабинета (оплата через ЮKassa или по счёту-договору).</p>
322
326
  <div class="status" id="pakOrderStatus"></div>
323
327
  </div>
324
328
  </div>
@@ -333,46 +337,50 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
333
337
  <div class="tiers">${renderTiers()}</div>
334
338
  </section>
335
339
 
336
- <!-- ============ ЛИЧНЫЙ КАБИНЕТ (открывается после входа / регистрации) ============ -->
340
+ <!-- ============ ЛИЧНЫЙ КАБИНЕТ: 2 ОСНОВНЫХ РЕЕСТРА (СЕРВЕРЫ И ФРАНШИЗЫ) ============ -->
337
341
  <section class="stage" id="stage-addons">
338
342
  <div class="sec-head">
339
- <div><span class="kicker">Личный кабинет владельца</span><h2 class="h2">Заказы, токен привязки и лицензии</h2></div>
340
- <p id="userBadgeBox"></p>
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>
341
353
  </div>
342
354
 
343
- <div class="card" style="margin-bottom:20px">
344
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
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)">
345
358
  <div>
346
- <span class="kicker">Заказы и оплата</span>
347
- <h3 class="h3" style="margin:4px 0 0">Ваши заказы в Личном кабинете</h3>
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>
348
362
  </div>
349
- <div style="display:flex;gap:8px;flex-wrap:wrap">
350
- <button class="btn btn-ghost btn-sm" data-stage="clients">+ Добавить лицензию / ПАК</button>
351
- <button class="btn btn-ghost btn-sm" data-stage="franchise">+ Зарезервировать франшизу</button>
352
- <button class="btn btn-ghost btn-sm" data-action="refresh-cabinet">Обновить</button>
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>
353
366
  </div>
354
367
  </div>
355
- <div id="cabinetOrdersBox" style="margin-top:14px"></div>
368
+ <div id="cabinetServersRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
356
369
  </div>
357
370
 
358
- <div class="split">
359
- <div class="card">
360
- <span class="kicker">Привязка локального сервера WORK</span>
361
- <h3 class="h3" style="margin:4px 0 8px">Токен владельца (содержит URL сервера поддержки)</h3>
362
- <p class="small" style="margin:0 0 12px">Скопируйте этот токен и вставьте его в разделе <b>WORK → Лицензия</b> вашей локальной системы. По этому токену система сама скачивает и обновляет оплаченные файлы лицензий в папку <code>#system</code>.</p>
363
- <div style="display:flex;gap:10px;flex-wrap:wrap;align-items:center">
364
- <input type="text" id="inpCabinetHubToken" readonly placeholder="owk1..." style="flex:1;min-width:220px;font-family:monospace">
365
- <button class="btn btn-primary btn-sm" data-action="copy-hub-token">Скопировать токен</button>
366
- <button class="btn btn-ghost btn-sm" data-action="gen-hub-token">Перевыпустить</button>
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>
367
381
  </div>
368
- <div class="small" id="tokenCopyStatus" style="color:var(--ok);min-height:18px;margin-top:6px"></div>
369
- </div>
370
-
371
- <div class="card">
372
- <span class="kicker">Действующие лицензии (.lic.json) и франшизы</span>
373
- <h3 class="h3" style="margin:4px 0 6px">Выпущенные файлы лицензий</h3>
374
- <div id="cabinetLicensesBox" class="small" style="margin-top:8px;line-height:1.55"></div>
375
382
  </div>
383
+ <div id="cabinetFranchisesRegistry" style="margin-top:16px;display:flex;flex-direction:column;gap:16px"></div>
376
384
  </div>
377
385
  </section>
378
386
 
@@ -425,7 +433,7 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
425
433
  <div><span>Остаток (в течение года)</span><b id="mapRegRest">—</b></div>
426
434
  </div>
427
435
  <button class="btn btn-primary" style="width:100%;margin-top:8px" id="btnReserveSelected" data-action="reserve-selected" disabled>Зарезервировать · залог ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</button>
428
- <p class="small muted" style="margin:8px 0 0;text-align:center">При нажатии «Зарезервировать» заявка на регион добавится как заказ в ваш Личный кабинет.</p>
436
+ <p class="small muted" style="margin:8px 0 0;text-align:center">Не более 1 франшизы в одном регионе. После резервирования даётся <b>15 минут</b> на оплату начального взноса через ЮKassa; далее — годовой график погашения (раз в месяц).</p>
429
437
  <div class="status" id="partnerActionMsg"></div>
430
438
  </aside>
431
439
  </div>
package/src/server.js CHANGED
@@ -479,16 +479,48 @@ export function createLicenseServerApp(options = {}) {
479
479
  return sendJson(res, 200, user);
480
480
  }
481
481
 
482
- // 2.0.1 Генерация или перевыпуск токена владельца (с зашитым URL сервера поддержки) в личном кабинете
482
+ // 2.0.1 Генерация или перевыпуск токена владельца/сервера (с зашитым URL сервера поддержки и licenseId) в личном кабинете
483
483
  if (req.method === 'POST' && pathname === '/api/cabinet/token') {
484
484
  const body = await readJsonBody(req);
485
485
  const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
486
486
  const result = store.generateUserHubToken(key, {
487
+ licenseId: body.licenseId || '',
487
488
  hubUrl: body.hubUrl || reqOrigin,
488
489
  });
489
490
  return sendJson(res, 200, result);
490
491
  }
491
492
 
493
+ // 2.0.2 Реестр серверов (устройств) пользователя: добавление, переименование, удаление
494
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/create') {
495
+ const body = await readJsonBody(req);
496
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
497
+ const result = store.createUserServer(key, {
498
+ serverName: body.serverName || '',
499
+ hwid: body.hwid || '',
500
+ hubUrl: body.hubUrl || reqOrigin,
501
+ });
502
+ return sendJson(res, 200, result);
503
+ }
504
+
505
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/rename') {
506
+ const body = await readJsonBody(req);
507
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
508
+ const result = store.renameUserServer(key, {
509
+ licenseId: body.licenseId || '',
510
+ serverName: body.serverName || '',
511
+ });
512
+ return sendJson(res, 200, result);
513
+ }
514
+
515
+ if (req.method === 'POST' && pathname === '/api/cabinet/servers/delete') {
516
+ const body = await readJsonBody(req);
517
+ const key = extractBearerToken(req, body, url) || body.email || body.accountId || '';
518
+ const result = store.deleteUserServer(key, {
519
+ licenseId: body.licenseId || '',
520
+ });
521
+ return sendJson(res, 200, result);
522
+ }
523
+
492
524
  if (req.method === 'POST' && pathname === '/api/auth/accept-oferta') {
493
525
  const body = await readJsonBody(req);
494
526
  const key = extractBearerToken(req, body, url) || body.email || '';
@@ -569,6 +601,7 @@ export function createLicenseServerApp(options = {}) {
569
601
  const user = ensureCustomerOferta(req, body);
570
602
  let order;
571
603
  let reservation = null;
604
+ let payment = null;
572
605
  if (body.kind === 'franchise_reservation' || body.regionCode) {
573
606
  const reserved = store.reserveFranchiseSlot({
574
607
  regionCode: body.regionCode,
@@ -576,14 +609,18 @@ export function createLicenseServerApp(options = {}) {
576
609
  companyName: body.companyName || user?.companyName || user?.email || 'Партнёр ODANT-WORK',
577
610
  inn: body.inn || user?.inn || '',
578
611
  email: body.email || user?.email || '',
612
+ depositTtlMs: body.depositTtlMs,
579
613
  });
580
614
  order = reserved.order;
581
- reservation = reserved.reservation;
615
+ payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
616
+ reservation = store.getFranchiseSummary(reserved.reservation.reservationId);
582
617
  }
583
618
  else {
584
619
  order = store.createOrder({
585
620
  ...body,
586
621
  licenseId: body.licenseId || user?.licenseId || '',
622
+ serverName: body.serverName || '',
623
+ createNewServer: Boolean(body.createNewServer || body.licenseId === 'NEW'),
587
624
  ownerAccountId: user?.userId || '',
588
625
  customer: {
589
626
  name: body.customer?.name || user?.companyName || user?.email || '',
@@ -596,12 +633,62 @@ export function createLicenseServerApp(options = {}) {
596
633
  const updatedUser = store.findUserBySessionKey(user?.sessionKey);
597
634
  return sendJson(res, 200, {
598
635
  ok: true,
636
+ ...(payment || {}),
599
637
  order,
600
638
  reservation,
601
639
  user: updatedUser,
602
640
  });
603
641
  }
604
642
 
643
+ // 2.6.1b Оплата очередного ежемесячного платежа (раз в месяц) или досрочное погашение по графику франшизы
644
+ if (req.method === 'POST' && pathname === '/api/cabinet/franchises/pay-installment') {
645
+ const body = await readJsonBody(req);
646
+ const user = ensureCustomerOferta(req, body);
647
+ const created = store.createFranchiseInstallmentOrder(user?.sessionKey || user?.userId, {
648
+ reservationId: body.reservationId,
649
+ monthNumber: body.monthNumber ?? null,
650
+ earlyPayoff: Boolean(body.earlyPayoff),
651
+ paymentMethod: body.paymentMethod || 'yookassa',
652
+ companyName: body.companyName || user?.companyName || '',
653
+ inn: body.inn || user?.inn || '',
654
+ });
655
+ const order = created.order;
656
+ if (order.paymentMethod === 'invoice') {
657
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
658
+ return sendJson(res, 200, {
659
+ ok: true,
660
+ orderId: order.orderId,
661
+ paymentMethod: 'invoice',
662
+ invoiceUrl: `${reqOrigin}${order.invoiceUrl}`,
663
+ confirmationUrl: `${reqOrigin}${order.invoiceUrl}`,
664
+ order,
665
+ franchise: store.getFranchiseSummary(body.reservationId),
666
+ user: updatedUser,
667
+ });
668
+ }
669
+ const payment = await yookassa.createPaymentForOrder(order, { originOverride: reqOrigin });
670
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
671
+ return sendJson(res, 200, {
672
+ ok: true,
673
+ ...payment,
674
+ order,
675
+ franchise: store.getFranchiseSummary(body.reservationId),
676
+ user: updatedUser,
677
+ });
678
+ }
679
+
680
+ // 2.6.1c Отмена неоплаченного резерва франшизы
681
+ if (req.method === 'POST' && pathname === '/api/cabinet/franchises/cancel') {
682
+ const body = await readJsonBody(req);
683
+ const user = ensureCustomerOferta(req, body);
684
+ const resCancel = store.cancelFranchiseReservation(body.reservationId, { byAdmin: Boolean(user?.isAdmin) });
685
+ const updatedUser = store.findUserBySessionKey(user?.sessionKey);
686
+ return sendJson(res, 200, {
687
+ ...resCancel,
688
+ user: updatedUser,
689
+ });
690
+ }
691
+
605
692
  // 2.6.2 Оплата существующего заказа из Личного кабинета (через ЮKassa или по счёту-договору для юрлиц)
606
693
  if (req.method === 'POST' && pathname === '/api/cabinet/orders/pay') {
607
694
  const body = await readJsonBody(req);
@@ -843,17 +930,46 @@ export function createLicenseServerApp(options = {}) {
843
930
  const tok = extractBearerToken(req, null, url);
844
931
  if (!store.verifyAdminToken(tok))
845
932
  return sendJson(res, 401, { error: 'Требуется авторизация администратора (/admin)' });
933
+ const allFranchises = store.listAllFranchises();
846
934
  return sendJson(res, 200, {
847
935
  licenses: store.listLicenses(),
848
936
  tickets: store.listTickets(),
849
- orders: Object.values(store.state.orders),
937
+ orders: Object.values(store.state.orders).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)),
850
938
  partners: store.listPartners(),
851
- reservations: Object.values(store.state.reservations || {}),
939
+ franchises: allFranchises,
940
+ reservations: allFranchises,
852
941
  regions: store.listRegionsWithQuotas(),
853
942
  users: store.listUsers(),
854
943
  });
855
944
  }
856
945
 
946
+ if (req.method === 'POST' && pathname === '/api/admin/servers/manage') {
947
+ const body = await readJsonBody(req);
948
+ const tok = extractBearerToken(req, body, url);
949
+ if (!store.verifyAdminToken(tok))
950
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
951
+ const server = store.adminManageServer(body);
952
+ return sendJson(res, 200, { ok: true, server });
953
+ }
954
+
955
+ if (req.method === 'POST' && pathname === '/api/admin/franchises/confirm-installment') {
956
+ const body = await readJsonBody(req);
957
+ const tok = extractBearerToken(req, body, url);
958
+ if (!store.verifyAdminToken(tok))
959
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
960
+ const result = store.adminConfirmFranchiseInstallment(body.reservationId, body);
961
+ return sendJson(res, 200, { ok: true, ...result });
962
+ }
963
+
964
+ if (req.method === 'POST' && pathname === '/api/admin/franchises/cancel') {
965
+ const body = await readJsonBody(req);
966
+ const tok = extractBearerToken(req, body, url);
967
+ if (!store.verifyAdminToken(tok))
968
+ return sendJson(res, 401, { error: 'Требуется авторизация администратора' });
969
+ const result = store.cancelFranchiseReservation(body.reservationId, { byAdmin: true });
970
+ return sendJson(res, 200, result);
971
+ }
972
+
857
973
  if (req.method === 'POST' && pathname.startsWith('/api/admin/orders/') && pathname.endsWith('/cancel')) {
858
974
  const body = await readJsonBody(req);
859
975
  const tok = extractBearerToken(req, body, url);