/* Аккаунт: регистрация с подтверждением почты, вход, сброс пароля, удаление аккаунта и синхронизация прогресса. Работает только если рядом есть API (/api/health). Без него сайт остаётся обычным локальным приложением. */ (function () { 'use strict'; const NO_CONNECTION = 'Нет связи с сервером. Проверьте подключение и повторите.'; const UNEXPECTED = 'Что-то пошло не так. Обновите страницу и повторите.'; const REQUEST_TIMEOUT_MS = 15000; const SYNC_DELAY_MS = 2000; const RETRY_FIRST_MS = 15000; const RETRY_MAX_MS = 5 * 60 * 1000; const MAX_SYNC_ATTEMPTS = 3; const TOKEN_LINK = /^#(verify|reset)=([A-Za-z0-9_-]{16,100})$/; let ctx = null; // зависимости, которые передаёт app.js const state = { available: false, user: null, mode: 'login', // login | register | forgot | reset | setpass | info message: null, // { type: 'ok' | 'err', text } email: '', busy: false, pending: null, confirmDelete: false, syncing: false, syncText: '', }; let syncTimer = null, retryTimer = null, retryDelay = 0, dirty = false; // ---------- Запросы к API ---------- async function call(method, path, body) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); const opts = { method, cache: 'no-store', credentials: 'same-origin', signal: controller.signal, headers: { 'X-Requested-With': 'split-trainer' } }; if (body !== undefined) { opts.headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(body); } try { const res = await fetch('api/' + path, opts); let data = null; try { data = await res.json(); } catch (e) { /* не JSON: например, страница ошибки от прокси */ } return { ok: res.ok, status: res.status, data: data || {} }; } finally { clearTimeout(timer); } } const errText = r => r.data.message || `Не удалось выполнить запрос (код ${r.status}).`; const isNetworkError = e => e instanceof TypeError || (e && e.name === 'AbortError'); // ---------- Ссылки из писем ---------- function takeTokenFromLink() { const m = TOKEN_LINK.exec(location.hash); if (!m) return null; // Токен сразу убираем из адресной строки и истории браузера history.replaceState(null, '', location.pathname + location.search); return { kind: m[1], token: m[2] }; } function handleLink() { const found = takeTokenFromLink(); if (!found) return; if (state.user) { // ссылка нужна гостю: при открытом входе она ничего бы не сделала ctx.toast('Вы уже вошли в аккаунт. Чтобы воспользоваться ссылкой из письма, сначала выйдите.'); return; } state.pending = found; state.mode = found.kind === 'reset' ? 'reset' : 'setpass'; state.message = null; ctx.go('account'); } // ---------- Данные устройства и аккаунта ---------- const T = () => window.TrainerSync; /* Локальный прогресс принадлежит тому аккаунту, под которым его синхронизировали. Если на этом браузере входит другой человек, чужие данные не должны уйти в его аккаунт. Данные гостя (владельца нет) объединяются с аккаунтом. */ function adoptAccount(email) { if (ctx.S.accountOwner && ctx.S.accountOwner !== email) { ctx.S.progress = {}; ctx.S.history = []; ctx.S.keys = { ru: {}, en: {} }; } ctx.S.accountOwner = email; ctx.save(); } function applyLocal(merged) { if (JSON.stringify(merged) === JSON.stringify(T().pick(ctx.S))) return; ctx.S.progress = merged.progress; ctx.S.history = merged.history; ctx.S.keys = merged.keys; ctx.save(); ctx.refresh(); } // ---------- Синхронизация ---------- function setSync(text) { state.syncText = text; if (ctx.isView('account')) render(); } function sessionLost() { state.user = null; state.mode = 'login'; state.message = { type: 'err', text: 'Сессия истекла: войдите снова.' }; updateNav(); setSync(''); } /* Cookie общая для всех вкладок: если в другой вкладке вошли в другой аккаунт, эта вкладка со своими данными в памяти не должна ничего записывать на сервер. Перезагрузка подхватит актуальное состояние. */ function accountSwitched() { ctx.toast('В другой вкладке выполнен вход в другой аккаунт: обновляю страницу'); setTimeout(() => location.reload(), 1200); } function scheduleRetry() { clearTimeout(retryTimer); retryDelay = Math.min(retryDelay ? retryDelay * 2 : RETRY_FIRST_MS, RETRY_MAX_MS); retryTimer = setTimeout(syncNow, retryDelay); } async function syncNow() { if (!state.user) return; if (state.syncing) { dirty = true; return; } // изменения во время синхронизации: повторим сразу после неё state.syncing = true; dirty = false; setSync('Синхронизация…'); try { for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) { const got = await call('GET', 'state'); if (got.status === 401) return sessionLost(); if (!got.ok) throw new Error('state'); if (got.data.email && got.data.email !== state.user) return accountSwitched(); // в другой вкладке вошли иначе const remote = T().sanitize(got.data.data); const merged = T().merge(T().pick(ctx.S), remote); applyLocal(merged); const upToDate = JSON.stringify(merged) === JSON.stringify(remote); const put = upToDate ? { ok: true } : await call('PUT', 'state', { version: got.data.version, data: merged, email: state.user }); if (put.ok) { retryDelay = 0; return setSync('Синхронизировано в ' + new Date().toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })); } if (put.status === 401) return sessionLost(); if (put.data.error === 'account_mismatch') return accountSwitched(); if (put.status !== 409) throw new Error('put'); // 409: параллельное изменение, повторяем со свежими данными } throw new Error('conflict'); } catch (e) { setSync('Не удалось синхронизировать. Прогресс сохранён на этом устройстве, повторим автоматически.'); scheduleRetry(); } finally { state.syncing = false; if (dirty) syncNow(); } } function afterRun() { if (!state.user) return; dirty = true; clearTimeout(syncTimer); syncTimer = setTimeout(syncNow, SYNC_DELAY_MS); } /* Очистка прогресса на сервере (для «Сбросить все данные» при входе в аккаунт). Возвращает true при успехе. */ async function clearServerProgress() { if (!state.user) return true; clearTimeout(syncTimer); // отложенная синхронизация вернула бы очищаемый прогресс обратно dirty = false; try { for (let attempt = 0; attempt < MAX_SYNC_ATTEMPTS; attempt++) { const got = await call('GET', 'state'); if (!got.ok || (got.data.email && got.data.email !== state.user)) return false; const put = await call('PUT', 'state', { version: got.data.version, data: {}, email: state.user }); if (put.ok) return true; if (put.status !== 409) return false; } } catch (e) { /* нет связи: сообщаем вызывающему */ } return false; } // ---------- Действия ---------- const updateNav = () => ctx.setNavLabel(state.user ? 'Аккаунт' : 'Войти'); async function run(action) { if (state.busy) return; state.busy = true; state.message = null; render(); try { await action(); } catch (e) { if (!isNetworkError(e)) console.error(e); state.message = { type: 'err', text: isNetworkError(e) ? NO_CONNECTION : UNEXPECTED }; } state.busy = false; render(); } const fail = r => { state.message = { type: 'err', text: errText(r) }; }; const ok = text => { state.message = { type: 'ok', text }; }; const actions = { login: (email, password) => run(async () => { const r = await call('POST', 'login', { email, password }); if (!r.ok) return fail(r); state.user = r.data.email; state.message = null; adoptAccount(state.user); updateNav(); ctx.toast('Вы вошли в аккаунт'); syncNow(); }), register: email => run(async () => { const r = await call('POST', 'register', { email }); if (!r.ok) return fail(r); state.mode = 'info'; ok(r.data.message); }), forgot: email => run(async () => { const r = await call('POST', 'forgot', { email }); if (!r.ok) return fail(r); state.mode = 'info'; ok(r.data.message); }), reset: password => run(async () => { const r = await call('POST', 'reset', { token: state.pending.token, password }); if (r.ok) { state.pending = null; state.mode = 'login'; ok(r.data.message); } else { if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'forgot'; } fail(r); } }), setPassword: password => run(async () => { const r = await call('POST', 'verify', { token: state.pending.token, password }); if (r.ok) { state.pending = null; state.mode = 'login'; ok(r.data.message); } else { if (r.data.error === 'invalid_token') { state.pending = null; state.mode = 'register'; } fail(r); } }), logout: () => run(async () => { const r = await call('POST', 'logout', {}); if (!r.ok) return fail(r); // иначе интерфейс сказал бы «вышли», а cookie осталась бы жива state.user = null; state.mode = 'login'; ok('Вы вышли из аккаунта. Прогресс остался на этом устройстве.'); updateNav(); setSync(''); }), deleteAccount: password => run(async () => { const r = await call('POST', 'delete-account', { password }); if (!r.ok) return fail(r); state.user = null; state.confirmDelete = false; state.mode = 'login'; ctx.S.accountOwner = null; // локальные данные снова ничьи (гостевые) ctx.save(); ok(r.data.message + ' Прогресс на этом устройстве сохранён: удалить его можно в разделе «Статистика».'); updateNav(); setSync(''); }), }; // ---------- Экран ---------- const mode = m => { state.mode = m; state.message = null; render(); }; const h = (...args) => ctx.h(...args); function messageBox() { return state.message ? h('div', { class: 'msg ' + state.message.type, role: state.message.type === 'err' ? 'alert' : 'status' }, state.message.text) : null; } function emailField(autocomplete) { return h('div', { class: 'field' }, h('label', { for: 'au-email' }, 'Почта'), h('input', { id: 'au-email', class: 'text', type: 'email', required: true, maxlength: 254, autocomplete, value: state.email, oninput: e => { state.email = e.target.value; } })); } function passwordField(label, autocomplete, id) { return h('div', { class: 'field' }, h('label', { for: id }, label), h('input', { id, class: 'text', type: 'password', required: true, minlength: 8, maxlength: 128, autocomplete })); } const linkButton = (text, onclick) => h('button', { type: 'button', class: 'linkbtn', onclick }, text); const submitButton = text => h('button', { type: 'submit', class: 'btn primary', disabled: state.busy }, state.busy ? 'Подождите…' : text); const form = (onsubmit, ...kids) => h('form', { class: 'form', onsubmit: e => { e.preventDefault(); onsubmit(e.target); } }, ...kids); const valueOf = (f, id) => f.querySelector('#' + id).value; /* Форма «новый пароль» для сброса и для первого пароля после подтверждения почты */ function newPasswordForm(title, hint, submitText, action) { return [h('h2', null, title), hint ? h('p', { class: 'muted small' }, hint) : null, form(f => { const password = valueOf(f, 'au-pass'); if (password !== valueOf(f, 'au-pass2')) { state.message = { type: 'err', text: 'Пароли не совпадают.' }; return render(); } action(password); }, passwordField('Пароль (не короче 8 символов)', 'new-password', 'au-pass'), passwordField('Повторите пароль', 'new-password', 'au-pass2'), submitButton(submitText))].filter(Boolean); } function guestPanel() { const box = h('div', { class: 'panel authbox' }); if (state.mode === 'login' || state.mode === 'register') { box.append(h('div', { class: 'seg', role: 'group', 'aria-label': 'Вход или регистрация' }, ['login', 'register'].map(m => h('button', { type: 'button', class: state.mode === m ? 'on' : '', 'aria-pressed': String(state.mode === m), onclick: () => mode(m) }, m === 'login' ? 'Вход' : 'Регистрация')))); } const message = messageBox(); if (message) box.append(message); // append(null) напечатал бы слово «null» if (state.mode === 'login') { box.append(form(f => actions.login(valueOf(f, 'au-email'), valueOf(f, 'au-pass')), emailField('username'), passwordField('Пароль', 'current-password', 'au-pass'), h('div', { class: 'row' }, submitButton('Войти'), linkButton('Забыли пароль?', () => mode('forgot'))))); } else if (state.mode === 'register') { box.append(form(f => actions.register(valueOf(f, 'au-email')), emailField('username'), h('p', { class: 'muted small' }, 'Мы отправим письмо со ссылкой. По ней вы подтвердите адрес и задайте пароль: до этого аккаунта нет.'), submitButton('Получить ссылку'))); } else if (state.mode === 'forgot') { box.append(h('h2', null, 'Сброс пароля'), form(f => actions.forgot(valueOf(f, 'au-email')), emailField('username'), h('p', { class: 'muted small' }, 'Пришлём ссылку для нового пароля, если такой адрес зарегистрирован.'), h('div', { class: 'row' }, submitButton('Отправить ссылку'), linkButton('Назад ко входу', () => mode('login'))))); } else if (state.mode === 'reset') { box.append(...newPasswordForm('Новый пароль', null, 'Сохранить пароль', actions.reset)); } else if (state.mode === 'setpass') { box.append(...newPasswordForm('Подтверждение почты', 'Адрес подтверждён. Придумайте пароль: после этого аккаунт будет создан.', 'Создать аккаунт', actions.setPassword)); } else { box.append(h('div', { class: 'row' }, linkButton('← Ко входу', () => mode('login')))); } return box; } function deleteForm() { return h('div', { class: 'panel authbox danger-zone' }, messageBox(), h('h2', null, 'Удалить аккаунт'), h('p', null, 'Будут удалены аккаунт и прогресс на сервере, все входы завершатся. Это нельзя отменить. На других устройствах локальные данные останутся.'), form(f => actions.deleteAccount(valueOf(f, 'au-del-pass')), passwordField('Введите пароль для подтверждения', 'current-password', 'au-del-pass'), h('div', { class: 'row' }, h('button', { type: 'submit', class: 'btn danger', disabled: state.busy }, 'Удалить навсегда'), linkButton('Отмена', () => { state.confirmDelete = false; state.message = null; render(); })))); } function accountPanel() { if (state.confirmDelete) return deleteForm(); return h('div', { class: 'panel authbox' }, messageBox(), h('div', null, h('b', null, state.user), h('div', { class: 'muted small' }, 'Вы вошли в аккаунт')), h('p', null, 'Прогресс, история и статистика клавиш сохраняются на сервере и доступны на любом устройстве после входа. Настройки и раскладка Vial остаются на этом устройстве.'), h('div', { class: 'muted small', role: 'status' }, state.syncText || 'Синхронизация ещё не выполнялась.'), h('div', { class: 'row' }, h('button', { class: 'btn', type: 'button', disabled: state.syncing, onclick: syncNow }, 'Синхронизировать сейчас'), h('button', { class: 'btn', type: 'button', disabled: state.busy, onclick: actions.logout }, 'Выйти'), linkButton('Удалить аккаунт…', () => { state.confirmDelete = true; state.message = null; render(); }))); } function render() { const root = document.getElementById('v-account'); if (!root) return; root.innerHTML = ''; const subtitle = state.user ? 'Прогресс сохраняется на сервере и доступен на других устройствах.' : 'Войдите, чтобы хранить прогресс на сервере. Без входа всё работает как раньше: данные остаются в этом браузере.'; root.append(h('div', { class: 'page-head' }, h('div', null, h('h1', null, 'Аккаунт'), h('p', { class: 'muted' }, subtitle)))); const panel = state.user ? accountPanel() : guestPanel(); root.append(...[].concat(panel)); if (state.message && state.message.type === 'err' && !state.busy) { const first = root.querySelector('input'); if (first) first.focus(); // после ошибки возвращаем фокус в форму, а не на начало страницы } } // ---------- Запуск ---------- async function init(context) { ctx = context; try { const health = await call('GET', 'health'); state.available = health.ok && health.data.ok === true; } catch (e) { state.available = false; } if (!state.available) return false; try { const me = await call('GET', 'me'); state.user = me.ok ? me.data.email : null; } catch (e) { /* остаёмся гостем */ } ctx.addNav(state.user ? 'Аккаунт' : 'Войти'); window.addEventListener('hashchange', handleLink); // ссылка из письма, вставленная в уже открытую вкладку window.addEventListener('online', () => syncNow()); handleLink(); if (state.user) { adoptAccount(state.user); syncNow(); } return true; } window.Auth = { init, render, afterRun, syncNow, clearServerProgress, isLoggedIn: () => !!state.user }; })();