PageSourceSearch

https://mapus.ai/static/js/450/user_auth.js

js mapus.ai collected 2026-10-05 12:13:40 UTC 22,762 bytes, 657 lines download raw bytes

1// Inline login for the public map pages. Renders a modal over the map, posts
2// to the editor auth endpoint, and on success swaps the "Войти" button for the
3// avatar badge in place (no page reload). On the home gate page, reloads so
4// the server can render the full map. Does nothing when the visitor is already
5// authenticated (no #login-btn and no #home-login-btn present).
6(function () {
7  const loginBtn = document.getElementById('login-btn');
8  const homeLoginBtn = document.getElementById('home-login-btn');
9  if (!loginBtn && !homeLoginBtn) return;
10
11  const modal = document.getElementById('login-modal');
12  const form = document.getElementById('login-form');
13  const emailInput = document.getElementById('login-email');
14  const passwordInput = document.getElementById('login-password');
15  const passwordWrap = document.getElementById('login-password-wrap');
16  const passwordLabel = document.getElementById('login-password-label');
17  const emailTip = document.getElementById('login-email-tip');
18  const passwordTip = document.getElementById('login-password-tip');
19  const formTip = document.getElementById('login-form-tip');
20  const successBox = document.getElementById('login-success');
21  const cancelBtn = document.getElementById('login-cancel');
22  const submitBtn = document.getElementById('login-submit');
23  const titleEl = document.getElementById('login-title');
24  const oauthBox = document.getElementById('login-oauth');
25  const captchaBox = document.getElementById('login-captcha');
26  const forgotLink = document.getElementById('login-forgot-link');
27  const registerLink = document.getElementById('login-register-link');
28  const backLink = document.getElementById('login-back-link');
29  const rememberInput = document.getElementById('login-remember');
30  const rememberWrap = document.getElementById('login-remember-wrap');
31  if (!modal || !form) return;
32
33  const authOptions = window.AUTH_OPTIONS || {
34    registration_enabled: false,
35    oauth_providers: [],
36    smartcaptcha: { enabled: false }
37  };
38  const captchaOpts = authOptions.smartcaptcha || { enabled: false };
39  const policy = (window.Mapus && window.Mapus.passwordPolicy) || {};
40  const setTip =
41    typeof policy.setFieldTip === 'function'
42      ? policy.setFieldTip
43      : function (input, tipEl, message) {
44          if (!tipEl) return;
45          tipEl.textContent = message || '';
46          tipEl.hidden = !message;
47          if (input) {
48            input.classList.toggle('field-invalid', Boolean(message));
49            input.setAttribute('aria-invalid', message ? 'true' : 'false');
50          }
51        };
52
53  // login | register | forgot
54  let mode = 'login';
55  let captchaToken = '';
56  let captchaWidgetId = null;
57  let captchaScriptPromise = null;
58
59  const ERROR_MESSAGES = {
60    'invalid credentials': 'Неверный email или пароль',
61    'email and password are required': 'Введите email и пароль',
62    'registration is disabled': 'Регистрация отключена',
63    'not a valid email address': 'Введите корректный адрес электронной почты',
64    'captcha failed': 'Подтвердите, что вы не робот',
65    forbidden: 'Запрос отклонён'
66  };
67
68  function emailError(value) {
69    if (typeof policy.validateEmail === 'function') {
70      return policy.validateEmail(value);
71    }
72    const trimmed = (value || '').trim();
73    if (!trimmed) return 'Введите адрес электронной почты';
74    if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) {
75      return 'Введите корректный адрес электронной почты';
76    }
77    return null;
78  }
79
80  function loginPasswordError(value) {
81    if (typeof policy.validateLoginPassword === 'function') {
82      return policy.validateLoginPassword(value);
83    }
84    if (!(value || '')) return 'Введите пароль';
85    return null;
86  }
87
88  /** @returns {{target: string, message: string}|null} */
89  function validateFields() {
90    const mailErr = emailError(emailInput.value);
91    if (mailErr) return { target: 'email', message: mailErr };
92    if (mode === 'login') {
93      const pwErr = loginPasswordError(passwordInput.value);
94      if (pwErr) return { target: 'password', message: pwErr };
95    }
96    return null;
97  }
98
99  const OAUTH_LABELS = {
100    google: 'Google',
101    yandex: 'Яндекс',
102    vk: 'ВКонтакте',
103    github: 'GitHub'
104  };
105
106  // Stable display order (API returns a sorted set). Not alphabetical: the two
107  // providers that survive the GeoIP filter in Russia lead, so the list does not
108  // start with a gap for most visitors.
109  const OAUTH_ORDER = ['yandex', 'vk', 'google', 'github'];
110
111  function clearFieldTips() {
112    setTip(emailInput, emailTip, null);
113    setTip(passwordInput, passwordTip, null);
114    if (formTip) {
115      formTip.textContent = '';
116      formTip.hidden = true;
117    }
118  }
119
120  function showFieldError(target, message) {
121    if (successBox) {
122      successBox.textContent = '';
123      successBox.hidden = true;
124    }
125    clearFieldTips();
126    if (target === 'email') setTip(emailInput, emailTip, message);
127    else if (target === 'password') setTip(passwordInput, passwordTip, message);
128    else if (formTip) {
129      formTip.textContent = message;
130      formTip.hidden = false;
131    }
132  }
133
134  function showError(message) {
135    // Form-level (captcha / network); credentials → password tip.
136    showFieldError('form', message);
137  }
138
139  function showSuccess(message) {
140    clearFieldTips();
141    if (!successBox) return;
142    successBox.textContent = message;
143    successBox.hidden = false;
144  }
145
146  function clearMessages() {
147    clearFieldTips();
148    if (successBox) {
149      successBox.textContent = '';
150      successBox.hidden = true;
151    }
152  }
153
154  function captchaNeeded() {
155    return Boolean(
156      captchaOpts.enabled &&
157        captchaOpts.client_key &&
158        (mode === 'register' || mode === 'forgot')
159    );
160  }
161
162  function loadCaptchaScript() {
163    if (window.smartCaptcha) return Promise.resolve();
164    if (captchaScriptPromise) return captchaScriptPromise;
165    captchaScriptPromise = new Promise((resolve, reject) => {
166      const existing = document.querySelector(
167        'script[data-mapus-smartcaptcha="1"]'
168      );
169      if (existing) {
170        existing.addEventListener('load', () => resolve());
171        existing.addEventListener('error', () =>
172          reject(new Error('smartcaptcha script failed'))
173        );
174        return;
175      }
176      const script = document.createElement('script');
177      script.src = 'https://smartcaptcha.cloud.yandex.ru/captcha.js';
178      script.defer = true;
179      script.dataset.mapusSmartcaptcha = '1';
180      script.onload = () => resolve();
181      script.onerror = () => reject(new Error('smartcaptcha script failed'));
182      document.head.appendChild(script);
183    });
184    return captchaScriptPromise;
185  }
186
187  function destroyCaptcha() {
188    captchaToken = '';
189    if (
190      captchaWidgetId !== null &&
191      window.smartCaptcha &&
192      typeof window.smartCaptcha.destroy === 'function'
193    ) {
194      try {
195        window.smartCaptcha.destroy(captchaWidgetId);
196      } catch (err) {
197        /* ignore */
198      }
199    }
200    captchaWidgetId = null;
201    if (captchaBox) {
202      captchaBox.replaceChildren();
203      captchaBox.hidden = true;
204    }
205  }
206
207  function resetCaptcha() {
208    captchaToken = '';
209    if (
210      captchaWidgetId !== null &&
211      window.smartCaptcha &&
212      typeof window.smartCaptcha.reset === 'function'
213    ) {
214      try {
215        window.smartCaptcha.reset(captchaWidgetId);
216        return;
217      } catch (err) {
218        /* fall through to re-render */
219      }
220    }
221    if (captchaNeeded()) {
222      destroyCaptcha();
223      syncCaptcha();
224    }
225  }
226
227  async function syncCaptcha() {
228    if (!captchaBox) return;
229    if (!captchaNeeded()) {
230      destroyCaptcha();
231      return;
232    }
233    captchaBox.hidden = false;
234    if (captchaWidgetId !== null) return;
235    try {
236      await loadCaptchaScript();
237    } catch (err) {
238      console.error(err);
239      showError('Не удалось загрузить капчу');
240      return;
241    }
242    if (!window.smartCaptcha || captchaWidgetId !== null) return;
243    captchaBox.replaceChildren();
244    captchaWidgetId = window.smartCaptcha.render(captchaBox, {
245      sitekey: captchaOpts.client_key,
246      hl: 'ru',
247      callback: function (token) {
248        captchaToken = token || '';
249      }
250    });
251  }
252
253  function setMode(next) {
254    mode = next;
255    clearMessages();
256    const isLogin = mode === 'login';
257    const isRegister = mode === 'register';
258    const isForgot = mode === 'forgot';
259
260    titleEl.textContent = isRegister
261      ? 'Регистрация'
262      : isForgot
263        ? 'Сброс пароля'
264        : 'Вход';
265
266    if (passwordWrap) passwordWrap.hidden = !isLogin;
267    if (passwordLabel) passwordLabel.hidden = !isLogin;
268    passwordInput.hidden = !isLogin;
269    if (!isLogin) passwordInput.value = '';
270
271    submitBtn.textContent = isRegister
272      ? 'Зарегистрироваться'
273      : isForgot
274        ? 'Отправить ссылку'
275        : 'Войти';
276
277    if (oauthBox) oauthBox.hidden = !isLogin || !oauthBox.children.length;
278    if (forgotLink) forgotLink.hidden = !isLogin;
279    if (registerLink) {
280      registerLink.hidden = !isLogin || !authOptions.registration_enabled;
281    }
282    if (backLink) backLink.hidden = isLogin;
283    // Registration and password reset have no session to keep, so the choice
284    // would be an inert control sitting under a form it does not affect.
285    if (rememberWrap) rememberWrap.hidden = !isLogin;
286    syncCaptcha();
287  }
288
289  function renderOauth() {
290    if (!oauthBox) return;
291    oauthBox.replaceChildren();
292    const enabled = new Set(authOptions.oauth_providers || []);
293    const providers = OAUTH_ORDER.filter((name) => enabled.has(name));
294    if (!authOptions.registration_enabled || !providers.length) {
295      oauthBox.hidden = true;
296      return;
297    }
298    const startBase = String(authOptions.oauth_start_base || '').replace(/\/$/, '');
299    providers.forEach((name) => {
300      const a = document.createElement('a');
301      a.className = 'login-oauth-btn login-oauth-' + name;
302      a.href =
303        startBase + '/auth/' + encodeURIComponent(name) + '/start';
304      a.title = 'Войти через ' + (OAUTH_LABELS[name] || name);
305
306      const icon = document.createElement('span');
307      icon.className = 'login-oauth-icon';
308      icon.setAttribute('aria-hidden', 'true');
309
310      const label = document.createElement('span');
311      label.className = 'login-oauth-label';
312      label.textContent = OAUTH_LABELS[name] || name;
313
314      a.append(icon, label);
315      // Второй щелчок, пока первый ещё уводит к провайдеру, -- второй старт:
316      // он заводил новый `state`, и возврат от первого не сходился (issue
317      // #227; сервер теперь принимает оба, но лишний круг к провайдеру ни к
318      // чему). Кнопка глохнет на время перехода и оживает, если переход не
319      // случился или со страницы провайдера вернулись «Назад».
320      a.addEventListener('click', (ev) => {
321        if (a.dataset.busy) {
322          ev.preventDefault();
323          return;
324        }
325        a.dataset.busy = '1';
326        a.setAttribute('aria-disabled', 'true');
327        a.classList.add('is-busy');
328        setTimeout(() => releaseOauth(a), OAUTH_BUSY_MS);
329      });
330      oauthBox.appendChild(a);
331    });
332    oauthBox.hidden = mode !== 'login';
333  }
334
335  //: Сколько кнопка входа через провайдера глуха после щелчка.
336  const OAUTH_BUSY_MS = 8000;
337
338  function releaseOauth(a) {
339    delete a.dataset.busy;
340    a.removeAttribute('aria-disabled');
341    a.classList.remove('is-busy');
342  }
343
344  // Возврат «Назад» со страницы провайдера поднимает страницу из кэша
345  // переходов вместе с заглохшими кнопками -- оживить.
346  window.addEventListener('pageshow', (ev) => {
347    if (ev.persisted && oauthBox) oauthBox.querySelectorAll('.login-oauth-btn').forEach(releaseOauth);
348  });
349
350  function openModal() {
351    setMode('login');
352    renderOauth();
353    modal.classList.add('open');
354    emailInput.focus();
355  }
356
357  function closeModal() {
358    modal.classList.remove('open');
359    destroyCaptcha();
360  }
361
362  // Replace the badge contents with the authenticated avatar using data from
363  // the login response, then drop the now-unneeded modal.
364  function renderBadge(badge) {
365    const wrap = document.querySelector('.user-badge');
366    if (wrap && badge) {
367      const link = document.createElement('a');
368      link.className = 'user-badge-avatar';
369      link.href = badge.profile_url || '/editor/';
370      link.title = 'Профиль';
371      link.rel = 'author';
372      if (badge.avatar_url) {
373        const img = document.createElement('img');
374        img.src = badge.avatar_url;
375        img.alt = 'Профиль';
376        link.appendChild(img);
377      } else {
378        const span = document.createElement('span');
379        span.className = 'user-badge-initial';
380        span.textContent = badge.initial || '?';
381        link.appendChild(span);
382      }
383      wrap.replaceChildren(link);
384    }
385    modal.remove();
386  }
387
388  function bindLoginTrigger(el) {
389    el.addEventListener('click', (e) => {
390      e.preventDefault();
391      openModal();
392    });
393  }
394
395  if (loginBtn) bindLoginTrigger(loginBtn);
396  if (homeLoginBtn) bindLoginTrigger(homeLoginBtn);
397  // The landing repeats the call to action at the foot of a long scroll, where
398  // sending the reader back up to the hero button would be the whole point
399  // missed. A class rather than an id: there may be more than one.
400  document.querySelectorAll('.js-open-login').forEach(bindLoginTrigger);
401
402  // ?login=1 opens the form on arrival. The editor has no login page of its own
403  // any more, so an unauthenticated visitor is sent here with this parameter --
404  // landing on a page where they still have to find the button would be sending
405  // them somewhere rather than letting them in.
406  if (new URLSearchParams(window.location.search).has('login')) {
407    openModal();
408    // Drop it from the address bar: reloading or sharing the URL should not
409    // reopen a form the visitor may have deliberately closed.
410    const url = new URL(window.location.href);
411    url.searchParams.delete('login');
412    window.history.replaceState({}, '', url.pathname + url.search + url.hash);
413  }
414
415  cancelBtn.addEventListener('click', closeModal);
416  // mousedown (not click/mouseup): press on the dimmed overlay closes; releasing
417  // outside after a press that started on the card must not dismiss.
418  modal.addEventListener('mousedown', (e) => {
419    if (e.target === modal) closeModal();
420  });
421  document.addEventListener('keydown', (e) => {
422    if (e.key === 'Escape') closeModal();
423  });
424
425  if (forgotLink) {
426    forgotLink.addEventListener('click', () => setMode('forgot'));
427  }
428  if (registerLink) {
429    registerLink.addEventListener('click', () => setMode('register'));
430  }
431  if (backLink) {
432    backLink.addEventListener('click', () => setMode('login'));
433  }
434
435  async function postJson(url, body) {
436    const res = await fetch(url, {
437      method: 'POST',
438      credentials: 'same-origin',
439      headers: {
440        'Content-Type': 'application/json',
441        'X-Requested-With': 'XMLHttpRequest'
442      },
443      body: JSON.stringify(body)
444    });
445    let data = null;
446    const contentType = res.headers.get('content-type') || '';
447    if (contentType.includes('application/json')) {
448      data = await res.json();
449    }
450    return { res, data };
451  }
452
453  function clearFormBanner() {
454    if (formTip && !formTip.hidden) {
455      formTip.textContent = '';
456      formTip.hidden = true;
457    }
458    if (successBox && !successBox.hidden) {
459      successBox.textContent = '';
460      successBox.hidden = true;
461    }
462  }
463
464  // Form-level banners sit above the form;
464 dismiss on any field interaction.
465  form.addEventListener('focusin', (e) => {
466    if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA')) {
467      clearFormBanner();
468    }
469  });
470
471  // Clicking an OAuth button blurs whichever field had focus, and the blur
472  // validators below would flash "введите адрес" at somebody already on their
473  // way to VK -- a complaint about a form they have chosen not to fill in.
474  //
475  // pointerdown is dispatched before the focus moves, so the flag is set in
476  // time; it is cleared on the next tick so that a click released outside the
477  // button does not go on silencing the validator. relatedTarget covers
478  // reaching the button by keyboard, where no pointer event happens at all.
479  let leavingToOauth = false;
480  if (oauthBox) {
481    oauthBox.addEventListener('pointerdown', () => {
482      leavingToOauth = true;
483      setTimeout(() => {
484        leavingToOauth = false;
485      }, 0);
486    });
487  }
488
489  function blurGoesToOauth(event) {
490    if (leavingToOauth) return true;
491    const to = event && event.relatedTarget;
492    return Boolean(to && oauthBox && oauthBox.contains(to));
493  }
494
495  // Blur checks the shape of what was typed, never the fact that nothing was.
496  // Tabbing through a form you have not filled in yet is not a mistake, and
497  // «Введите адрес» said while the cursor is still on its way to the next field
498  // is an accusation. Emptiness is the submit handler's business.
499  emailInput.addEventListener('blur', (e) => {
500    if (blurGoesToOauth(e)) return;
501    if (!emailInput.value.trim()) {
502      setTip(emailInput, emailTip, null);
503      return;
504    }
505    const err = emailError(emailInput.value);
506    if (err) showFieldError('email', err);
507    else setTip(emailInput, emailTip, null);
508  });
509
510  emailInput.addEventListener('input', () => {
511    if (emailTip && !emailTip.hidden) setTip(emailInput, emailTip, null);
512  });
513
514  passwordInput.addEventListener('blur', (e) => {
515    if (blurGoesToOauth(e)) return;
516    if (mode !== 'login' || passwordInput.hidden) return;
517    if (!passwordInput.value) {
518      setTip(passwordInput, passwordTip, null);
519      return;
520    }
521    const err = loginPasswordError(passwordInput.value);
522    if (err) showFieldError('password', err);
523    else setTip(passwordInput, passwordTip, null);
524  });
525
526  passwordInput.addEventListener('input', () => {
527    if (passwordTip && !passwordTip.hidden) setTip(passwordInput, passwordTip, null);
528  });
529
530  form.addEventListener('submit', async (e) => {
531    e.preventDefault();
532    clearMessages();
533
534    const fieldError = validateFields();
535    if (fieldError) {
536      showFieldError(fieldError.target, fieldError.message);
537      return;
538    }
539    if (captchaNeeded() && !captchaToken) {
540      showFieldError('form', 'Подтвердите, что вы не робот');
541      return;
542    }
543
544    const email = emailInput.value.trim();
545    const password = passwordInput.value;
546
547    submitBtn.disabled = true;
548    try {
549      if (mode === 'register') {
550        const body = { email };
551        if (captchaNeeded()) body.captcha_token = captchaToken;
552        const { res, data } = await postJson('/api/auth/register', body);
553        if (res.ok) {
554          showSuccess(
555            'Если адрес свободен, мы отправили письмо со ссылкой для задания пароля.'
556          );
557          resetCaptcha();
558          return;
559        }
560        resetCaptcha();
561        const serverError = data && data.error;
562        const msg =
563          ERROR_MESSAGES[serverError] ||
564          serverError ||
565          'Не удалось зарегистрироваться';
566        showFieldError(
567          serverError === 'not a valid email address' ? 'email' : 'form',
568          msg
569        );
570        return;
571      }
572
573      if (mode === 'forgot') {
574        const body = { email };
575        if (captchaNeeded()) body.captcha_token = captchaToken;
576        const { res, data } = await postJson('/api/auth/forgot-password', body);
577        if (res.ok) {
578          showSuccess(
579            'Если аккаунт существует, мы отправили письмо со ссылкой для сброса пароля.'
580          );
581          resetCaptcha();
582          return;
583        }
584        resetCaptcha();
585        const serverError = data && data.error;
586        const msg =
587          ERROR_MESSAGES[serverError] ||
588          serverError ||
589          'Не удалось отправить письмо';
590        showFieldError(
591          serverError === 'not a valid email address' ? 'email' : 'form',
592          msg
593        );
594        return;
595      }
596
597      const { res, data } = await postJson('/editor/api/auth/login', {
598        email,
599        password,
600        // Absent or false keeps the session in the browser's memory, which is
601        // the safer default on a shared machine; true asks for the longest
602        // cookie a browser will actually honour.
603        remember: Boolean(rememberInput && rememberInput.checked)
604      });
605
606      if (res.ok) {
607        if (document.getElementById('home-gate')) {
608          window.location.reload();
609          return;
610        }
611        renderBadge(data && data.badge);
612        document.dispatchEvent(new CustomEvent('mapus:auth-changed'));
613        return;
614      }
615
616      const serverError = data && data.error;
617      const msg =
618        ERROR_MESSAGES[serverError] || serverError || 'Не удалось войти';
619      if (serverError === 'not a valid email address') {
620        showFieldError('email', msg);
621      } else {
622        // Login failures (credentials, network-mapped, unknown) sit above
623        // the whole form — not as a field bubble.
624        showFieldError('form', msg);
625      }
626    } catch (err) {
627      console.error('auth failed', err);
628      showFieldError('form', 'Не удалось выполнить запрос');
629    } finally {
630      submitBtn.disabled = false;
631    }
632  });
633
634  renderOauth();
635  if (registerLink) {
636    registerLink.hidden = !authOptions.registration_enabled;
637  }
638})();
639
640// Logout button on the owner's profile page: clears the session, then goes home.
641(function () {
642  const logoutBtn = document.getElementById('logout-btn');
643  if (!logoutBtn) return;
644
645  logoutBtn.addEventListener('click', async () => {
646    logoutBtn.disabled = true;
647    try {
648      await fetch('/editor/api/auth/logout', {
649        method: 'POST',
650        credentials: 'same-origin'
651      });
652    } catch (err) {
653      console.error('logout failed', err);
654    }
655    window.location.href = '/';
656  });
657})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.