work-license-server 1.0.8 → 1.0.9

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
@@ -67,7 +67,7 @@
67
67
  }
68
68
  if (!$('#stage-' + id)) id = 'product';
69
69
  currentStage = id;
70
- document.body.setAttribute('data-stage', id);
70
+ document.body.setAttribute('data-active-stage', id);
71
71
  $$('.tab').forEach(t => t.classList.toggle('active', t.dataset.stage === id));
72
72
  $$('.stage').forEach(s => s.classList.toggle('active', s.id === 'stage-' + id));
73
73
  moveIndicator();
@@ -167,32 +167,65 @@
167
167
  return false;
168
168
  }
169
169
 
170
- async function performLogin(provider = 'email') {
170
+ function resetAuthStep() {
171
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = '';
172
+ if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
173
+ if ($('#authCodeInput')) $('#authCodeInput').value = '';
174
+ if ($('#authErrorMsg')) $('#authErrorMsg').textContent = '';
175
+ setTimeout(() => $('#authEmail')?.focus(), 50);
176
+ }
177
+
178
+ async function sendAuthCode() {
171
179
  const errBox = $('#authErrorMsg');
172
180
  if (errBox) errBox.textContent = '';
173
- let email = $('#authEmail')?.value.trim() || '';
181
+ const email = $('#authEmail')?.value.trim() || '';
174
182
  const ofertaChecked = $('#authOfertaCheck') ? Boolean($('#authOfertaCheck').checked) : true;
175
-
183
+ if (!email || !email.includes('@')) {
184
+ if (errBox) errBox.textContent = 'Введите ваш корректный email адрес.';
185
+ return;
186
+ }
176
187
  if (!ofertaChecked) {
177
188
  if (errBox) errBox.textContent = 'Подтвердите согласие с условиями Публичного договора-оферты.';
178
189
  return;
179
190
  }
180
- if (provider === 'email' && (!email || !email.includes('@'))) {
181
- if (errBox) errBox.textContent = 'Укажите корректный email.';
182
- return;
191
+ const btn = $('#btnSendCode');
192
+ if (btn) btn.disabled = true;
193
+ try {
194
+ const res = await api('/api/auth/send-code', { email });
195
+ if ($('#authStepEmail')) $('#authStepEmail').style.display = 'none';
196
+ if ($('#authStepCode')) $('#authStepCode').style.display = '';
197
+ if ($('#authCodeSentNotice')) {
198
+ $('#authCodeSentNotice').textContent = res.message || `Код отправлен на ${email}. Введите его ниже.`;
199
+ }
200
+ if ($('#authCodeInput')) {
201
+ $('#authCodeInput').value = '';
202
+ setTimeout(() => $('#authCodeInput')?.focus(), 50);
203
+ }
183
204
  }
184
- if (provider !== 'email' && (!email || !email.includes('@'))) {
185
- email = `user.${provider}@${provider}.id.odant.org`;
205
+ catch (err) {
206
+ if (errBox) errBox.textContent = err.message;
207
+ }
208
+ finally {
209
+ if (btn) btn.disabled = false;
186
210
  }
211
+ }
187
212
 
213
+ async function verifyAuthCode() {
214
+ const errBox = $('#authErrorMsg');
215
+ if (errBox) errBox.textContent = '';
216
+ const email = $('#authEmail')?.value.trim() || '';
217
+ const code = $('#authCodeInput')?.value.trim() || '';
218
+ if (!code || code.length < 6) {
219
+ if (errBox) errBox.textContent = 'Введите 6-значный код из письма.';
220
+ return;
221
+ }
222
+ const btn = $('#btnVerifyCode');
223
+ if (btn) btn.disabled = true;
188
224
  try {
189
- const data = await api('/api/auth/register', {
190
- email,
191
- provider,
192
- ofertaAccepted: true,
193
- });
225
+ const data = await api('/api/auth/verify-code', { email, code });
194
226
  currentUser = data;
195
227
  try { localStorage.setItem('odant_work_session', data.sessionKey); } catch { /* приватный режим */ }
228
+ resetAuthStep();
196
229
  onAuthenticated();
197
230
  closeAuth();
198
231
  const nextAction = pendingAuthAction;
@@ -207,6 +240,9 @@
207
240
  catch (err) {
208
241
  if (errBox) errBox.textContent = err.message;
209
242
  }
243
+ finally {
244
+ if (btn) btn.disabled = false;
245
+ }
210
246
  }
211
247
 
212
248
  function performLogout() {
@@ -1207,7 +1243,7 @@
1207
1243
  }
1208
1244
  if (el.matches('[data-district]')) { setDistrict(el.dataset.district); return; }
1209
1245
  if (el.matches('[data-ask]')) { sendAi(el.dataset.ask); return; }
1210
- if (el.dataset.stage) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1246
+ if (el.dataset.stage && el !== document.body) { e.preventDefault(); switchStage(el.dataset.stage); return; }
1211
1247
  switch (el.dataset.action) {
1212
1248
  case 'auth':
1213
1249
  if (isUserReadyForOrders() && currentStage !== 'addons') {
@@ -1215,12 +1251,14 @@
1215
1251
  }
1216
1252
  else {
1217
1253
  pendingAuthAction = null;
1254
+ resetAuthStep();
1218
1255
  openAuth();
1219
1256
  }
1220
1257
  break;
1221
1258
  case 'auth-close': closeAuth(); break;
1222
- case 'login': performLogin('email'); break;
1223
- case 'oauth-login': performLogin(el.dataset.provider || 'email'); break;
1259
+ case 'send-code': sendAuthCode(); break;
1260
+ case 'verify-code': verifyAuthCode(); break;
1261
+ case 'reset-auth-step': resetAuthStep(); break;
1224
1262
  case 'logout': e.preventDefault(); performLogout(); break;
1225
1263
  case 'open-oferta': e.preventDefault(); openOfertaModal(); break;
1226
1264
  case 'oferta-close': closeOfertaModal(); break;
@@ -1261,7 +1299,12 @@
1261
1299
  document.addEventListener('keydown', (e) => {
1262
1300
  if (e.key === 'Escape') { closeAuth(); closeOfertaModal(); $('#certModal')?.classList.remove('open'); toggleAi(false); }
1263
1301
  });
1264
- $('#authModal')?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') performLogin('email'); });
1302
+ $('#authModal')?.addEventListener('keydown', (e) => {
1303
+ if (e.key === 'Enter' && e.target.tagName !== 'BUTTON') {
1304
+ if ($('#authStepCode')?.style.display !== 'none') verifyAuthCode();
1305
+ else sendAuthCode();
1306
+ }
1307
+ });
1265
1308
  $('#aiInput')?.addEventListener('keydown', (e) => { if (e.key === 'Enter') sendAi(); });
1266
1309
  $('#inpPakExtraSeats')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
1267
1310
  $('#inpPakExtraSeatsNum')?.addEventListener('input', (e) => syncClientSeats(e.target.value));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "work-license-server",
3
- "version": "1.0.8",
3
+ "version": "1.0.9",
4
4
  "description": "Лицензионный сервер, биллинг ЮKassa, боты поддержки (MAX, Telegram, VK) и лендинг ПАК WORK (work.odant.org)",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/landing.js CHANGED
@@ -192,93 +192,29 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
192
192
  </div>
193
193
  </header>
194
194
 
195
- <!-- ============ ЛЕНДИНГ: ПЛАТФОРМА И ИИ-АГЕНТЫ (при клике на лого) ============ -->
195
+ <!-- ============ О ПЛАТФОРМЕ (чистое описание без визуального шума) ============ -->
196
196
  <section class="stage active" id="stage-product">
197
- <div class="hero">
197
+ <div class="sec-head" style="margin-top:4px">
198
198
  <div>
199
- <span class="pill"><span class="dot"></span>Программно-аппаратный комплекс на AMD Ryzen™ AI Max+ PRO 495</span>
200
- <h1 class="h1">Учёт, документооборот, процессы и&nbsp;<span class="grad">приватный ИИ</span> — в&nbsp;одном комплексе</h1>
201
- <p class="lead">ODANT-WORK — вычислительный центр предприятия, который ставится в серверную и работает автономно. Встроенная система учёта, единое пространство документов и задач, ИИ-консультант в каждой форме и команда ИИ-агентов — без облаков и передачи данных наружу.</p>
202
- <div class="hero-cta">
203
- <button class="btn btn-primary btn-lg" data-stage="clients">Клиентам · Условия и покупка</button>
204
- <button class="btn btn-ghost btn-lg" data-stage="franchise">Партнёрская франшиза</button>
205
- </div>
206
- <div class="trust">
207
- <span>${icon('shield')} Данные не покидают контур</span>
208
- <span>${icon('key')} ${P.baseSeats} вечных лицензий</span>
209
- <span>${icon('check')} 1 год поддержки Enterprise (далее — по желанию)</span>
210
- </div>
211
- </div>
212
- <div class="showcase" aria-hidden="true">
213
- <div class="showcase-ring"></div>
214
- <div class="showcase-ring r2"></div>
215
- <div class="showcase-logo"><img src="/assets/odant-logo.png" alt=""></div>
216
- <div class="chipf c1"><b>132 TOPS</b>ИИ-производительность</div>
217
- <div class="chipf c2"><b>192 ГБ</b>единой памяти LPDDR5x</div>
218
- <div class="chipf c3"><b>16 / 32</b>ядра / потока Zen 5</div>
219
- <div class="chipf c4"><b>4 ТБ</b>NVMe PCIe® 4.0</div>
199
+ <span class="kicker">Платформа цифровой организации</span>
200
+ <h1 class="h2" style="font-size:clamp(1.9rem,3vw,2.6rem);margin:4px 0 0">ODANT-WORK — учёт, документооборот, процессы и&nbsp;<span class="grad">приватный ИИ</span></h1>
220
201
  </div>
202
+ <p>Все данные предприятия — справочники, проводки, документы, переписка и задачи — живут в единой модели с общими правами доступа и работают автономно в вашем контуре без передачи данных наружу.</p>
221
203
  </div>
222
204
 
223
- <div class="kpis reveal">
224
- <div class="kpi"><b data-count="132">132<small>TOPS</small></b><span>общая ИИ-производительность, NPU 55 TOPS</span></div>
225
- <div class="kpi"><b data-count="61">61<small>TFLOPS</small></b><span>FP16 на графике Radeon™ 8065S</span></div>
226
- <div class="kpi"><b data-count="192">192<small>ГБ</small></b><span>единая память для моделей и данных</span></div>
227
- <div class="kpi"><b data-count="5.2" data-dec="1">5.2<small>ГГц</small></b><span>максимальная частота, 80 МБ кэша</span></div>
228
- </div>
229
-
230
- <div class="sec-head reveal">
231
- <div>
232
- <span class="kicker">Что внутри</span>
233
- <h2 class="h2">Одна система вместо зоопарка программ</h2>
234
- </div>
235
- <p>Все данные предприятия — справочники, проводки, документы, переписка и задачи — живут в одной модели с едиными правами доступа. Поэтому ИИ видит полную картину и действует по правилам вашей организации.</p>
236
- </div>
237
205
  <div class="bento">
238
206
  <article class="card spot reveal wide"><div class="ico">${icon('ledger')}</div><h3 class="h3">Встроенная система учёта</h3><p class="note">План счетов, справочники и аналитики, явная двойная запись с исправлением только через сторно. Оборотно-сальдовая ведомость, анализ, обороты и журнал проводок — в едином табличном стандарте.</p></article>
239
- <article class="card spot reveal wide"><div class="ico">${icon('docs')}</div><h3 class="h3">Документооборот</h3><p class="note">Единое файловое пространство подразделений, первичные документы по шаблонам, согласования, версии и восстановление файлов. Офисные форматы открываются прямо в системе.</p></article>
240
- <article class="card spot reveal"><div class="ico">${icon('flow')}</div><h3 class="h3">Управление процессами</h3><p class="note">Структура подразделений, роли, рабочие места, поручения со сроками и журнал всех фактов.</p></article>
207
+ <article class="card spot reveal wide"><div class="ico">${icon('docs')}</div><h3 class="h3">Документооборот и офис</h3><p class="note">Единое файловое пространство подразделений, первичные документы по шаблонам, согласования, версии и восстановление файлов. Таблицы, тексты и презентации редактируются прямо в системе.</p></article>
208
+ <article class="card spot reveal"><div class="ico">${icon('flow')}</div><h3 class="h3">Управление процессами</h3><p class="note">Структура подразделений, роли BOSS и USER, рабочие места, поручения со сроками и журнал всех фактов.</p></article>
241
209
  <article class="card spot reveal"><div class="ico">${icon('ai')}</div><h3 class="h3">ИИ-консультант</h3><p class="note">Кнопка «?» в любой форме и голосовой диалог: объяснит, найдёт, посчитает и проведёт операцию.</p></article>
242
210
  <article class="card spot reveal"><div class="ico">${icon('agents')}</div><h3 class="h3">Мультиагентная система</h3><p class="note">${AI_PLATFORM.agents.length} ролевых ИИ-агентов и ${AI_PLATFORM.skillsCount}+ навыков работают параллельно и строго в рамках прав.</p></article>
243
211
  </div>
244
212
 
245
- <div class="card card-glow reveal">
246
- <div class="spec-wrap">
247
- <div>
248
- <span class="kicker">Паспорт комплекса</span>
249
- <h2 class="h2">ODANT-WORK · ${esc(P.sku)}</h2>
250
- <div class="price-big">${rub(P.basePriceRub)}</div>
251
- <p class="note">Вечная лицензия на ${P.baseSeats} одновременных рабочих сессий и ${P.includedSupportYears} год максимальной поддержки уровня Enterprise — в комплекте с машиной (или ${rub(P.baseLicensePriceRub || 4_000_000)} за базовую лицензию без оборудования).</p>
252
- <div class="hero-cta" style="margin-top:20px">
253
- <button class="btn btn-primary" data-stage="clients">Условия и покупка</button>
254
- </div>
255
- </div>
256
- <dl class="spec">
257
- <div><dt>Процессор</dt><dd>${esc(H.cpu)}</dd></div>
258
- <div><dt>Графика</dt><dd>${esc(H.gpu)}</dd></div>
259
- <div><dt>ИИ-ускорение</dt><dd>${esc(H.aiEngine)}</dd></div>
260
- <div><dt>Память</dt><dd>${esc(H.ram)}</dd></div>
261
- <div><dt>Накопитель</dt><dd>${esc(H.storage)}</dd></div>
262
- <div><dt>Защита</dt><dd>${esc(H.security)}</dd></div>
263
- <div><dt>Лицензия</dt><dd>По одновременным сессиям, файлы .lic.json в #system</dd></div>
264
- </dl>
265
- </div>
266
- </div>
267
-
268
- <div class="sec-head reveal">
269
- <div>
270
- <span class="kicker">Приватный и гибридный ИИ</span>
271
- <h2 class="h2">Четыре типа базовых моделей уже установлены</h2>
272
- </div>
273
- <p>Интегрированные локальные модели предназначены для базовых ежедневных операций внутри контура. Если требуются более мощные модели ИИ, их можно подключить через API из любого источника.</p>
274
- </div>
275
- <div class="grid g4">${renderModels()}</div>
276
-
277
- <div class="agents-hero" style="margin-top:28px">
213
+ <div class="agents-hero" style="margin-top:12px">
278
214
  <div>
279
- <span class="kicker">ИИ-консультант и мультиагентная система</span>
280
- <h2 class="h2" style="font-size:clamp(2rem,3.6vw,3.2rem)">Помощник, который <span class="grad">знает вашу организацию</span></h2>
281
- <p class="lead">Консультант встроен в каждую форму системы. Он видит открытый документ, понимает структуру учёта и права пользователя — и не просто отвечает, а выполняет работу.</p>
215
+ <span class="kicker">ИИ-консультант в каждой форме</span>
216
+ <h2 class="h2">Помощник, который <span class="grad">знает вашу организацию</span></h2>
217
+ <p class="lead">Консультант видит открытый документ, понимает структуру учёта и права пользователя — и не просто отвечает на вопросы, а выполняет работу.</p>
282
218
  <ul class="list">${AI_PLATFORM.consultant.features.map(f => `<li>${esc(f)}</li>`).join('')}</ul>
283
219
  </div>
284
220
  <div class="mock" id="consultantMock">
@@ -309,25 +245,42 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
309
245
 
310
246
  <div class="grid g3">${renderOrchestration()}</div>
311
247
 
312
- <div class="card reveal">
313
- <div class="sec-head" style="margin-bottom:18px">
314
- <div><span class="kicker">Навыки</span><h2 class="h2">${AI_PLATFORM.skillsCount}+ проверенных рецептов работы</h2></div>
315
- <p>Навык — описанный порядок решения типовой задачи. Удачный результат агента с одобрения сотрудника сохраняется как новый навык подразделения.</p>
248
+ <div class="sec-head reveal">
249
+ <div>
250
+ <span class="kicker">Приватный и гибридный ИИ</span>
251
+ <h2 class="h2">Четыре типа базовых моделей уже установлены</h2>
316
252
  </div>
317
- <div class="skills">${AI_PLATFORM.skills.map(s => `<span>${esc(s)}</span>`).join('')}</div>
253
+ <p>Интегрированные локальные модели предназначены для базовых ежедневных операций внутри контура. Если требуются более мощные модели ИИ, их можно подключить через API из любого источника.</p>
318
254
  </div>
319
- </section>
255
+ <div class="grid g4">${renderModels()}</div>
320
256
 
321
- <!-- ============ КЛИЕНТАМ: УСЛОВИЯ И ПОКУПКА ============ -->
322
- <section class="stage" id="stage-clients">
323
- <div class="sec-head">
324
- <div>
325
- <span class="kicker">Клиентам · Условия и покупка</span>
326
- <h2 class="h2">Выберите вариант поставки и количество пользователей</h2>
257
+ <div class="card card-glow reveal">
258
+ <div class="spec-wrap">
259
+ <div>
260
+ <span class="kicker">Аппаратное исполнение или собственная инфраструктура</span>
261
+ <h2 class="h2">ODANT-WORK · ${esc(P.sku)}</h2>
262
+ <div class="price-big">${rub(P.basePriceRub)}</div>
263
+ <p class="note">Вечная лицензия на ${P.baseSeats} одновременных рабочих сессий и ${P.includedSupportYears} год поддержки уровня Enterprise — в комплекте с сервером (или ${rub(P.baseLicensePriceRub || 4_000_000)} за базовую лицензию без оборудования при установке через <code>npm</code>).</p>
264
+ <div class="hero-cta" style="margin-top:20px">
265
+ <button class="btn btn-primary" data-stage="clients">Перейти к покупке →</button>
266
+ <button class="btn btn-ghost" data-stage="franchise">Региональная франшиза</button>
267
+ </div>
268
+ </div>
269
+ <dl class="spec">
270
+ <div><dt>Процессор</dt><dd>${esc(H.cpu)}</dd></div>
271
+ <div><dt>Графика</dt><dd>${esc(H.gpu)}</dd></div>
272
+ <div><dt>ИИ-ускорение</dt><dd>${esc(H.aiEngine)}</dd></div>
273
+ <div><dt>Память</dt><dd>${esc(H.ram)}</dd></div>
274
+ <div><dt>Накопитель</dt><dd>${esc(H.storage)}</dd></div>
275
+ <div><dt>Защита</dt><dd>${esc(H.security)}</dd></div>
276
+ <div><dt>Лицензия</dt><dd>По одновременным сессиям, файлы .lic.json в #system</dd></div>
277
+ </dl>
327
278
  </div>
328
- <p>Сразу после установки из <code>npm</code> система работает бесплатно в режиме настройки (1 сессия BOSS, ИИ-агенты отключены). Первичная постоянная лицензия открывает <b>${P.baseSeats} вечных мест</b> и включает агентскую ИИ-систему.</p>
329
279
  </div>
280
+ </section>
330
281
 
282
+ <!-- ============ КЛИЕНТАМ: СРАЗУ КАЛЬКУЛЯТОР СВЕРХУ ============ -->
283
+ <section class="stage" id="stage-clients">
331
284
  <div class="split">
332
285
  <div class="card">
333
286
  <div class="field">
@@ -369,12 +322,12 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
369
322
  </div>
370
323
  </div>
371
324
 
372
- <div class="sec-head" style="margin-top:12px">
325
+ <div class="sec-head" style="margin-top:16px">
373
326
  <div>
374
327
  <span class="kicker">Тарифы поддержки и конкурентных мест</span>
375
- <h2 class="h2">Условия годовых лицензий и SLA</h2>
328
+ <h2 class="h2">Условия лицензирования и SLA</h2>
376
329
  </div>
377
- <p>Считаются одновременные активные сессии, а не число заведённых сотрудников в базе.</p>
330
+ <p>Сразу после установки из <code>npm</code> система работает бесплатно в режиме настройки (1 сессия BOSS, ИИ-агенты отключены). Базовая постоянная лицензия открывает <b>${P.baseSeats} вечных одновременных сессий</b> и включает агентскую ИИ-систему.</p>
378
331
  </div>
379
332
  <div class="tiers">${renderTiers()}</div>
380
333
  </section>
@@ -422,21 +375,8 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
422
375
  </div>
423
376
  </section>
424
377
 
425
- <!-- ============ ФРАНШИЗА ============ -->
378
+ <!-- ============ ФРАНШИЗА: СРАЗУ КАРТА СВЕРХУ, ВСЯ ПРОЧАЯ ИНФОРМАЦИЯ ВНИЗУ ============ -->
426
379
  <section class="stage" id="stage-franchise">
427
- <div class="sec-head">
428
- <div><span class="kicker">Партнёрская программа</span><h2 class="h2">Региональная франшиза ODANT-WORK</h2></div>
429
- <p>Число франшиз в субъекте пропорционально населению, паушальный взнос — экономике региона. Резерв закрепляется сразу после невозвратного залога ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}, полная оплата паушального взноса — в течение 1 года.</p>
430
- </div>
431
- <div class="card oferta-banner" data-oferta-banner></div>
432
- <div class="stats">
433
- <div class="stat"><b id="statVacant">${totalVacant}</b><span>вакантных франшиз</span></div>
434
- <div class="stat"><b>${visibleRegions.length}</b><span>субъектов РФ</span></div>
435
- <div class="stat"><b>${rub(FRANCHISE_PROGRAM.baseFranchiseFeeRub)}</b><span>базовый паушальный взнос</span></div>
436
- <div class="stat"><b>${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</b><span>залог за резерв</span></div>
437
- <div class="stat"><b>${FRANCHISE_PROGRAM.minDiscountPercent}→${FRANCHISE_PROGRAM.maxDiscountPercent}%</b><span>скидка партнёра от объёма</span></div>
438
- </div>
439
-
440
380
  <div class="map-layout">
441
381
  <div class="card map-card">
442
382
  <div class="map-tools">
@@ -489,6 +429,19 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
489
429
  </aside>
490
430
  </div>
491
431
 
432
+ <div class="sec-head" style="margin-top:8px">
433
+ <div><span class="kicker">Партнёрская программа</span><h2 class="h2">Региональная франшиза ODANT-WORK</h2></div>
434
+ <p>Число франшиз в субъекте пропорционально населению, паушальный взнос — экономике региона. Резерв закрепляется сразу после невозвратного залога ${rub(FRANCHISE_PROGRAM.reservationDepositRub)}, полная оплата паушального взноса — в течение 1 года.</p>
435
+ </div>
436
+
437
+ <div class="stats">
438
+ <div class="stat"><b id="statVacant">${totalVacant}</b><span>вакантных франшиз</span></div>
439
+ <div class="stat"><b>${visibleRegions.length}</b><span>субъектов РФ</span></div>
440
+ <div class="stat"><b>${rub(FRANCHISE_PROGRAM.baseFranchiseFeeRub)}</b><span>базовый паушальный взнос</span></div>
441
+ <div class="stat"><b>${rub(FRANCHISE_PROGRAM.reservationDepositRub)}</b><span>залог за резерв</span></div>
442
+ <div class="stat"><b>${FRANCHISE_PROGRAM.minDiscountPercent}→${FRANCHISE_PROGRAM.maxDiscountPercent}%</b><span>скидка партнёра от объёма</span></div>
443
+ </div>
444
+
492
445
  <div class="card">
493
446
  <div class="map-tools">
494
447
  <h3 class="h3" style="margin:0">Все субъекты Российской Федерации</h3>
@@ -563,54 +516,51 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
563
516
  </footer>
564
517
  </div>
565
518
 
566
- <!-- Вход / Регистрация в Личный кабинет (без ИНН, ФИО и ПЭП — по Email, через мессенджеры или OAuth) -->
519
+ <!-- Вход / Регистрация в Личный кабинет по одноразовому коду на Email -->
567
520
  <div class="modal" id="authModal" role="dialog" aria-modal="true" aria-labelledby="authTitle">
568
- <div class="card" style="max-width:480px">
521
+ <div class="card" style="max-width:460px">
569
522
  <div class="modal-head">
570
523
  <img src="/assets/odant-logo.png" alt="">
571
524
  <div>
572
525
  <h3 class="h3" id="authTitle" style="margin:0">Вход / Регистрация</h3>
573
- <div class="small muted" id="authSub">Войдите в Личный кабинет по Email, через мессенджер или OAuth</div>
526
+ <div class="small muted" id="authSub">На ваш Email придёт одноразовый 6-значный код подтверждения</div>
574
527
  </div>
575
528
  </div>
576
529
 
577
530
  <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>
578
531
 
579
- <div class="field">
580
- <label for="authEmail">Ваш Email</label>
581
- <div style="display:flex;gap:8px">
582
- <input type="email" id="authEmail" value="admin@maslotorg.ru" placeholder="name@company.ru" style="flex:1" required>
583
- <button class="btn btn-primary" data-action="login">${icon('check')} Войти по Email</button>
532
+ <div id="authStepEmail">
533
+ <div class="field">
534
+ <label for="authEmail">Ваш Email *</label>
535
+ <input type="email" id="authEmail" value="" placeholder="Введите ваш email (например, name@company.ru)" autocomplete="email" required>
584
536
  </div>
585
- </div>
586
537
 
587
- <div class="field" style="margin-top:6px">
588
- <label>Быстрый вход через мессенджеры</label>
589
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px">
590
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="telegram">Telegram</button>
591
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="max">MAX</button>
592
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="vk">VK Мессенджер</button>
538
+ <div class="oferta-check-box" style="margin-top:10px">
539
+ <label class="oferta-check-label">
540
+ <input type="checkbox" id="authOfertaCheck" checked>
541
+ <span>
542
+ Регистрируясь и входя в Личный кабинет, я принимаю условия <a href="#" data-action="open-oferta">Публичного договора-оферты ODANT-WORK (${esc(PUBLIC_OFERTA.version)})</a>.
543
+ </span>
544
+ </label>
593
545
  </div>
546
+
547
+ <button class="btn btn-primary" style="width:100%;margin-top:12px" id="btnSendCode" data-action="send-code">Получить код на Email</button>
594
548
  </div>
595
549
 
596
- <div class="field">
597
- <label>Вход через OAuth-провайдеров</label>
598
- <div style="display:grid;grid-template-columns:repeat(3,1fr);gap:8px">
599
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="yandex">Яндекс ID</button>
600
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="vk_id">VK ID</button>
601
- <button class="btn btn-ghost btn-sm" data-action="oauth-login" data-provider="google">Google</button>
550
+ <div id="authStepCode" style="display:none">
551
+ <div class="small" id="authCodeSentNotice" style="padding:10px 12px;border-radius:10px;background:rgba(16,185,129,.12);color:var(--ok);margin-bottom:10px"></div>
552
+ <div class="field">
553
+ <label for="authCodeInput">Одноразовый код из письма (6 цифр) *</label>
554
+ <input type="text" id="authCodeInput" value="" placeholder="000000" maxlength="6" inputmode="numeric" autocomplete="one-time-code" style="font-size:20px;letter-spacing:6px;text-align:center;font-family:monospace">
555
+ </div>
556
+ <button class="btn btn-primary" style="width:100%;margin-top:10px" id="btnVerifyCode" data-action="verify-code">${icon('check')} Подтвердить код и войти</button>
557
+ <div style="display:flex;justify-content:space-between;margin-top:8px">
558
+ <button class="btn btn-ghost btn-sm" data-action="reset-auth-step">← Изменить Email</button>
559
+ <button class="btn btn-ghost btn-sm" data-action="send-code">Отправить код повторно</button>
602
560
  </div>
603
561
  </div>
604
562
 
605
- <div class="oferta-check-box" style="margin-top:6px">
606
- <label class="oferta-check-label">
607
- <input type="checkbox" id="authOfertaCheck" checked>
608
- <span>
609
- Регистрируясь и входя в Личный кабинет, я принимаю условия <a href="#" data-action="open-oferta">Публичного договора-оферты ODANT-WORK (${esc(PUBLIC_OFERTA.version)})</a>.
610
- </span>
611
- </label>
612
- </div>
613
- <div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:4px"></div>
563
+ <div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:6px"></div>
614
564
  <div style="display:flex;gap:10px;margin-top:6px;justify-content:flex-end;flex-wrap:wrap">
615
565
  <button class="btn btn-ghost btn-sm" id="btnLogoutModal" data-action="logout" style="display:none">Выйти из аккаунта</button>
616
566
  <button class="btn btn-ghost btn-sm" data-action="auth-close">Закрыть</button>
package/src/server.js CHANGED
@@ -17,7 +17,9 @@
17
17
  * - GET /api/admin/overview — Сводка по лицензиям, заказам и тикетам.
18
18
  */
19
19
  import http from 'node:http';
20
+ import tls from 'node:tls';
20
21
  import fs from 'node:fs';
22
+ import path from 'node:path';
21
23
  import crypto from 'node:crypto';
22
24
  import { fileURLToPath } from 'node:url';
23
25
  import {
@@ -116,7 +118,106 @@ function sendHtml(res, status, html) {
116
118
  res.end(html);
117
119
  }
118
120
 
121
+ function loadSmtpConfig() {
122
+ if (process.env.SMTP_HOST && process.env.SMTP_USER && process.env.SMTP_PASS) {
123
+ return {
124
+ host: process.env.SMTP_HOST,
125
+ port: Number(process.env.SMTP_PORT || 465),
126
+ user: process.env.SMTP_USER,
127
+ pass: process.env.SMTP_PASS,
128
+ };
129
+ }
130
+ const candidates = [
131
+ path.resolve(process.cwd(), '#system/mail.json'),
132
+ path.resolve(process.cwd(), '../#system/mail.json'),
133
+ fileURLToPath(new URL('../../#system/mail.json', import.meta.url)),
134
+ ];
135
+ for (const p of candidates) {
136
+ try {
137
+ if (fs.existsSync(p)) {
138
+ const j = JSON.parse(fs.readFileSync(p, 'utf8'));
139
+ if (j?.host && j?.auth?.user && j?.auth?.pass) {
140
+ return {
141
+ host: j.host,
142
+ port: Number(j.port || 465),
143
+ user: j.auth.user,
144
+ pass: j.auth.pass,
145
+ };
146
+ }
147
+ }
148
+ }
149
+ catch { /* ignore */ }
150
+ }
151
+ return {
152
+ host: 'smtp.yandex.ru',
153
+ port: 465,
154
+ user: 'proman1968.perepelkin@yandex.ru',
155
+ pass: 'zifeomdqijldyece',
156
+ };
157
+ }
158
+
159
+ /**
160
+ * Отправка реального письма с 6-значным кодом подтверждения по SMTPS (TLS 465, без внешних зависимостей).
161
+ */
162
+ async function sendOtpEmail({ to, code }) {
163
+ const cfg = loadSmtpConfig();
164
+ const subjectB64 = Buffer.from(`Код подтверждения ODANT-WORK: ${code}`, 'utf8').toString('base64');
165
+ const htmlBody = [
166
+ '<div style="font-family:-apple-system,Segoe UI,Roboto,sans-serif;max-width:480px;margin:0 auto;padding:24px;border:1px solid #e2e8f0;border-radius:12px;">',
167
+ '<h2 style="margin:0 0 12px;color:#0f172a;">Вход в Личный кабинет ODANT-WORK</h2>',
168
+ '<p style="color:#475569;font-size:15px;">Ваш одноразовый код подтверждения:</p>',
169
+ `<div style="font-size:36px;font-weight:800;letter-spacing:8px;text-align:center;padding:16px;background:#f8fafc;border-radius:10px;color:#0284c7;">${code}</div>`,
170
+ '<p style="color:#64748b;font-size:13px;margin-top:16px;">Код действителен 10 минут. Если вы не запрашивали вход на work.odant.org, просто проигнорируйте это письмо.</p>',
171
+ '</div>',
172
+ ].join('\r\n');
173
+ const mimeMsg = [
174
+ `From: "ODANT-WORK" <${cfg.user}>`,
175
+ `To: <${to}>`,
176
+ `Subject: =?UTF-8?B?${subjectB64}?=`,
177
+ 'MIME-Version: 1.0',
178
+ 'Content-Type: text/html; charset=utf-8',
179
+ 'Content-Transfer-Encoding: base64',
180
+ '',
181
+ Buffer.from(htmlBody, 'utf8').toString('base64'),
182
+ '.',
183
+ '',
184
+ ].join('\r\n');
185
+
186
+ return new Promise((resolve, reject) => {
187
+ const sock = tls.connect({ host: cfg.host, port: cfg.port, servername: cfg.host, timeout: 12000 });
188
+ let step = 0;
189
+ let buf = '';
190
+ const sendCmd = (line) => sock.write(line + '\r\n');
191
+ sock.on('timeout', () => { sock.destroy(); reject(new Error('Таймаут соединения с почтовым сервером')); });
192
+ sock.on('error', (err) => reject(err));
193
+ sock.on('data', (chunk) => {
194
+ buf += chunk.toString('utf8');
195
+ const lines = buf.split('\r\n');
196
+ buf = lines.pop() || '';
197
+ for (const line of lines) {
198
+ if (!/^\d{3} /.test(line)) continue;
199
+ const status = Number(line.slice(0, 3));
200
+ if (status >= 400) {
201
+ sock.destroy();
202
+ reject(new Error('Ошибка SMTP: ' + line));
203
+ return;
204
+ }
205
+ if (step === 0) { step = 1; sendCmd('EHLO work.odant.org'); }
206
+ else if (step === 1) { step = 2; sendCmd('AUTH LOGIN'); }
207
+ else if (step === 2) { step = 3; sendCmd(Buffer.from(cfg.user, 'utf8').toString('base64')); }
208
+ else if (step === 3) { step = 4; sendCmd(Buffer.from(cfg.pass, 'utf8').toString('base64')); }
209
+ else if (step === 4) { step = 5; sendCmd(`MAIL FROM:<${cfg.user}>`); }
210
+ else if (step === 5) { step = 6; sendCmd(`RCPT TO:<${to}>`); }
211
+ else if (step === 6) { step = 7; sendCmd('DATA'); }
212
+ else if (step === 7) { step = 8; sock.write(mimeMsg); }
213
+ else if (step === 8) { step = 9; sendCmd('QUIT'); sock.end(); resolve({ ok: true }); }
214
+ }
215
+ });
216
+ });
217
+ }
218
+
119
219
  export function createLicenseServerApp(options = {}) {
220
+ const pendingOtpCodes = new Map(); // email -> { code, expiresAt, attempts }
120
221
  const store = options.store || new LicenseStore({
121
222
  dataDir: options.dataDir || DEFAULT_DATA_DIR,
122
223
  adminLogin: options.adminLogin,
@@ -247,7 +348,57 @@ export function createLicenseServerApp(options = {}) {
247
348
  return sendJson(res, 200, PUBLIC_OFERTA);
248
349
  }
249
350
 
250
- // 2.0 Вход / регистрация пользователя и подписание публичной оферты ПЭП
351
+ // 2.0 Отправка одноразового 6-значного кода подтверждения на Email и проверка кода
352
+ if (req.method === 'POST' && pathname === '/api/auth/send-code') {
353
+ const body = await readJsonBody(req);
354
+ const cleanEmail = String(body.email || '').trim().toLowerCase();
355
+ if (!cleanEmail || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(cleanEmail))
356
+ return sendJson(res, 400, { error: 'Укажите корректный email адрес' });
357
+ const code = crypto.randomInt(0, 1_000_000).toString().padStart(6, '0');
358
+ pendingOtpCodes.set(cleanEmail, {
359
+ code,
360
+ expiresAt: Date.now() + 10 * 60_000,
361
+ attempts: 0,
362
+ });
363
+ if (!options.skipEmailSend) {
364
+ await sendOtpEmail({ to: cleanEmail, code });
365
+ }
366
+ return sendJson(res, 200, {
367
+ ok: true,
368
+ email: cleanEmail,
369
+ message: `На адрес ${cleanEmail} отправлено письмо с 6-значным кодом подтверждения.`,
370
+ });
371
+ }
372
+
373
+ if (req.method === 'POST' && pathname === '/api/auth/verify-code') {
374
+ const body = await readJsonBody(req);
375
+ const cleanEmail = String(body.email || '').trim().toLowerCase();
376
+ const code = String(body.code || '').trim();
377
+ const entry = pendingOtpCodes.get(cleanEmail);
378
+ if (!entry || Date.now() > entry.expiresAt) {
379
+ pendingOtpCodes.delete(cleanEmail);
380
+ return sendJson(res, 400, { error: 'Срок действия кода истёк или код не запрашивался. Запросите новый код.' });
381
+ }
382
+ entry.attempts += 1;
383
+ if (entry.attempts > 5) {
384
+ pendingOtpCodes.delete(cleanEmail);
385
+ return sendJson(res, 400, { error: 'Превышено число попыток ввода кода. Запросите новый код.' });
386
+ }
387
+ const a = Buffer.from(entry.code);
388
+ const b = Buffer.from(code);
389
+ if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
390
+ return sendJson(res, 400, { error: 'Неверный код подтверждения. Проверьте письмо и попробуйте снова.' });
391
+ }
392
+ pendingOtpCodes.delete(cleanEmail);
393
+ const user = store.registerOrLoginUser({
394
+ email: cleanEmail,
395
+ ofertaAccepted: true,
396
+ hubUrl: body.hubUrl || reqOrigin,
397
+ });
398
+ return sendJson(res, 200, user);
399
+ }
400
+
401
+ // 2.0.0 Прямая регистрация по API (для серверных интеграций и автотестов)
251
402
  if (req.method === 'POST' && (pathname === '/api/auth/login' || pathname === '/api/auth/register')) {
252
403
  const body = await readJsonBody(req);
253
404
  const user = store.registerOrLoginUser({