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 +61 -18
- package/package.json +1 -1
- package/src/landing.js +85 -135
- package/src/server.js +152 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
185
|
-
|
|
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/
|
|
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 '
|
|
1223
|
-
case '
|
|
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) => {
|
|
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
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="
|
|
197
|
+
<div class="sec-head" style="margin-top:4px">
|
|
198
198
|
<div>
|
|
199
|
-
<span class="
|
|
200
|
-
<h1 class="
|
|
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 — учёт, документооборот, процессы и <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"
|
|
240
|
-
<article class="card spot reveal"><div class="ico">${icon('flow')}</div><h3 class="h3">Управление процессами</h3><p class="note">Структура подразделений,
|
|
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="
|
|
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">ИИ-консультант
|
|
280
|
-
<h2 class="h2"
|
|
281
|
-
<p class="lead">Консультант
|
|
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="
|
|
313
|
-
<div
|
|
314
|
-
<
|
|
315
|
-
<
|
|
248
|
+
<div class="sec-head reveal">
|
|
249
|
+
<div>
|
|
250
|
+
<span class="kicker">Приватный и гибридный ИИ</span>
|
|
251
|
+
<h2 class="h2">Четыре типа базовых моделей уже установлены</h2>
|
|
316
252
|
</div>
|
|
317
|
-
<
|
|
253
|
+
<p>Интегрированные локальные модели предназначены для базовых ежедневных операций внутри контура. Если требуются более мощные модели ИИ, их можно подключить через API из любого источника.</p>
|
|
318
254
|
</div>
|
|
319
|
-
|
|
255
|
+
<div class="grid g4">${renderModels()}</div>
|
|
320
256
|
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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:
|
|
325
|
+
<div class="sec-head" style="margin-top:16px">
|
|
373
326
|
<div>
|
|
374
327
|
<span class="kicker">Тарифы поддержки и конкурентных мест</span>
|
|
375
|
-
<h2 class="h2">Условия
|
|
328
|
+
<h2 class="h2">Условия лицензирования и SLA</h2>
|
|
376
329
|
</div>
|
|
377
|
-
<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
|
-
<!-- Вход / Регистрация в Личный кабинет
|
|
519
|
+
<!-- Вход / Регистрация в Личный кабинет по одноразовому коду на Email -->
|
|
567
520
|
<div class="modal" id="authModal" role="dialog" aria-modal="true" aria-labelledby="authTitle">
|
|
568
|
-
<div class="card" style="max-width:
|
|
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"
|
|
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
|
|
580
|
-
<
|
|
581
|
-
|
|
582
|
-
<input type="email" id="authEmail" value="
|
|
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
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
|
597
|
-
<
|
|
598
|
-
<div
|
|
599
|
-
<
|
|
600
|
-
<
|
|
601
|
-
|
|
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="
|
|
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({
|