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/assets/landing.js CHANGED
@@ -129,12 +129,47 @@
129
129
  card.style.setProperty('--my', (e.clientY - r.top) + 'px');
130
130
  }, { passive: true });
131
131
 
132
- /* ================= Вход / Регистрация (Email, Мессенджеры, OAuth) ================= */
132
+ /* ================= Вход / Регистрация и постоянная сессия в браузере ================= */
133
+ function readCookieSession() {
134
+ try {
135
+ const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
136
+ return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
137
+ }
138
+ catch { return ''; }
139
+ }
140
+ function persistSessionInBrowser(sessionKey = '', email = '', isAdmin = false) {
141
+ try {
142
+ if (sessionKey) {
143
+ localStorage.setItem('odant_work_session', sessionKey);
144
+ sessionStorage.setItem('odant_work_session', sessionKey);
145
+ if (isAdmin) {
146
+ localStorage.setItem('odant_work_admin_token', sessionKey);
147
+ sessionStorage.setItem('odant_work_admin_token', sessionKey);
148
+ }
149
+ document.cookie = `odant_work_session=${encodeURIComponent(sessionKey)}; path=/; max-age=31536000; SameSite=Lax`;
150
+ }
151
+ if (email) {
152
+ localStorage.setItem('odant_work_email', email);
153
+ }
154
+ }
155
+ catch { /* приватный режим */ }
156
+ }
157
+ function clearSessionFromBrowser() {
158
+ try {
159
+ localStorage.removeItem('odant_work_session');
160
+ localStorage.removeItem('odant_work_admin_token');
161
+ sessionStorage.removeItem('odant_work_session');
162
+ sessionStorage.removeItem('odant_work_admin_token');
163
+ document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
164
+ }
165
+ catch { /* ignore */ }
166
+ }
167
+
133
168
  function openAuth(hintMsg = '', intentTitle = '') {
134
169
  if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
135
170
  const intentBox = $('#authIntentNotice');
136
171
  if (intentBox) {
137
- if (intentTitle) {
172
+ if (intentTitle && !currentUser) {
138
173
  intentBox.style.display = '';
139
174
  intentBox.innerHTML = `Выбранная позиция: <b>${esc(intentTitle)}</b>.<br>После входа она автоматически появится как заказ в вашем Личном кабинете.`;
140
175
  }
@@ -143,9 +178,36 @@
143
178
  intentBox.textContent = '';
144
179
  }
145
180
  }
146
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = currentUser ? '' : 'none';
181
+ if (currentUser) {
182
+ if ($('#authTitle')) $('#authTitle').textContent = 'Ваш аккаунт';
183
+ if ($('#authSub')) $('#authSub').textContent = 'Сессия сохранена в браузере';
184
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = '';
185
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = 'none';
186
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
187
+ if ($('#authLoggedEmail')) $('#authLoggedEmail').textContent = currentUser.email || '';
188
+ if ($('#authLoggedRoleBadge')) {
189
+ $('#authLoggedRoleBadge').innerHTML = currentUser.isAdmin
190
+ ? '<span class="tag ok">BOSS · Главный администратор</span>'
191
+ : '<span class="tag">Клиент ODANT-WORK</span>';
192
+ }
193
+ if ($('#btnGoAdminModal')) $('#btnGoAdminModal').style.display = currentUser.isAdmin ? '' : 'none';
194
+ }
195
+ else {
196
+ if ($('#authTitle')) $('#authTitle').textContent = 'Вход / Регистрация';
197
+ if ($('#authSub')) $('#authSub').textContent = 'На ваш Email придёт одноразовый 6-значный код подтверждения';
198
+ if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = 'none';
199
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = '';
200
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
201
+ try {
202
+ const savedEmail = localStorage.getItem('odant_work_email') || '';
203
+ if (savedEmail && $('#authEmail') && !$('#authEmail').value) {
204
+ $('#authEmail').value = savedEmail;
205
+ }
206
+ }
207
+ catch { /* ignore */ }
208
+ setTimeout(() => $('#authEmail')?.focus(), 50);
209
+ }
147
210
  $('#authModal')?.classList.add('open');
148
- setTimeout(() => $('#authEmail')?.focus(), 50);
149
211
  }
150
212
  function closeAuth() {
151
213
  $('#authModal')?.classList.remove('open');
@@ -224,7 +286,7 @@
224
286
  try {
225
287
  const data = await api('/api/auth/verify-code', { email, code });
226
288
  currentUser = data;
227
- try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
289
+ persistSessionInBrowser(data.sessionKey, data.email, data.isAdmin);
228
290
  resetAuthStep();
229
291
  onAuthenticated();
230
292
  closeAuth();
@@ -245,18 +307,14 @@
245
307
  }
246
308
  }
247
309
 
248
- function performLogout() {
310
+ async function performLogout() {
311
+ try { await api('/api/auth/logout', {}); } catch { /* ignore */ }
249
312
  currentUser = null;
250
313
  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 */ }
314
+ clearSessionFromBrowser();
256
315
  if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
257
316
  if ($('#tileAdmin')) $('#tileAdmin').style.display = 'none';
258
317
  if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
259
- if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
260
318
  closeAuth();
261
319
  if (currentStage === 'addons') switchStage('product');
262
320
  moveIndicator();
@@ -264,20 +322,36 @@
264
322
 
265
323
  async function restoreSession() {
266
324
  let key = '';
267
- try { key = localStorage.getItem('odant_work_session') || ''; } catch { /* приватный режим */ }
325
+ try {
326
+ key = localStorage.getItem('odant_work_session')
327
+ || sessionStorage.getItem('odant_work_session')
328
+ || localStorage.getItem('odant_work_admin_token')
329
+ || readCookieSession()
330
+ || '';
331
+ }
332
+ catch { key = readCookieSession(); }
268
333
  if (!key) return;
269
334
  try {
270
335
  const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
271
336
  if (data.authenticated && data.user) {
272
- currentUser = { ...data.user, sessionKey: key };
337
+ currentUser = { ...data.user, sessionKey: data.user.sessionKey || key };
338
+ persistSessionInBrowser(currentUser.sessionKey, currentUser.email, currentUser.isAdmin);
273
339
  onAuthenticated();
274
340
  if (location.hash === '#addons' && isUserReadyForOrders()) switchStage('addons', { scroll: false });
275
341
  }
276
342
  }
277
- catch { /* сессия устарела */ }
343
+ catch { /* сессия отозвана */ }
278
344
  }
279
345
 
280
346
  let franchiseCountdownTimer = null;
347
+ let activeCabinetTab = 'servers';
348
+
349
+ function switchCabinetTab(tab = 'servers') {
350
+ activeCabinetTab = tab === 'franchises' ? 'franchises' : 'servers';
351
+ $$('[data-cab-tab]').forEach(b => b.classList.toggle('active', b.dataset.cabTab === activeCabinetTab));
352
+ if ($('#cabSecServers')) $('#cabSecServers').style.display = activeCabinetTab === 'servers' ? '' : 'none';
353
+ if ($('#cabSecFranchises')) $('#cabSecFranchises').style.display = activeCabinetTab === 'franchises' ? '' : 'none';
354
+ }
281
355
 
282
356
  function onAuthenticated() {
283
357
  if (!currentUser) return;
@@ -360,11 +434,11 @@
360
434
  const box = $('#cabinetServersRegistry');
361
435
  if (!box) return;
362
436
  const servers = getUserServersList();
363
- if ($('#badgeServersCount')) $('#badgeServersCount').textContent = String(servers.length);
437
+ if ($('#navCabServers')) $('#navCabServers').textContent = `Серверы и устройства (${servers.length})`;
364
438
 
365
439
  if (!servers.length) {
366
- box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
367
- В вашем реестре пока нет добавленных серверов WORK. Введите название сервера выше и нажмите <b>«+ Добавить сервер (устройство)»</b>.
440
+ box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
441
+ В реестре пока нет серверов WORK. Введите название нового сервера справа вверху и нажмите <b>«+ Добавить сервер»</b>.
368
442
  </div>`;
369
443
  return;
370
444
  }
@@ -375,12 +449,12 @@
375
449
  const tok = s.hubToken || currentUser?.hubToken || '';
376
450
  const hwidBound = Boolean(s.hwidBound || s.hardware?.hwidBound || s.hardware?.nodeFingerprint);
377
451
  const hwidText = hwidBound
378
- ? `Железо привязано: <code>${esc(s.hardware?.hwid || '')}</code>${s.hardware?.nodeFingerprint ? ` · узел <code>${esc(s.hardware.nodeFingerprint.slice(0, 16))}…</code>` : ''}`
379
- : 'Ожидает привязки токена в локальном WORK (раздел WORK → Лицензия)';
452
+ ? `HWID: <code>${esc(s.hardware?.hwid || '')}</code>`
453
+ : 'ожидает привязки токена в WORK → Лицензия';
380
454
 
381
455
  const baseBadge = s.baseActivated
382
- ? `<span class="tag ok">✓ Базовая активна · ${s.baseSeats || 10} вечных мест + ИИ-агенты</span>`
383
- : `<span class="tag">Базовая не активирована (1 сессия BOSS · ИИ отключён)</span>`;
456
+ ? `<span class="tag ok">✓ Базовая: ${s.baseSeats || 10} вечн. мест + ИИ</span>`
457
+ : `<span class="tag">Базовая не активна (1 сессия BOSS)</span>`;
384
458
 
385
459
  const files = s.licenseFiles || [];
386
460
  const activeAddons = s.activeAddons || [];
@@ -390,118 +464,83 @@
390
464
  const canDelete = !s.baseActivated && !activeAddons.length && !paidOrders.length;
391
465
 
392
466
  const baseActionsHtml = !s.baseActivated
393
- ? `<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px">
394
- <button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую лицензию (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
395
- <button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Заказать ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>
396
- </div>`
467
+ ? `<button class="btn btn-primary btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="license_only">Купить базовую (${rub(D.product?.baseLicensePriceRub || 4_000_000)})</button>
468
+ <button class="btn btn-ghost btn-sm" data-buy-for-server="${esc(lid)}" data-buy-variant="pak">Купить ПАК с сервером (${rub(D.product?.basePriceRub || 5_000_000)})</button>`
397
469
  : '';
398
470
 
399
- const addonsListHtml = activeAddons.length
400
- ? `<div style="margin-top:8px;display:flex;flex-direction:column;gap:6px">
401
- ${activeAddons.map(sub => {
402
- const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
403
- return `<div style="display:flex;justify-content:space-between;align-items:center;padding:6px 10px;border-radius:8px;background:rgba(255,255,255,.03);border:1px solid var(--border)">
404
- <span><b>+${sub.seats} конк. мест</b> · уровень <b>${esc(sub.tierName || sub.tier)}</b> (${sub.years || 1} г.)</span>
405
- <span class="small muted">действует до ${esc(exp)}</span>
406
- </div>`;
407
- }).join('')}
408
- </div>`
409
- : `<div class="small muted" style="margin-top:6px">Дополнительные временные места не подключены.</div>`;
471
+ const addonsSummaryHtml = activeAddons.length
472
+ ? activeAddons.map(sub => {
473
+ const exp = sub.expiresAt ? new Date(sub.expiresAt).toLocaleDateString('ru-RU') : '—';
474
+ return `<span class="tag ok" style="margin-right:6px">+${sub.seats} мест · ${esc(sub.tierName || sub.tier)} до ${esc(exp)}</span>`;
475
+ }).join('')
476
+ : `<span class="small muted">Доп. места не подключены</span>`;
410
477
 
411
478
  const filesHtml = files.length
412
- ? `<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)">
413
- <div class="small muted" style="margin-bottom:6px">Выпущенные подписанные файлы лицензий этого сервера (автоматически загружаются в <code>#system</code> по токену):</div>
414
- <div style="display:flex;flex-direction:column;gap:6px">
415
- ${files.map((f, fIdx) => {
416
- const lf = f.licenseFile || f;
417
- const isBase = lf.kind === 'base' || lf.perpetual;
418
- const label = isBase ? 'Базовая вечная лицензия (10 мест + ИИ)' : `Доп. лицензия (+${lf.seats} мест · ${String(lf.tier || '').toUpperCase()})`;
419
- return `<div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;font-size:12.5px">
420
- <span><b>${esc(label)}</b> · <code>${esc(f.fileName || lf.fileId)}</code></span>
421
- <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">Скачать .lic.json</button>
422
- </div>`;
423
- }).join('')}
424
- </div>
479
+ ? `<div style="margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center">
480
+ <span class="small muted">Файлы лицензий (<code>#system</code>):</span>
481
+ ${files.map((f, fIdx) => `<button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-download-lic="${esc(lid)}" data-file-idx="${fIdx}">↓ ${esc(f.fileName || 'license.lic.json')}</button>`).join('')}
425
482
  </div>`
426
483
  : '';
427
484
 
428
485
  const pendingOrdersHtml = pendingOrders.length
429
- ? `<div style="margin-top:14px;padding-top:12px;border-top:1px solid var(--border)">
430
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.06em;color:var(--warn);margin-bottom:8px">Ожидают оплаты по этому серверу (${pendingOrders.length})</div>
486
+ ? `<div style="margin-top:10px;padding:10px 12px;border-radius:8px;border:1px solid var(--line-strong);background:color-mix(in srgb, var(--warn) 6%, transparent)">
431
487
  ${pendingOrders.map(o => {
432
488
  const title = (o.items && o.items.length) ? o.items.map(it => esc(it.title)).join(' + ') : esc(o.tierName || o.orderId);
433
- return `<div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(245,158,11,.05);margin-bottom:8px">
434
- <div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
435
- <div>
436
- <div class="small muted">Заказ <code>${esc(o.orderId)}</code></div>
437
- <div style="font-weight:700;font-size:14px;margin-top:2px">${title}</div>
438
- </div>
439
- <div style="font-size:17px;font-weight:800">${rub(o.amountRub)}</div>
440
- </div>
441
- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:10px;align-items:center">
442
- <button class="btn btn-primary btn-sm" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить через ЮKassa (${rub(o.amountRub)})</button>
443
- <button class="btn btn-ghost btn-sm" data-invoice-toggle="${esc(o.orderId)}">Счёт-договор (для юр. лиц)</button>
444
- <button class="btn btn-ghost btn-sm" data-cancel-order="${esc(o.orderId)}" style="color:var(--danger)">Отменить заказ</button>
489
+ return `<div style="display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;align-items:center">
490
+ <div class="small"><b>Ожидает оплаты:</b> ${title} · <b>${rub(o.amountRub)}</b></div>
491
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
492
+ <button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-order="${esc(o.orderId)}" data-pay-method="yookassa">Оплатить ЮKassa</button>
493
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-invoice-toggle="${esc(o.orderId)}">Счёт юрлицу</button>
494
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-cancel-order="${esc(o.orderId)}">Отменить</button>
445
495
  </div>
446
- <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding:12px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
447
- <div class="small" style="margin-bottom:8px;font-weight:600">Реквизиты юр. лица для счёта-договора:</div>
448
- <div class="row2">
449
- <div class="field"><label>Наименование организации *</label><input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="ООО «Маслоторг»"></div>
450
- <div class="field"><label>ИНН *</label><input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="7701234567" inputmode="numeric"></div>
451
- </div>
452
- <button class="btn btn-primary btn-sm" style="margin-top:8px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Сформировать и открыть счёт-договор →</button>
496
+ </div>
497
+ <div id="invBox-${esc(o.orderId)}" style="display:none;margin-top:10px;padding-top:10px;border-top:1px solid var(--line)">
498
+ <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
499
+ <input type="text" id="invCompany-${esc(o.orderId)}" value="${esc(o.customer?.name || currentUser?.companyName || '')}" placeholder="Организация (ООО «Маслоторг»)" style="width:240px;padding:6px 10px;font-size:13px">
500
+ <input type="text" id="invInn-${esc(o.orderId)}" value="${esc(o.customer?.inn || currentUser?.inn || '')}" placeholder="ИНН" style="width:150px;padding:6px 10px;font-size:13px">
501
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12.5px" data-pay-order="${esc(o.orderId)}" data-pay-method="invoice">Открыть счёт-договор →</button>
453
502
  </div>
454
503
  </div>`;
455
504
  }).join('')}
456
505
  </div>`
457
506
  : '';
458
507
 
459
- return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
460
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
461
- <div>
462
- <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
463
- <h4 class="h3" style="margin:0;font-size:18px">${esc(name)}</h4>
464
- <code style="font-size:12px;padding:2px 8px;border-radius:6px;background:rgba(255,255,255,.06)">${esc(lid)}</code>
465
- ${baseBadge}
466
- </div>
467
- <div class="small muted" style="margin-top:6px">${hwidText}</div>
508
+ return `<div class="sec-item">
509
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
510
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
511
+ <b style="font-size:15px">${esc(name)}</b>
512
+ <span class="small muted">· <code>${esc(lid)}</code> · ${hwidText}</span>
513
+ ${baseBadge}
468
514
  </div>
469
- <div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
470
- <span class="pill" style="padding:4px 12px;font-size:12.5px">Сессий всего: <b>${s.totalSeats}</b> (${s.baseSeats || 0} вечн. + ${s.addonSeats || 0} доп.)</span>
471
- <button class="btn btn-ghost btn-sm" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
472
- ${canDelete ? `<button class="btn btn-ghost btn-sm" data-delete-server="${esc(lid)}" style="color:var(--danger)">Удалить</button>` : ''}
515
+ <div style="display:flex;gap:6px;align-items:center;flex-wrap:wrap">
516
+ <span class="small muted" style="margin-right:4px">Сессий: <b>${s.totalSeats}</b></span>
517
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-rename-server="${esc(lid)}" data-current-name="${esc(name)}">Переименовать</button>
518
+ ${canDelete ? `<button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px;color:var(--danger)" data-delete-server="${esc(lid)}">Удалить</button>` : ''}
473
519
  </div>
474
520
  </div>
475
521
 
476
- <div class="split" style="margin-top:14px;gap:16px">
477
- <!-- Токен этого сервера -->
478
- <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
479
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2);margin-bottom:4px">Токен привязки этого сервера (owk1...)</div>
480
- <p class="small muted" style="margin:0 0 8px">Вставьте этот токен в <b>WORK → Лицензия</b> на данном сервере. Токен привяжет устройство и автоматически скачает все его лицензии в <code>#system</code>.</p>
481
- <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
482
- <input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:180px;font-family:monospace;font-size:12.5px;padding:7px 10px">
483
- <button class="btn btn-primary btn-sm" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
484
- <button class="btn btn-ghost btn-sm" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
485
- </div>
486
- <div class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok);min-height:16px;margin-top:4px"></div>
487
- </div>
522
+ <!-- Строка токена привязки этого сервера -->
523
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px">
524
+ <span class="small muted" style="min-width:115px">Токен привязки:</span>
525
+ <input type="text" id="srvTok-${esc(lid)}" readonly value="${esc(tok)}" style="flex:1;min-width:200px;max-width:440px;padding:6px 10px;font-family:monospace;font-size:12px;border-radius:8px">
526
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-copy-server-token="${esc(lid)}">Скопировать токен</button>
527
+ <button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px" data-regen-server-token="${esc(lid)}">Перевыпустить</button>
528
+ <span class="small" id="srvTokStatus-${esc(lid)}" style="color:var(--ok)"></span>
529
+ </div>
488
530
 
489
- <!-- Лицензии этого сервера -->
490
- <div style="padding:12px 14px;border-radius:10px;border:1px solid var(--border);background:rgba(255,255,255,.02)">
491
- <div style="display:flex;justify-content:space-between;align-items:center;gap:8px;flex-wrap:wrap">
492
- <div style="font-size:12px;font-weight:700;text-transform:uppercase;letter-spacing:.05em;color:var(--brand-2)">Лицензии сервера (базовая и доп.)</div>
493
- <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места / SLA</button>
494
- </div>
495
- <div class="small" style="margin-top:6px">
496
- <b>Базовая лицензия:</b> ${s.baseActivated ? '<span style="color:var(--ok)">Активирована (10 вечных мест + ИИ-агенты)</span>' : '<span style="color:var(--muted)">Не активирована</span>'}
497
- </div>
531
+ <!-- Строка лицензий и докупки мест -->
532
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:10px;flex-wrap:wrap;margin-top:10px">
533
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
534
+ <span class="small muted" style="min-width:115px">Доп. лицензии:</span>
535
+ ${addonsSummaryHtml}
536
+ </div>
537
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
498
538
  ${baseActionsHtml}
499
- <div class="small" style="margin-top:8px"><b>Дополнительные годовые лицензии:</b></div>
500
- ${addonsListHtml}
501
- ${filesHtml}
539
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-buy-for-server="${esc(lid)}" data-buy-variant="addons_only">+ Докупить рабочие места</button>
502
540
  </div>
503
541
  </div>
504
542
 
543
+ ${filesHtml}
505
544
  ${pendingOrdersHtml}
506
545
  </div>`;
507
546
  }).join('');
@@ -544,11 +583,11 @@
544
583
  if (!box) return;
545
584
  const franchises = currentUser?.franchises || currentUser?.reservations || [];
546
585
  const activeList = franchises.filter(f => f && f.status !== 'canceled' && f.status !== 'expired');
547
- if ($('#badgeFranchisesCount')) $('#badgeFranchisesCount').textContent = String(activeList.length);
586
+ if ($('#navCabFranchises')) $('#navCabFranchises').textContent = `Региональные франшизы (${activeList.length})`;
548
587
 
549
588
  if (!activeList.length) {
550
- box.innerHTML = `<div style="padding:22px;border-radius:12px;border:1px dashed var(--border);color:var(--muted);text-align:center;">
551
- У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«Зарезервировать франшизу на карте РФ»</b> выше, чтобы выбрать субъект РФ.
589
+ box.innerHTML = `<div style="padding:18px;color:var(--muted);text-align:center;">
590
+ У вас пока нет зарезервированных или действующих региональных франшиз. Нажмите <b>«+ Выбрать регион на карте РФ»</b> справа вверху.
552
591
  </div>`;
553
592
  startFranchiseCountdowns();
554
593
  return;
@@ -569,29 +608,28 @@
569
608
  const remSec = Math.max(0, Math.floor((deadline - now) / 1000));
570
609
 
571
610
  const statusBadge = !f.depositPaid
572
- ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Ожидает оплаты залога · <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
611
+ ? `<span class="tag" style="background:rgba(245,158,11,.18);color:var(--warn)">Оплата залога: <b data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b></span>`
573
612
  : (remainingRub <= 0
574
- ? `<span class="tag ok">✓ Франшиза полностью оплачена (12/12)</span>`
575
- : `<span class="tag ok">✓ Залог оплачен · Погашение по графику (${paidMonths}/12 мес.)</span>`);
613
+ ? `<span class="tag ok">✓ Погашена полностью (12/12)</span>`
614
+ : `<span class="tag ok">✓ Залог оплачен · График ${paidMonths}/12 мес.</span>`);
576
615
 
577
616
  const depositAlertHtml = !f.depositPaid
578
- ? `<div style="margin-top:12px;padding:14px 16px;border-radius:12px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:14px;flex-wrap:wrap">
579
- <div>
580
- <div style="font-weight:700;color:var(--warn)">Требуется оплата начального взноса (${rub(depositRub)}) в течение 15 минут</div>
581
- <div class="small muted" style="margin-top:2px">Осталось времени: <b style="color:var(--text);font-family:monospace;font-size:14px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</b>. Если оплата через ЮKassa не поступит вовремя, резерв региона автоматически отменится.</div>
617
+ ? `<div style="margin-top:10px;padding:12px 14px;border-radius:10px;border:1px solid rgba(245,158,11,.45);background:rgba(245,158,11,.08);display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
618
+ <div class="small">
619
+ <b>Оплатите начальный взнос (${rub(depositRub)}) через ЮKassa в течение <span style="font-family:monospace;font-size:13.5px" data-deposit-deadline="${deadline}">${fmtCountdown(remSec)}</span></b>, иначе резерв автоматически отменится.
582
620
  </div>
583
- <div style="display:flex;gap:8px;flex-wrap:wrap">
584
- <button class="btn btn-primary btn-sm" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить начальный взнос ${rub(depositRub)} (ЮKassa)</button>
585
- <button class="btn btn-ghost btn-sm" data-cancel-franchise="${esc(rid)}" style="color:var(--danger)">Отменить резерв</button>
621
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
622
+ <button class="btn btn-primary btn-sm" style="padding:6px 12px;font-size:12px" data-pay-order="${esc(f.orderId)}" data-pay-method="yookassa">Оплатить ${rub(depositRub)} (ЮKassa)</button>
623
+ <button class="btn btn-ghost btn-sm" style="padding:6px 10px;font-size:12px;color:var(--danger)" data-cancel-franchise="${esc(rid)}">Отменить резерв</button>
586
624
  </div>
587
625
  </div>`
588
626
  : '';
589
627
 
590
628
  const nextInst = f.nextInstallment || schedule.find(x => x.status !== 'paid');
591
629
  const scheduleControlsHtml = (f.depositPaid && remainingRub > 0)
592
- ? `<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
593
- ${nextInst ? `<button class="btn btn-primary btn-sm" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить платёж №${nextInst.monthNumber} (${rub(nextInst.amountRub)} · ЮKassa)</button>` : ''}
594
- <button class="btn btn-ghost btn-sm" data-pay-installment="${esc(rid)}" data-early="1">Досрочно погасить весь остаток (${rub(remainingRub)})</button>
630
+ ? `<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
631
+ ${nextInst ? `<button class="btn btn-primary btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${nextInst.monthNumber}">Оплатить месяц №${nextInst.monthNumber} (${rub(nextInst.amountRub)})</button>` : ''}
632
+ <button class="btn btn-ghost btn-sm" style="padding:5px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-early="1">Погасить досрочно (${rub(remainingRub)})</button>
595
633
  </div>`
596
634
  : '';
597
635
 
@@ -607,62 +645,48 @@
607
645
  stHtml = '<span class="tag bad">Просрочен</span>';
608
646
  }
609
647
  else if (inst.displayStatus === 'next') {
610
- stHtml = '<span class="tag" style="color:var(--brand-2)">Ближайший к оплате</span>';
648
+ stHtml = '<span class="tag" style="color:var(--brand-2)">К оплате</span>';
611
649
  }
612
650
 
613
651
  const actHtml = (!isPaid && f.depositPaid)
614
652
  ? `<div style="display:flex;gap:6px;justify-content:flex-end;flex-wrap:wrap">
615
- <button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">Оплатить ЮKassa</button>
653
+ <button class="btn btn-primary btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="yookassa">ЮKassa</button>
616
654
  <button class="btn btn-ghost btn-sm" style="padding:4px 10px;font-size:12px" data-pay-installment="${esc(rid)}" data-month="${inst.monthNumber}" data-pay-method="invoice">Счёт</button>
617
655
  </div>`
618
- : (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После оплаты залога</span>`);
656
+ : (isPaid ? `<span class="small muted">Закрыт</span>` : `<span class="small muted">После залога</span>`);
619
657
 
620
658
  return `<tr>
621
659
  <td><b>Месяц ${inst.monthNumber} / 12</b></td>
622
660
  <td>${esc(dueStr)}</td>
623
- <td class="num"><b>${rub(inst.amountRub)}</b></td>
661
+ <td><b>${rub(inst.amountRub)}</b></td>
624
662
  <td>${stHtml}</td>
625
663
  <td style="text-align:right">${actHtml}</td>
626
664
  </tr>`;
627
665
  }).join('');
628
666
 
629
- return `<div style="padding:18px 20px;border-radius:14px;border:1px solid var(--border);background:rgba(255,255,255,.015)">
630
- <div style="display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;padding-bottom:12px;border-bottom:1px solid var(--border)">
631
- <div>
632
- <div style="display:flex;align-items:center;gap:10px;flex-wrap:wrap">
633
- <h4 class="h3" style="margin:0;font-size:18px">${esc(f.regionName)}</h4>
634
- <span class="tag">${esc(f.district || f.regionCode)}</span>
635
- <code style="font-size:12px;color:var(--muted)">${esc(rid)}</code>
636
- ${statusBadge}
637
- </div>
638
- <div class="small muted" style="margin-top:6px">
639
- Паушальный взнос региона: <b>${rub(totalFeeRub)}</b> · Начальный взнос (залог): <b>${rub(depositRub)}</b> (${f.depositPaid ? 'оплачен' : 'ожидает оплаты'}) · Погашено всего: <b>${rub(totalPaidRub)}</b> · Остаток: <b>${rub(remainingRub)}</b>
640
- </div>
667
+ return `<div class="sec-item">
668
+ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
669
+ <div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
670
+ <b style="font-size:15px">${esc(f.regionName)}</b>
671
+ <span class="small muted">· ${esc(f.district || f.regionCode)} · <code>${esc(rid)}</code></span>
672
+ ${statusBadge}
641
673
  </div>
642
674
  ${scheduleControlsHtml}
643
675
  </div>
644
-
645
- ${depositAlertHtml}
646
-
647
- <div style="margin-top:14px">
648
- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;margin-bottom:8px;flex-wrap:wrap">
649
- <span class="small" style="font-weight:700">Годовой график погашения платежей (12 месяцев, платёж раз в месяц)</span>
650
- <span class="small muted">Погашено ${pct}% (${rub(totalPaidRub)} из ${rub(totalFeeRub)})</span>
651
- </div>
652
- <div class="bar" style="width:100%;height:8px;margin-bottom:12px"><i style="--p:${pct}%"></i></div>
653
- <div class="table-wrap" style="max-height:340px">
654
- <table class="t">
655
- <thead><tr>
656
- <th>Период</th>
657
- <th>Дата платежа</th>
658
- <th class="num">Сумма взноса</th>
659
- <th>Статус</th>
660
- <th style="text-align:right">Действие</th>
661
- </tr></thead>
662
- <tbody>${scheduleRowsHtml}</tbody>
663
- </table>
664
- </div>
676
+ <div class="small muted" style="margin-top:4px">
677
+ Паушальный взнос: <b>${rub(totalFeeRub)}</b> · Залог: <b>${rub(depositRub)}</b> · Оплачено: <b>${rub(totalPaidRub)}</b> (${pct}%) · Остаток: <b>${rub(remainingRub)}</b>
665
678
  </div>
679
+ ${depositAlertHtml}
680
+ <table class="sec-table" style="margin-top:10px">
681
+ <thead><tr>
682
+ <th>Период</th>
683
+ <th>Дата платежа</th>
684
+ <th>Сумма</th>
685
+ <th>Статус</th>
686
+ <th style="text-align:right">Оплата</th>
687
+ </tr></thead>
688
+ <tbody>${scheduleRowsHtml}</tbody>
689
+ </table>
666
690
  </div>`;
667
691
  }).join('');
668
692
 
@@ -958,6 +982,7 @@
958
982
  onAuthenticated();
959
983
  }
960
984
  if (st) st.textContent = '';
985
+ switchCabinetTab('servers');
961
986
  switchStage('addons');
962
987
  }
963
988
  catch (err) {
@@ -1428,6 +1453,7 @@
1428
1453
  }
1429
1454
  if (msg) msg.textContent = '';
1430
1455
  refreshCatalogRegions();
1456
+ switchCabinetTab('franchises');
1431
1457
  switchStage('addons');
1432
1458
  }
1433
1459
  catch (err) {
@@ -1688,8 +1714,9 @@
1688
1714
  /* ================= Привязка событий ================= */
1689
1715
  function bind() {
1690
1716
  document.addEventListener('click', (e) => {
1691
- const el = e.target.closest('[data-stage],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1717
+ const el = e.target.closest('[data-stage],[data-cab-tab],[data-action],[data-reserve],[data-pay-order],[data-invoice-toggle],[data-cancel-order],[data-copy-server-token],[data-regen-server-token],[data-rename-server],[data-delete-server],[data-download-lic],[data-buy-for-server],[data-pay-installment],[data-cancel-franchise],[data-ask],[data-district],th[data-sort],#regionsTableBody tr');
1692
1718
  if (!el) return;
1719
+ if (el.matches('[data-cab-tab]')) { e.preventDefault(); switchCabinetTab(el.dataset.cabTab); return; }
1693
1720
  if (el.matches('[data-reserve]')) { e.stopPropagation(); reserveRegionFranchise(el.dataset.reserve); return; }
1694
1721
  if (el.matches('[data-pay-order]')) { e.stopPropagation(); payCabinetOrder(el.dataset.payOrder, el.dataset.payMethod || 'yookassa'); return; }
1695
1722
  if (el.matches('[data-invoice-toggle]')) {
@@ -1727,14 +1754,12 @@
1727
1754
  if (el.dataset.stage && el !== document.body) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1728
1755
  switch (el.dataset.action) {
1729
1756
  case 'auth':
1730
- if (isUserReadyForOrders() && currentStage !== 'addons') {
1731
- switchStage('addons');
1732
- }
1733
- else {
1734
- pendingAuthAction = null;
1735
- resetAuthStep();
1736
- openAuth();
1737
- }
1757
+ pendingAuthAction = null;
1758
+ openAuth();
1759
+ break;
1760
+ case 'go-cabinet':
1761
+ closeAuth();
1762
+ switchStage('addons');
1738
1763
  break;
1739
1764
  case 'auth-close': closeAuth(); break;
1740
1765
  case 'send-code': sendAuthCode(); break;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",