work-license-server 1.0.16 → 1.0.18
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/admin.js +40 -9
- package/assets/landing.js +146 -26
- package/package.json +1 -1
- package/src/admin.js +25 -1
- package/src/crypto.js +72 -0
- package/src/landing.js +14 -1
- package/src/server.js +20 -1
- package/src/store.js +56 -10
package/assets/admin.js
CHANGED
|
@@ -16,28 +16,48 @@
|
|
|
16
16
|
let activeTab = 'servers';
|
|
17
17
|
let admCountdownTimer = null;
|
|
18
18
|
|
|
19
|
+
function readCookieSession() {
|
|
20
|
+
try {
|
|
21
|
+
const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
|
|
22
|
+
return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
|
|
23
|
+
}
|
|
24
|
+
catch { return ''; }
|
|
25
|
+
}
|
|
26
|
+
|
|
19
27
|
function getStoredToken() {
|
|
20
28
|
try {
|
|
21
|
-
return
|
|
29
|
+
return localStorage.getItem('odant_work_session')
|
|
22
30
|
|| localStorage.getItem(TOKEN_KEY)
|
|
23
|
-
||
|
|
31
|
+
|| sessionStorage.getItem(TOKEN_KEY)
|
|
32
|
+
|| sessionStorage.getItem('odant_work_session')
|
|
33
|
+
|| readCookieSession()
|
|
24
34
|
|| '';
|
|
25
35
|
}
|
|
26
|
-
catch { return
|
|
36
|
+
catch { return readCookieSession(); }
|
|
27
37
|
}
|
|
28
|
-
function setStoredToken(tok) {
|
|
38
|
+
function setStoredToken(tok, email = '', userObj = null) {
|
|
29
39
|
adminToken = tok || '';
|
|
30
40
|
try {
|
|
31
41
|
if (tok) {
|
|
32
42
|
sessionStorage.setItem(TOKEN_KEY, tok);
|
|
33
43
|
localStorage.setItem(TOKEN_KEY, tok);
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
44
|
+
localStorage.setItem('odant_work_session', tok);
|
|
45
|
+
sessionStorage.setItem('odant_work_session', tok);
|
|
46
|
+
document.cookie = `odant_work_session=${encodeURIComponent(tok)}; path=/; max-age=31536000; SameSite=Lax`;
|
|
37
47
|
}
|
|
38
48
|
else {
|
|
39
49
|
sessionStorage.removeItem(TOKEN_KEY);
|
|
40
50
|
localStorage.removeItem(TOKEN_KEY);
|
|
51
|
+
localStorage.removeItem('odant_work_session');
|
|
52
|
+
localStorage.removeItem('odant_work_user_cache');
|
|
53
|
+
sessionStorage.removeItem('odant_work_session');
|
|
54
|
+
document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
|
|
55
|
+
}
|
|
56
|
+
if (email) {
|
|
57
|
+
localStorage.setItem('odant_work_email', email);
|
|
58
|
+
}
|
|
59
|
+
if (userObj && typeof userObj === 'object') {
|
|
60
|
+
localStorage.setItem('odant_work_user_cache', JSON.stringify(userObj));
|
|
41
61
|
}
|
|
42
62
|
}
|
|
43
63
|
catch { /* приватный режим */ }
|
|
@@ -78,6 +98,13 @@
|
|
|
78
98
|
$('#adminLoginScreen').style.display = '';
|
|
79
99
|
$('#adminAppShell').style.display = 'none';
|
|
80
100
|
if ($('#admLoginError')) $('#admLoginError').textContent = errMsg;
|
|
101
|
+
try {
|
|
102
|
+
const savedEmail = localStorage.getItem('odant_work_email') || '';
|
|
103
|
+
if (savedEmail && $('#admLoginEmail') && !$('#admLoginEmail').value) {
|
|
104
|
+
$('#admLoginEmail').value = savedEmail;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch { /* ignore */ }
|
|
81
108
|
setTimeout(() => $('#admLoginEmail')?.focus(), 50);
|
|
82
109
|
}
|
|
83
110
|
|
|
@@ -102,6 +129,7 @@
|
|
|
102
129
|
$('#adminLoginScreen').style.display = 'none';
|
|
103
130
|
$('#adminAppShell').style.display = '';
|
|
104
131
|
if ($('#admUserLabel')) $('#admUserLabel').textContent = login;
|
|
132
|
+
if ($('#admProfileEmail')) $('#admProfileEmail').textContent = login;
|
|
105
133
|
requestAnimationFrame(moveIndicator);
|
|
106
134
|
}
|
|
107
135
|
|
|
@@ -165,7 +193,7 @@
|
|
|
165
193
|
if (!user.isAdmin) {
|
|
166
194
|
throw new Error(`Аккаунт ${user.email} не обладает правами администратора (BOSS) сервера лицензирования.`);
|
|
167
195
|
}
|
|
168
|
-
setStoredToken(user.sessionKey);
|
|
196
|
+
setStoredToken(user.sessionKey, user.email, user);
|
|
169
197
|
resetAdmStep();
|
|
170
198
|
showAdminApp(user.email);
|
|
171
199
|
await refreshRegistry();
|
|
@@ -179,9 +207,9 @@
|
|
|
179
207
|
}
|
|
180
208
|
|
|
181
209
|
async function logoutAdmin() {
|
|
210
|
+
$('#admProfileModal')?.classList.remove('open');
|
|
182
211
|
try { await api('/api/admin/logout', {}); } catch { /* ignore */ }
|
|
183
212
|
setStoredToken('');
|
|
184
|
-
try { localStorage.removeItem('odant_work_session'); } catch { /* ignore */ }
|
|
185
213
|
resetAdmStep();
|
|
186
214
|
showLoginGate('');
|
|
187
215
|
}
|
|
@@ -196,6 +224,7 @@
|
|
|
196
224
|
try {
|
|
197
225
|
const me = await api('/api/admin/me');
|
|
198
226
|
if (me.authenticated) {
|
|
227
|
+
setStoredToken(saved, me.email || me.login || '');
|
|
199
228
|
showAdminApp(me.login || 'admin');
|
|
200
229
|
await refreshRegistry();
|
|
201
230
|
}
|
|
@@ -900,6 +929,8 @@
|
|
|
900
929
|
case 'adm-send-code': sendAdminOtpCode(); break;
|
|
901
930
|
case 'adm-verify-code': verifyAdminOtpCode(); break;
|
|
902
931
|
case 'adm-reset-step': resetAdmStep(); break;
|
|
932
|
+
case 'open-adm-profile': $('#admProfileModal')?.classList.add('open'); break;
|
|
933
|
+
case 'close-adm-profile': $('#admProfileModal')?.classList.remove('open'); break;
|
|
903
934
|
case 'admin-logout': logoutAdmin(); break;
|
|
904
935
|
case 'registry': refreshRegistry(); break;
|
|
905
936
|
case 'open-create-server-modal': $('#admCreateServerModal')?.classList.add('open'); break;
|
package/assets/landing.js
CHANGED
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
let REGIONS = (D.regions || []).slice();
|
|
15
15
|
let currentUser = null;
|
|
16
16
|
let currentStage = 'product';
|
|
17
|
+
let requestedInitialStage = '';
|
|
17
18
|
let selectedCode = null;
|
|
18
19
|
let pendingAuthAction = null;
|
|
19
20
|
|
|
@@ -129,12 +130,95 @@
|
|
|
129
130
|
card.style.setProperty('--my', (e.clientY - r.top) + 'px');
|
|
130
131
|
}, { passive: true });
|
|
131
132
|
|
|
132
|
-
/* ================= Вход / Регистрация
|
|
133
|
+
/* ================= Вход / Регистрация и постоянная сессия в браузере ================= */
|
|
134
|
+
function readCookieSession() {
|
|
135
|
+
try {
|
|
136
|
+
const m = String(document.cookie || '').match(/(?:^|;\s*)odant_work_session=([^;]+)/);
|
|
137
|
+
return m?.[1] ? decodeURIComponent(m[1].trim()) : '';
|
|
138
|
+
}
|
|
139
|
+
catch { return ''; }
|
|
140
|
+
}
|
|
141
|
+
function persistSessionInBrowser(sessionKey = '', email = '', isAdmin = false, userObj = null) {
|
|
142
|
+
try {
|
|
143
|
+
if (sessionKey) {
|
|
144
|
+
localStorage.setItem('odant_work_session', sessionKey);
|
|
145
|
+
sessionStorage.setItem('odant_work_session', sessionKey);
|
|
146
|
+
if (isAdmin) {
|
|
147
|
+
localStorage.setItem('odant_work_admin_token', sessionKey);
|
|
148
|
+
sessionStorage.setItem('odant_work_admin_token', sessionKey);
|
|
149
|
+
}
|
|
150
|
+
document.cookie = `odant_work_session=${encodeURIComponent(sessionKey)}; path=/; max-age=31536000; SameSite=Lax`;
|
|
151
|
+
}
|
|
152
|
+
if (email) {
|
|
153
|
+
localStorage.setItem('odant_work_email', email);
|
|
154
|
+
}
|
|
155
|
+
if (userObj && typeof userObj === 'object') {
|
|
156
|
+
localStorage.setItem('odant_work_user_cache', JSON.stringify(userObj));
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
catch { /* приватный режим */ }
|
|
160
|
+
}
|
|
161
|
+
function clearSessionFromBrowser() {
|
|
162
|
+
try {
|
|
163
|
+
localStorage.removeItem('odant_work_session');
|
|
164
|
+
localStorage.removeItem('odant_work_admin_token');
|
|
165
|
+
localStorage.removeItem('odant_work_user_cache');
|
|
166
|
+
sessionStorage.removeItem('odant_work_session');
|
|
167
|
+
sessionStorage.removeItem('odant_work_admin_token');
|
|
168
|
+
document.cookie = 'odant_work_session=; path=/; max-age=0; SameSite=Lax';
|
|
169
|
+
}
|
|
170
|
+
catch { /* ignore */ }
|
|
171
|
+
}
|
|
172
|
+
function hydrateCachedUser() {
|
|
173
|
+
try {
|
|
174
|
+
const key = localStorage.getItem('odant_work_session')
|
|
175
|
+
|| sessionStorage.getItem('odant_work_session')
|
|
176
|
+
|| localStorage.getItem('odant_work_admin_token')
|
|
177
|
+
|| readCookieSession()
|
|
178
|
+
|| '';
|
|
179
|
+
if (!key) return;
|
|
180
|
+
const raw = localStorage.getItem('odant_work_user_cache');
|
|
181
|
+
if (raw) {
|
|
182
|
+
const cached = JSON.parse(raw);
|
|
183
|
+
if (cached?.email) {
|
|
184
|
+
currentUser = { ...cached, sessionKey: key, ofertaAccepted: cached.ofertaAccepted !== false };
|
|
185
|
+
onAuthenticated();
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Если есть подписанный токен wks_1.<payload>.<sig>, декодируем payload синхронно до ответа /api/auth/me
|
|
190
|
+
if (key.startsWith('wks_1.')) {
|
|
191
|
+
const parts = key.split('.');
|
|
192
|
+
if (parts[1]) {
|
|
193
|
+
const b64 = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
|
194
|
+
const json = decodeURIComponent(escape(atob(b64)));
|
|
195
|
+
const payload = JSON.parse(json);
|
|
196
|
+
if (payload?.email) {
|
|
197
|
+
currentUser = {
|
|
198
|
+
userId: payload.userId || '',
|
|
199
|
+
email: payload.email,
|
|
200
|
+
name: payload.name || payload.email,
|
|
201
|
+
companyName: payload.companyName || '',
|
|
202
|
+
inn: payload.inn || '',
|
|
203
|
+
isAdmin: Boolean(payload.isAdmin),
|
|
204
|
+
ofertaAccepted: true,
|
|
205
|
+
sessionKey: key,
|
|
206
|
+
servers: [],
|
|
207
|
+
franchises: [],
|
|
208
|
+
};
|
|
209
|
+
onAuthenticated();
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
catch { /* ignore */ }
|
|
215
|
+
}
|
|
216
|
+
|
|
133
217
|
function openAuth(hintMsg = '', intentTitle = '') {
|
|
134
218
|
if ($('#authErrorMsg')) $('#authErrorMsg').textContent = hintMsg || '';
|
|
135
219
|
const intentBox = $('#authIntentNotice');
|
|
136
220
|
if (intentBox) {
|
|
137
|
-
if (intentTitle) {
|
|
221
|
+
if (intentTitle && !currentUser) {
|
|
138
222
|
intentBox.style.display = '';
|
|
139
223
|
intentBox.innerHTML = `Выбранная позиция: <b>${esc(intentTitle)}</b>.<br>После входа она автоматически появится как заказ в вашем Личном кабинете.`;
|
|
140
224
|
}
|
|
@@ -143,9 +227,36 @@
|
|
|
143
227
|
intentBox.textContent = '';
|
|
144
228
|
}
|
|
145
229
|
}
|
|
146
|
-
if (
|
|
230
|
+
if (currentUser) {
|
|
231
|
+
if ($('#authTitle')) $('#authTitle').textContent = 'Ваш аккаунт';
|
|
232
|
+
if ($('#authSub')) $('#authSub').textContent = 'Сессия сохранена в браузере';
|
|
233
|
+
if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = '';
|
|
234
|
+
if ($('#authStepEmail')) $('#authStepEmail').style.display = 'none';
|
|
235
|
+
if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
|
|
236
|
+
if ($('#authLoggedEmail')) $('#authLoggedEmail').textContent = currentUser.email || '';
|
|
237
|
+
if ($('#authLoggedRoleBadge')) {
|
|
238
|
+
$('#authLoggedRoleBadge').innerHTML = currentUser.isAdmin
|
|
239
|
+
? '<span class="tag ok">BOSS · Главный администратор</span>'
|
|
240
|
+
: '<span class="tag">Клиент ODANT-WORK</span>';
|
|
241
|
+
}
|
|
242
|
+
if ($('#btnGoAdminModal')) $('#btnGoAdminModal').style.display = currentUser.isAdmin ? '' : 'none';
|
|
243
|
+
}
|
|
244
|
+
else {
|
|
245
|
+
if ($('#authTitle')) $('#authTitle').textContent = 'Вход / Регистрация';
|
|
246
|
+
if ($('#authSub')) $('#authSub').textContent = 'На ваш Email придёт одноразовый 6-значный код подтверждения';
|
|
247
|
+
if ($('#authStepLoggedIn')) $('#authStepLoggedIn').style.display = 'none';
|
|
248
|
+
if ($('#authStepEmail')) $('#authStepEmail').style.display = '';
|
|
249
|
+
if ($('#authStepCode')) $('#authStepCode').style.display = 'none';
|
|
250
|
+
try {
|
|
251
|
+
const savedEmail = localStorage.getItem('odant_work_email') || '';
|
|
252
|
+
if (savedEmail && $('#authEmail') && !$('#authEmail').value) {
|
|
253
|
+
$('#authEmail').value = savedEmail;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
catch { /* ignore */ }
|
|
257
|
+
setTimeout(() => $('#authEmail')?.focus(), 50);
|
|
258
|
+
}
|
|
147
259
|
$('#authModal')?.classList.add('open');
|
|
148
|
-
setTimeout(() => $('#authEmail')?.focus(), 50);
|
|
149
260
|
}
|
|
150
261
|
function closeAuth() {
|
|
151
262
|
$('#authModal')?.classList.remove('open');
|
|
@@ -224,7 +335,7 @@
|
|
|
224
335
|
try {
|
|
225
336
|
const data = await api('/api/auth/verify-code', { email, code });
|
|
226
337
|
currentUser = data;
|
|
227
|
-
|
|
338
|
+
persistSessionInBrowser(data.sessionKey, data.email, data.isAdmin, data);
|
|
228
339
|
resetAuthStep();
|
|
229
340
|
onAuthenticated();
|
|
230
341
|
closeAuth();
|
|
@@ -245,18 +356,14 @@
|
|
|
245
356
|
}
|
|
246
357
|
}
|
|
247
358
|
|
|
248
|
-
function performLogout() {
|
|
359
|
+
async function performLogout() {
|
|
360
|
+
try { await api('/api/auth/logout', {}); } catch { /* ignore */ }
|
|
249
361
|
currentUser = null;
|
|
250
362
|
pendingAuthAction = null;
|
|
251
|
-
|
|
252
|
-
localStorage.removeItem('odant_work_session');
|
|
253
|
-
localStorage.removeItem('odant_work_admin_token');
|
|
254
|
-
sessionStorage.removeItem('odant_work_admin_token');
|
|
255
|
-
} catch { /* ignore */ }
|
|
363
|
+
clearSessionFromBrowser();
|
|
256
364
|
if ($('#tileAddons')) $('#tileAddons').style.display = 'none';
|
|
257
365
|
if ($('#tileAdmin')) $('#tileAdmin').style.display = 'none';
|
|
258
366
|
if ($('#btnAuthText')) $('#btnAuthText').textContent = 'Войти / Регистрация';
|
|
259
|
-
if ($('#btnLogoutModal')) $('#btnLogoutModal').style.display = 'none';
|
|
260
367
|
closeAuth();
|
|
261
368
|
if (currentStage === 'addons') switchStage('product');
|
|
262
369
|
moveIndicator();
|
|
@@ -264,17 +371,28 @@
|
|
|
264
371
|
|
|
265
372
|
async function restoreSession() {
|
|
266
373
|
let key = '';
|
|
267
|
-
try {
|
|
374
|
+
try {
|
|
375
|
+
key = localStorage.getItem('odant_work_session')
|
|
376
|
+
|| sessionStorage.getItem('odant_work_session')
|
|
377
|
+
|| localStorage.getItem('odant_work_admin_token')
|
|
378
|
+
|| readCookieSession()
|
|
379
|
+
|| '';
|
|
380
|
+
}
|
|
381
|
+
catch { key = readCookieSession(); }
|
|
268
382
|
if (!key) return;
|
|
269
383
|
try {
|
|
270
384
|
const data = await api('/api/auth/me?sessionKey=' + encodeURIComponent(key));
|
|
271
385
|
if (data.authenticated && data.user) {
|
|
272
|
-
currentUser = { ...data.user, sessionKey: key };
|
|
386
|
+
currentUser = { ...data.user, sessionKey: data.user.sessionKey || key };
|
|
387
|
+
persistSessionInBrowser(currentUser.sessionKey, currentUser.email, currentUser.isAdmin, currentUser);
|
|
273
388
|
onAuthenticated();
|
|
274
|
-
if (location.hash === '#addons' && isUserReadyForOrders())
|
|
389
|
+
if ((requestedInitialStage === 'addons' || location.hash === '#addons') && isUserReadyForOrders()) {
|
|
390
|
+
requestedInitialStage = '';
|
|
391
|
+
switchStage('addons', { scroll: false });
|
|
392
|
+
}
|
|
275
393
|
}
|
|
276
394
|
}
|
|
277
|
-
catch { /* сессия
|
|
395
|
+
catch { /* сессия отозвана */ }
|
|
278
396
|
}
|
|
279
397
|
|
|
280
398
|
let franchiseCountdownTimer = null;
|
|
@@ -1688,14 +1806,12 @@
|
|
|
1688
1806
|
if (el.dataset.stage && el !== document.body) { e.preventDefault(); switchStage(el.dataset.stage); return; }
|
|
1689
1807
|
switch (el.dataset.action) {
|
|
1690
1808
|
case 'auth':
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
openAuth();
|
|
1698
|
-
}
|
|
1809
|
+
pendingAuthAction = null;
|
|
1810
|
+
openAuth();
|
|
1811
|
+
break;
|
|
1812
|
+
case 'go-cabinet':
|
|
1813
|
+
closeAuth();
|
|
1814
|
+
switchStage('addons');
|
|
1699
1815
|
break;
|
|
1700
1816
|
case 'auth-close': closeAuth(); break;
|
|
1701
1817
|
case 'send-code': sendAuthCode(); break;
|
|
@@ -1770,14 +1886,18 @@
|
|
|
1770
1886
|
|
|
1771
1887
|
function start() {
|
|
1772
1888
|
applyTheme(document.documentElement.getAttribute('data-theme') || 'dark');
|
|
1889
|
+
requestedInitialStage = D.initialStage || location.hash.slice(1);
|
|
1890
|
+
hydrateCachedUser();
|
|
1773
1891
|
bind();
|
|
1774
1892
|
renderOrbit();
|
|
1775
1893
|
recalcBaseOrder();
|
|
1776
1894
|
recalcPartner();
|
|
1777
1895
|
renderRegionsTable();
|
|
1778
1896
|
initBackdrop();
|
|
1779
|
-
const
|
|
1780
|
-
|
|
1897
|
+
const targetStage = (requestedInitialStage === 'addons' && isUserReadyForOrders())
|
|
1898
|
+
? 'addons'
|
|
1899
|
+
: (requestedInitialStage && requestedInitialStage !== 'addons' && requestedInitialStage !== 'admin' ? requestedInitialStage : 'product');
|
|
1900
|
+
switchStage(targetStage, { scroll: false });
|
|
1781
1901
|
if (document.fonts?.ready) document.fonts.ready.then(moveIndicator);
|
|
1782
1902
|
initMap().then(() => selectRegion(selectedCode || 'RU-SVE'));
|
|
1783
1903
|
restoreSession();
|
package/package.json
CHANGED
package/src/admin.js
CHANGED
|
@@ -115,7 +115,7 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
115
115
|
|
|
116
116
|
<div class="top-actions">
|
|
117
117
|
<button class="icon-btn" id="btnTheme" title="Светлая / тёмная тема" aria-label="Переключить тему">${icon('sun')}</button>
|
|
118
|
-
<button class="btn btn-primary btn-sm" data-action="
|
|
118
|
+
<button class="btn btn-primary btn-sm" data-action="open-adm-profile" title="Аккаунт администратора">${icon('user')}<span id="admUserLabel">BOSS</span></button>
|
|
119
119
|
</div>
|
|
120
120
|
</header>
|
|
121
121
|
|
|
@@ -402,6 +402,30 @@ export function renderAdminPage({ publicKey = '', assetVersion = '' } = {}) {
|
|
|
402
402
|
</div>
|
|
403
403
|
</div>
|
|
404
404
|
|
|
405
|
+
<!-- Модальное окно: Аккаунт администратора (защита от случайного выхода) -->
|
|
406
|
+
<div class="modal" id="admProfileModal" role="dialog" aria-modal="true">
|
|
407
|
+
<div class="card" style="max-width:440px">
|
|
408
|
+
<div class="modal-head">
|
|
409
|
+
<img src="/assets/odant-logo.png" alt="">
|
|
410
|
+
<div>
|
|
411
|
+
<h3 class="h3" style="margin:0">Аккаунт администратора</h3>
|
|
412
|
+
<div class="small muted">Сессия активна и сохранена в браузере</div>
|
|
413
|
+
</div>
|
|
414
|
+
<button class="icon-btn" data-action="close-adm-profile" aria-label="Закрыть">${icon('close')}</button>
|
|
415
|
+
</div>
|
|
416
|
+
<div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
|
|
417
|
+
<div class="small muted">Вы вошли как</div>
|
|
418
|
+
<div style="font-size:16px;font-weight:700;margin-top:2px" id="admProfileEmail">—</div>
|
|
419
|
+
<div style="margin-top:6px"><span class="tag ok">BOSS · Главный администратор</span></div>
|
|
420
|
+
</div>
|
|
421
|
+
<div style="display:flex;flex-direction:column;gap:8px">
|
|
422
|
+
<a class="btn btn-primary" href="/#addons" style="width:100%;text-decoration:none">Перейти в Личный кабинет</a>
|
|
423
|
+
<button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="admin-logout">Выйти из аккаунта</button>
|
|
424
|
+
<button type="button" class="btn btn-ghost btn-sm" style="width:100%" data-action="close-adm-profile">Закрыть</button>
|
|
425
|
+
</div>
|
|
426
|
+
</div>
|
|
427
|
+
</div>
|
|
428
|
+
|
|
405
429
|
<script>window.__ODANT_ADMIN__ = ${jsonForScript(adminData)};</script>
|
|
406
430
|
<script src="/assets/admin.js${v}" defer></script>
|
|
407
431
|
</body>
|
package/src/crypto.js
CHANGED
|
@@ -381,3 +381,75 @@ export function verifyOwnerAccessToken(tokenStr = '', trustedPublicKeyB64 = '')
|
|
|
381
381
|
return false;
|
|
382
382
|
}
|
|
383
383
|
}
|
|
384
|
+
|
|
385
|
+
export const SESSION_TOKEN_PREFIX = 'wks_1';
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Сгенерировать бессрочный подписанный токен сессии браузера (wks_1.<payload_b64url>.<sig_b64url>),
|
|
389
|
+
* который не сбрасывается при перезапуске/обновлении контейнера сервера, пока пользователь сам не нажал «Выйти».
|
|
390
|
+
*/
|
|
391
|
+
export function createSessionToken({
|
|
392
|
+
userId = '',
|
|
393
|
+
email = '',
|
|
394
|
+
name = '',
|
|
395
|
+
companyName = '',
|
|
396
|
+
inn = '',
|
|
397
|
+
isAdmin = false,
|
|
398
|
+
issuedAt = Date.now(),
|
|
399
|
+
} = {}, authorityKeys) {
|
|
400
|
+
const payloadObj = {
|
|
401
|
+
v: 1,
|
|
402
|
+
userId: String(userId || ''),
|
|
403
|
+
email: String(email || '').toLowerCase(),
|
|
404
|
+
...(name ? { name: String(name) } : {}),
|
|
405
|
+
...(companyName ? { companyName: String(companyName) } : {}),
|
|
406
|
+
...(inn ? { inn: String(inn) } : {}),
|
|
407
|
+
isAdmin: Boolean(isAdmin),
|
|
408
|
+
issuedAt: Number(issuedAt || Date.now()),
|
|
409
|
+
};
|
|
410
|
+
const payloadB64 = Buffer.from(JSON.stringify(payloadObj), 'utf8').toString('base64url');
|
|
411
|
+
let sigB64 = '';
|
|
412
|
+
if (authorityKeys?.privateKey) {
|
|
413
|
+
const priv = crypto.createPrivateKey({
|
|
414
|
+
key: Buffer.from(String(authorityKeys.privateKey), 'base64'),
|
|
415
|
+
format: 'der',
|
|
416
|
+
type: 'pkcs8',
|
|
417
|
+
});
|
|
418
|
+
sigB64 = crypto.sign(null, Buffer.from(`${SESSION_TOKEN_PREFIX}.${payloadB64}`, 'utf8'), priv).toString('base64url');
|
|
419
|
+
}
|
|
420
|
+
return `${SESSION_TOKEN_PREFIX}.${payloadB64}.${sigB64}`;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Проверить подпись Ed25519 сессионного токена (wks_1.<payload>.<sig>) и вернуть payload.
|
|
425
|
+
*/
|
|
426
|
+
export function verifySessionToken(tokenStr = '', trustedPublicKeyB64 = '') {
|
|
427
|
+
try {
|
|
428
|
+
const raw = String(tokenStr || '').trim();
|
|
429
|
+
const parts = raw.split('.');
|
|
430
|
+
if (parts.length !== 3 || parts[0] !== SESSION_TOKEN_PREFIX || !parts[2])
|
|
431
|
+
return null;
|
|
432
|
+
const payload = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
|
|
433
|
+
if (!payload?.email)
|
|
434
|
+
return null;
|
|
435
|
+
const trustedKeys = resolveTrustedAuthorityKeys(trustedPublicKeyB64);
|
|
436
|
+
const signedData = Buffer.from(`${SESSION_TOKEN_PREFIX}.${parts[1]}`, 'utf8');
|
|
437
|
+
const sigBuf = Buffer.from(parts[2], 'base64url');
|
|
438
|
+
for (const keyB64 of trustedKeys) {
|
|
439
|
+
try {
|
|
440
|
+
const pub = crypto.createPublicKey({
|
|
441
|
+
key: Buffer.from(String(keyB64), 'base64'),
|
|
442
|
+
format: 'der',
|
|
443
|
+
type: 'spki',
|
|
444
|
+
});
|
|
445
|
+
if (crypto.verify(null, signedData, pub, sigBuf))
|
|
446
|
+
return payload;
|
|
447
|
+
}
|
|
448
|
+
catch { /* try next */ }
|
|
449
|
+
}
|
|
450
|
+
return null;
|
|
451
|
+
}
|
|
452
|
+
catch {
|
|
453
|
+
return null;
|
|
454
|
+
}
|
|
455
|
+
}
|
package/src/landing.js
CHANGED
|
@@ -498,6 +498,20 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
498
498
|
|
|
499
499
|
<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>
|
|
500
500
|
|
|
501
|
+
<!-- Карточка активной сессии (показывается, когда пользователь уже вошёл) -->
|
|
502
|
+
<div id="authStepLoggedIn" style="display:none">
|
|
503
|
+
<div style="padding:14px 16px;border-radius:12px;border:1px solid var(--line-strong);background:var(--field);margin-bottom:14px">
|
|
504
|
+
<div class="small muted">Активная сессия в браузере</div>
|
|
505
|
+
<div style="font-size:16px;font-weight:700;margin-top:2px" id="authLoggedEmail">—</div>
|
|
506
|
+
<div style="margin-top:6px" id="authLoggedRoleBadge"></div>
|
|
507
|
+
</div>
|
|
508
|
+
<div style="display:flex;flex-direction:column;gap:8px">
|
|
509
|
+
<button type="button" class="btn btn-primary" style="width:100%" data-action="go-cabinet">Перейти в Личный кабинет</button>
|
|
510
|
+
<a class="btn btn-ghost" id="btnGoAdminModal" href="/admin" style="width:100%;display:none;text-decoration:none">Открыть Админ-панель</a>
|
|
511
|
+
<button type="button" class="btn btn-ghost" style="width:100%;color:var(--danger)" data-action="logout">Выйти из аккаунта</button>
|
|
512
|
+
</div>
|
|
513
|
+
</div>
|
|
514
|
+
|
|
501
515
|
<div id="authStepEmail">
|
|
502
516
|
<div class="field">
|
|
503
517
|
<label for="authEmail">Ваш Email *</label>
|
|
@@ -531,7 +545,6 @@ export function renderLandingPage({ publicKey = '', regions = null, assetVersion
|
|
|
531
545
|
|
|
532
546
|
<div class="status" id="authErrorMsg" style="color:var(--danger);min-height:18px;margin-top:6px"></div>
|
|
533
547
|
<div style="display:flex;gap:10px;margin-top:6px;justify-content:flex-end;flex-wrap:wrap">
|
|
534
|
-
<button class="btn btn-ghost btn-sm" id="btnLogoutModal" data-action="logout" style="display:none">Выйти из аккаунта</button>
|
|
535
548
|
<button class="btn btn-ghost btn-sm" data-action="auth-close">Закрыть</button>
|
|
536
549
|
</div>
|
|
537
550
|
</div>
|
package/src/server.js
CHANGED
|
@@ -316,7 +316,15 @@ export function createLicenseServerApp(options = {}) {
|
|
|
316
316
|
function extractBearerToken(req, body = null, url = null) {
|
|
317
317
|
const authHeader = String(req.headers.authorization || '').replace(/^Bearer\s+/i, '').trim();
|
|
318
318
|
const xAdmin = String(req.headers['x-admin-token'] || req.headers['x-admin-key'] || '').trim();
|
|
319
|
-
|
|
319
|
+
let cookieTok = '';
|
|
320
|
+
const cookieHdr = String(req.headers.cookie || '');
|
|
321
|
+
if (cookieHdr) {
|
|
322
|
+
const m = cookieHdr.match(/(?:^|;\s*)odant_work_session=([^;]+)/);
|
|
323
|
+
if (m?.[1]) {
|
|
324
|
+
try { cookieTok = decodeURIComponent(m[1].trim()); } catch { cookieTok = m[1].trim(); }
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
return authHeader || xAdmin || body?.adminToken || body?.sessionKey || url?.searchParams?.get('sessionKey') || cookieTok || '';
|
|
320
328
|
}
|
|
321
329
|
|
|
322
330
|
function ensureCustomerOferta(req, body = {}) {
|
|
@@ -466,6 +474,7 @@ export function createLicenseServerApp(options = {}) {
|
|
|
466
474
|
ofertaAccepted: true,
|
|
467
475
|
hubUrl: body.hubUrl || reqOrigin,
|
|
468
476
|
});
|
|
477
|
+
res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
|
|
469
478
|
return sendJson(res, 200, user);
|
|
470
479
|
}
|
|
471
480
|
|
|
@@ -476,9 +485,18 @@ export function createLicenseServerApp(options = {}) {
|
|
|
476
485
|
...body,
|
|
477
486
|
hubUrl: body.hubUrl || reqOrigin,
|
|
478
487
|
});
|
|
488
|
+
res.setHeader('Set-Cookie', `odant_work_session=${encodeURIComponent(user.sessionKey)}; Path=/; Max-Age=31536000; SameSite=Lax`);
|
|
479
489
|
return sendJson(res, 200, user);
|
|
480
490
|
}
|
|
481
491
|
|
|
492
|
+
if (req.method === 'POST' && pathname === '/api/auth/logout') {
|
|
493
|
+
const body = await readJsonBody(req);
|
|
494
|
+
const tok = extractBearerToken(req, body, url);
|
|
495
|
+
store.logoutAdmin(tok);
|
|
496
|
+
res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
|
|
497
|
+
return sendJson(res, 200, { ok: true });
|
|
498
|
+
}
|
|
499
|
+
|
|
482
500
|
// 2.0.1 Генерация или перевыпуск токена владельца/сервера (с зашитым URL сервера поддержки и licenseId) в личном кабинете
|
|
483
501
|
if (req.method === 'POST' && pathname === '/api/cabinet/token') {
|
|
484
502
|
const body = await readJsonBody(req);
|
|
@@ -906,6 +924,7 @@ export function createLicenseServerApp(options = {}) {
|
|
|
906
924
|
if (req.method === 'POST' && pathname === '/api/admin/logout') {
|
|
907
925
|
const body = await readJsonBody(req);
|
|
908
926
|
const tok = extractBearerToken(req, body, url);
|
|
927
|
+
res.setHeader('Set-Cookie', 'odant_work_session=; Path=/; Max-Age=0; SameSite=Lax');
|
|
909
928
|
return sendJson(res, 200, store.logoutAdmin(tok));
|
|
910
929
|
}
|
|
911
930
|
|
package/src/store.js
CHANGED
|
@@ -29,6 +29,8 @@ import {
|
|
|
29
29
|
createOwnerAccessToken,
|
|
30
30
|
parseOwnerAccessToken,
|
|
31
31
|
verifyOwnerAccessToken,
|
|
32
|
+
createSessionToken,
|
|
33
|
+
verifySessionToken,
|
|
32
34
|
} from './crypto.js';
|
|
33
35
|
|
|
34
36
|
const ONE_YEAR_MS = 365 * 24 * 3600_000;
|
|
@@ -82,6 +84,7 @@ export class LicenseStore {
|
|
|
82
84
|
this.adminLogin = String(adminLogin || process.env.WORK_ADMIN_LOGIN || 'admin').trim();
|
|
83
85
|
this.adminPassword = String(adminPassword || process.env.WORK_ADMIN_PASSWORD || process.env.WORK_ADMIN_KEY || 'odant-admin');
|
|
84
86
|
this.adminSessions = new Map();
|
|
87
|
+
this.revokedSessions = new Set();
|
|
85
88
|
|
|
86
89
|
this.state = {
|
|
87
90
|
licenses: {},
|
|
@@ -124,7 +127,10 @@ export class LicenseStore {
|
|
|
124
127
|
|
|
125
128
|
logoutAdmin(token = '') {
|
|
126
129
|
const clean = String(token || '').trim();
|
|
127
|
-
if (clean)
|
|
130
|
+
if (clean) {
|
|
131
|
+
this.adminSessions.delete(clean);
|
|
132
|
+
this.revokedSessions.add(clean);
|
|
133
|
+
}
|
|
128
134
|
return { ok: true };
|
|
129
135
|
}
|
|
130
136
|
|
|
@@ -134,15 +140,15 @@ export class LicenseStore {
|
|
|
134
140
|
|
|
135
141
|
getAdminIdentity(token = '') {
|
|
136
142
|
const clean = String(token || '').trim();
|
|
137
|
-
if (!clean)
|
|
143
|
+
if (!clean || this.revokedSessions.has(clean))
|
|
138
144
|
return null;
|
|
139
|
-
const
|
|
140
|
-
if (
|
|
145
|
+
const userFromTok = this._findUser(clean);
|
|
146
|
+
if (userFromTok && userFromTok.isAdmin) {
|
|
141
147
|
return {
|
|
142
148
|
authenticated: true,
|
|
143
|
-
login:
|
|
144
|
-
email:
|
|
145
|
-
userId:
|
|
149
|
+
login: userFromTok.email,
|
|
150
|
+
email: userFromTok.email,
|
|
151
|
+
userId: userFromTok.userId,
|
|
146
152
|
role: 'BOSS',
|
|
147
153
|
};
|
|
148
154
|
}
|
|
@@ -1197,11 +1203,40 @@ export class LicenseStore {
|
|
|
1197
1203
|
|
|
1198
1204
|
_findUser(sessionKeyOrEmail = '') {
|
|
1199
1205
|
const key = String(sessionKeyOrEmail || '').trim();
|
|
1200
|
-
if (!key)
|
|
1206
|
+
if (!key || this.revokedSessions.has(key))
|
|
1201
1207
|
return null;
|
|
1202
|
-
|
|
1208
|
+
let found = Object.values(this.state.users || {}).find(
|
|
1203
1209
|
u => u.sessionKey === key || u.email === key.toLowerCase() || u.userId === key
|
|
1204
1210
|
) || null;
|
|
1211
|
+
if (found)
|
|
1212
|
+
return found;
|
|
1213
|
+
|
|
1214
|
+
// Если контейнер перезапускался, проверяем криптографическую подпись бессрочного токена сессии wks_1...
|
|
1215
|
+
const verifiedPayload = verifySessionToken(key, this.authorityKeys.publicKey);
|
|
1216
|
+
if (verifiedPayload?.email) {
|
|
1217
|
+
const cleanEmail = String(verifiedPayload.email).toLowerCase();
|
|
1218
|
+
let user = this.state.users[cleanEmail];
|
|
1219
|
+
if (!user) {
|
|
1220
|
+
this.registerOrLoginUser({
|
|
1221
|
+
email: cleanEmail,
|
|
1222
|
+
name: verifiedPayload.name || '',
|
|
1223
|
+
companyName: verifiedPayload.companyName || '',
|
|
1224
|
+
inn: verifiedPayload.inn || '',
|
|
1225
|
+
ofertaAccepted: true,
|
|
1226
|
+
});
|
|
1227
|
+
user = this.state.users[cleanEmail];
|
|
1228
|
+
}
|
|
1229
|
+
if (user) {
|
|
1230
|
+
if (verifiedPayload.isAdmin) {
|
|
1231
|
+
user.isAdmin = true;
|
|
1232
|
+
user.role = 'BOSS';
|
|
1233
|
+
}
|
|
1234
|
+
user.sessionKey = key;
|
|
1235
|
+
try { this._save(); } catch { /* ignore */ }
|
|
1236
|
+
return user;
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
return null;
|
|
1205
1240
|
}
|
|
1206
1241
|
|
|
1207
1242
|
/**
|
|
@@ -1558,6 +1593,17 @@ export class LicenseStore {
|
|
|
1558
1593
|
user.role = 'BOSS';
|
|
1559
1594
|
}
|
|
1560
1595
|
|
|
1596
|
+
// Выпускаем криптографически подписанный бессрочный токен сессии (wks_1...), устойчивый к перезапускам сервера
|
|
1597
|
+
user.sessionKey = createSessionToken({
|
|
1598
|
+
userId: user.userId,
|
|
1599
|
+
email: user.email,
|
|
1600
|
+
name: user.name,
|
|
1601
|
+
companyName: user.companyName,
|
|
1602
|
+
inn: user.inn,
|
|
1603
|
+
isAdmin: Boolean(user.isAdmin),
|
|
1604
|
+
issuedAt: now,
|
|
1605
|
+
}, this.authorityKeys);
|
|
1606
|
+
|
|
1561
1607
|
if (ofertaAccepted !== false && !user.ofertaAccepted) {
|
|
1562
1608
|
user.ofertaAccepted = true;
|
|
1563
1609
|
user.ofertaAcceptedAt = now;
|
|
@@ -1784,7 +1830,7 @@ export class LicenseStore {
|
|
|
1784
1830
|
const key = String(sessionKey || '').trim();
|
|
1785
1831
|
if (!key)
|
|
1786
1832
|
return null;
|
|
1787
|
-
const user =
|
|
1833
|
+
const user = this._findUser(key);
|
|
1788
1834
|
if (!user)
|
|
1789
1835
|
return null;
|
|
1790
1836
|
return this._formatUserResponse(user);
|