/* ============================================================ auth.jsx — ПРОТОТИП авторизации (mock-бэкенд). Реализует UX по ТЗ: вход по номеру телефона с кодом страны, пароль задаёт пользователь, подтверждение через SMS (2FA), договор оферты нужно прочитать и нажать «Согласен» до оплаты. ВНИМАНИЕ — это фронтенд-прототип: · пользователи хранятся в localStorage (не в БД); · SMS не отправляются — код показывается в модалке (демо); · пароль хешируется SHA-256 на клиенте лишь чтобы не держать его в открытом виде; в бою хеширование/соль — на сервере. Для продакшена нужен backend + реальный SMS-провайдер; точки подключения помечены комментарием // BACKEND:. ============================================================ */ const AUTH_USERS_KEY = "al_users_v1"; const AUTH_SESSION_KEY = "al_session_v1"; /* коды стран для селектора (value = ISO, чтобы +7 RU и +7 KZ различались) */ const COUNTRY_CODES = [ { iso: "RU", code: "+7", name: "Россия" }, { iso: "KZ", code: "+7", name: "Казахстан" }, { iso: "BY", code: "+375", name: "Беларусь" }, { iso: "UA", code: "+380", name: "Украина" }, { iso: "UZ", code: "+998", name: "Узбекистан" }, { iso: "KG", code: "+996", name: "Кыргызстан" }, { iso: "CN", code: "+86", name: "中国" }, { iso: "US", code: "+1", name: "USA/Canada" }, { iso: "GB", code: "+44", name: "UK" }, { iso: "DE", code: "+49", name: "Germany" }, { iso: "AE", code: "+971", name: "UAE" }, ]; const isoToCode = (iso) => (COUNTRY_CODES.find((c) => c.iso === iso) || COUNTRY_CODES[0]).code; /* ---- SHA-256 (см. примечание в шапке) ---- */ async function sha256(str) { const buf = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)); return [...new Uint8Array(buf)].map((b) => b.toString(16).padStart(2, "0")).join(""); } /* ---- store ---- */ function _loadUsers() { try { return JSON.parse(localStorage.getItem(AUTH_USERS_KEY) || "{}"); } catch (e) { return {}; } } function _saveUsers(u) { try { localStorage.setItem(AUTH_USERS_KEY, JSON.stringify(u)); } catch (e) {} } const _authSubs = new Set(); function _notifyAuth() { _authSubs.forEach((fn) => fn()); } const normalizePhone = (iso, raw) => isoToCode(iso) + String(raw || "").replace(/\D/g, ""); const gen6 = () => String(Math.floor(100000 + Math.random() * 900000)); /* authApi — backend-first, с localStorage как кэшем/фолбэком. Когда сервер (backend/server.py) доступен, авторизация и покупки идут через него; локальное хранилище служит синхронным кэшем текущего пользователя, чтобы useAuth/Learn/шапка работали без переписывания. Когда сервера нет — всё работает на localStorage (демо-прототип). */ const authApi = { currentPhone() { try { return localStorage.getItem(AUTH_SESSION_KEY) || null; } catch (e) { return null; } }, // uid getUser(uid) { return _loadUsers()[uid] || null; }, exists(uid) { return !!_loadUsers()[uid]; }, listUsers() { const u = _loadUsers(); return Object.keys(u).map((k) => u[k]); }, // для панели автора (офлайн) /* какие способы входа доступны на сервере (для отрисовки кнопок) */ async providers() { try { if (await backendUp()) return await apiGet("/api/auth/providers", { auth: false }); } catch (e) {} return { vk: false, yandex: false, telegram: { enabled: false, bot: "" }, email: true, demo: true }; }, /* VK ID / Яндекс ID: получить URL авторизации и уйти на провайдера. Если провайдер не настроен на сервере — демо-вход тестовым пользователем. */ async oauthLogin(provider) { if (await backendUp()) { try { const r = await apiGet("/api/auth/oauth/" + provider + "/start", { auth: false }); if (r && r.url) { window.location.href = r.url; return { redirect: true }; } if (r && r.demo) return await this.demoLogin(provider); } catch (e) { /* упадём в демо ниже */ } } return await this.demoLogin(provider); }, /* Telegram: реальный Login-виджет отдаёт объект с полем hash; демо — {} */ async telegramLogin(data) { if (await backendUp()) { try { const r = await apiPost("/api/auth/telegram", data || {}, { auth: false }); if (r && r.token) return this._applySession(r); } catch (e) { if (e && e.status && e.status !== 501) throw e; } } return await this.demoLogin("telegram"); }, /* демо-вход (сервер с DEV_AUTH или полностью офлайн) */ async demoLogin(provider, extra) { if (await backendUp()) { try { const r = await apiPost("/api/auth/demo", Object.assign({ provider }, extra || {}), { auth: false }); if (r && r.token) return this._applySession(r); } catch (e) {} } const uid = provider + ":demo"; const names = { vk: "Демо · VK ID", yandex: "Демо · Яндекс ID", telegram: "Демо · Telegram", email: "Демо · E-mail" }; const user = { uid, name: (extra && extra.name) || names[provider] || "Демо", email: (extra && extra.email) || "", provider }; this._cacheUser(Object.assign({ purchased: [] }, user)); this._setSession(uid); return user; }, /* e-mail: старт — сервер (или мок) отправляет код и magic-link */ async emailStart(email, name) { email = String(email || "").trim().toLowerCase(); if (await backendUp()) { const r = await apiPost("/api/auth/email/start", { email, name: name || "" }, { auth: false }); return { email, backend: true, code: (r && r.dev_code) || null, link: (r && r.dev_link) || null }; } return { email, backend: false, code: gen6() }; }, /* e-mail: подтверждение кода */ async emailVerify(pending, enteredCode) { const code = String(enteredCode || "").replace(/\D/g, ""); if (pending.backend) { const r = await apiPost("/api/auth/email/verify", { email: pending.email, code }, { auth: false }); if (r && r.token) return this._applySession(r); return; } if (code !== pending.code) { const e = new Error("badcode"); e.code = "badcode"; throw e; } const uid = "email:" + pending.email; const user = { uid, name: pending.email.split("@")[0], email: pending.email, provider: "email" }; this._cacheUser(Object.assign({ purchased: [] }, user)); this._setSession(uid); return user; }, /* применить ответ сервера {token,user}: токен + кэш + сессия + профиль */ _applySession(r) { if (r.token) setToken(r.token); this._cacheUser(r.user); this._setSession(r.user.uid); this._refreshMe(); return r.user; }, /* кэш текущего пользователя в al_users_v1 — чтобы синхронные хуки работали */ _cacheUser(user) { if (!user || !user.uid) return; const cur = _loadUsers(); const prev = cur[user.uid] || {}; cur[user.uid] = Object.assign({ purchased: [], createdAt: Date.now() }, prev, { uid: user.uid, name: user.name || prev.name || "", email: user.email || prev.email || "", avatar: user.avatar || prev.avatar || "", provider: user.provider || prev.provider || "", }); _saveUsers(cur); }, /* подтянуть профиль + покупки с сервера, обновить кэш */ async _refreshMe() { if (!(await backendUp()) || !getToken()) return; try { const me = await apiGet("/api/me"); if (me && me.user) { this._cacheUser(me.user); const cur = _loadUsers(); if (cur[me.user.uid]) { cur[me.user.uid].purchased = me.purchased || []; _saveUsers(cur); } _notifyAuth(); } } catch (e) { if (e && e.status === 401) this.logout(); } // токен истёк }, _setSession(uid) { try { localStorage.setItem(AUTH_SESSION_KEY, uid); } catch (e) {} _notifyAuth(); }, logout() { setToken(null); try { localStorage.removeItem(AUTH_SESSION_KEY); } catch (e) {} _notifyAuth(); }, async markPurchased(uid, tariffId) { if ((await backendUp()) && getToken()) { try { await apiPost("/api/purchase", { tariff: tariffId }); } catch (e) {} } const users = _loadUsers(); users[uid] = Object.assign({ uid, purchased: [], createdAt: Date.now() }, users[uid] || {}); users[uid].purchased = Array.from(new Set([...(users[uid].purchased || []), tariffId])); _saveUsers(users); _notifyAuth(); }, }; /* при загрузке страницы: если есть токен — проверяем сессию и подтягиваем профиль */ if (typeof getToken === "function" && getToken()) { setTimeout(() => authApi._refreshMe(), 0); } /* subscribe hook — компоненты обновляются при входе/выходе */ function useAuth() { const [phone, setPhone] = React.useState(authApi.currentPhone()); React.useEffect(() => { const fn = () => setPhone(authApi.currentPhone()); _authSubs.add(fn); return () => { _authSubs.delete(fn); }; }, []); return { phone, user: phone ? authApi.getUser(phone) : null, logout: () => authApi.logout() }; } /* ============================================================ AuthModal — экраны входа / регистрации + 2FA по SMS ============================================================ */ function AuthField({ label, type = "text", value, onChange, placeholder, autoFocus }) { const [focus, setFocus] = React.useState(false); return ( ); } /* кнопка входа через провайдера (VK ID / Яндекс ID / Telegram) */ function ProviderBtn({ onClick, disabled, bg, fg, badge, label }) { return ( ); } /* официальный Telegram Login Widget — рендерит кнопку Telegram и отдаёт данные входа в колбэк (проверка подписи — на бэкенде). */ function TelegramLoginButton({ bot, onAuth }) { const ref = React.useRef(null); React.useEffect(() => { window.__alTgAuth = (user) => { try { onAuth(user); } catch (e) {} }; const s = document.createElement("script"); s.src = "https://telegram.org/js/telegram-widget.js?22"; s.async = true; s.setAttribute("data-telegram-login", bot); s.setAttribute("data-size", "large"); s.setAttribute("data-radius", "10"); s.setAttribute("data-onauth", "__alTgAuth(user)"); s.setAttribute("data-request-access", "write"); const el = ref.current; if (el) { el.innerHTML = ""; el.appendChild(s); } return () => { try { delete window.__alTgAuth; } catch (e) {} }; }, [bot]); return
; } /* ============================================================ AuthModal — вход через VK ID · Яндекс ID · Telegram · E-mail (код или magic-link). Без SMS. Показываются только настроенные способы (по /api/auth/providers); в демо-режиме — все. ============================================================ */ function AuthModal({ open, onClose, onAuthed }) { const { t } = useT(); const [step, setStep] = React.useState("choose"); // choose | code const [email, setEmail] = React.useState(""); const [code, setCode] = React.useState(""); const [pending, setPending] = React.useState(null); // {email, backend, code?, link?} const [err, setErr] = React.useState(""); const [busy, setBusy] = React.useState(false); const [prov, setProv] = React.useState(null); // конфиг способов входа React.useEffect(() => { if (open) { setStep("choose"); setErr(""); setCode(""); setPending(null); if (authApi.providers) authApi.providers().then(setProv); } }, [open]); React.useEffect(() => { if (window.lucide) lucide.createIcons(); }); if (!open) return null; // какие способы показывать: в демо — все; в бою — только настроенные const demo = !prov || prov.demo; const showVk = demo || (prov && prov.vk); const showYandex = demo || (prov && prov.yandex); const tgReal = !!(prov && prov.telegram && prov.telegram.enabled && prov.telegram.bot); const showTelegram = demo || tgReal; const showEmail = demo || (prov && prov.email); const emailOk = /^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email.trim()); const finishAuthed = () => { const uid = authApi.currentPhone(); onClose(); // по ТЗ: после входа сразу в личный кабинет. Если у вызова свой обработчик // (форма оплаты / сам кабинет) — отдаём управление ему; иначе → learn.html. if (onAuthed) onAuthed(uid); else window.location.href = "learn.html"; }; const doProvider = async (provider) => { setErr(""); setBusy(true); try { const res = await authApi.oauthLogin(provider); // vk | yandex if (res && res.redirect) return; // ушли на страницу провайдера finishAuthed(); // демо-вход прошёл сразу } catch (e) { setErr("Не удалось войти. Попробуйте ещё раз."); setBusy(false); } }; const doTelegram = async () => { setErr(""); setBusy(true); try { // Боевой вход: Telegram Login Widget передаёт объект с полем hash в // window.__tgAuthData (см. комментарий ниже). Иначе — демо/сервер. await authApi.telegramLogin(window.__tgAuthData || {}); finishAuthed(); } catch (e) { setErr("Не удалось войти через Telegram."); setBusy(false); } }; const startEmail = async (e) => { if (e) e.preventDefault(); setErr(""); if (!emailOk) return setErr("Введите корректный e-mail"); setBusy(true); try { const p = await authApi.emailStart(email.trim(), ""); setPending(p); setStep("code"); } catch (e2) { setErr(e2.code === "bad_email" ? "Введите корректный e-mail" : "Не удалось отправить письмо"); } finally { setBusy(false); } }; const verifyEmail = async (e) => { if (e) e.preventDefault(); setErr(""); setBusy(true); try { await authApi.emailVerify(pending, code); finishAuthed(); } catch (e2) { const bad = ["badcode", "bad_code", "code_expired"].indexOf(e2.code) >= 0; setErr(bad ? "Неверный или истёкший код" : "Ошибка входа"); setBusy(false); } }; const shell = (children) => (
e.stopPropagation()} style={{ width: "min(440px, 100%)", background: "var(--paper)", borderRadius: 14, border: "1px solid var(--hairline)", boxShadow: "0 40px 120px rgba(14,14,12,0.28)", padding: "clamp(26px,4vw,38px)", position: "relative" }}> {children}
); if (step === "code") { return shell(

Введите код из письма

Мы отправили код и ссылку для входа на {pending.email}.

{/* демо-подсказка: код/ссылка известны клиенту только в деморежиме */} {pending && (pending.code || pending.link) && (
{pending.code && (
Демо-код:{" "} {pending.code}
)} {pending.link && ( Войти по magic-ссылке )}
)} {err &&

{err}

}
); } return shell(

Войдите удобным способом

{showVk && ( doProvider("vk")} disabled={busy} bg="#0077FF" fg="#fff" badge="VK" label="Войти через VK ID" /> )} {showYandex && ( doProvider("yandex")} disabled={busy} bg="#FC3F1D" fg="#fff" badge="Я" label="Войти через Яндекс ID" /> )} {showTelegram && (tgReal ? { setErr(""); setBusy(true); try { await authApi.telegramLogin(user); finishAuthed(); } catch (e) { setErr("Не удалось войти через Telegram."); setBusy(false); } }} /> : } label="Войти через Telegram" /> )}
{/* разделитель + e-mail (если способ доступен) */} {showEmail && (
или по e-mail
setEmail(e.target.value)} placeholder="you@example.com" autoFocus style={{ width: "100%", height: 50, padding: "0 15px", background: "var(--paper)", border: "1px solid var(--hairline-2)", borderRadius: 8, fontFamily: "var(--font-sans)", fontSize: 15.5, color: "var(--ink)", outline: "none" }} />
)} {err &&

{err}

}

Нажимая «Войти», вы соглашаетесь с условиями оферты и обработкой персональных данных.

); } /* ============================================================ OfferModal — договор оферты; «Согласен» активна после прочтения ============================================================ */ function OfferModal({ open, onClose, onAccept }) { const { t } = useT(); const [canAccept, setCanAccept] = React.useState(false); const bodyRef = React.useRef(null); React.useEffect(() => { if (open) setCanAccept(false); }, [open]); if (!open) return null; const paras = t("auth.offerBody"); const onScroll = () => { const el = bodyRef.current; if (!el) return; if (el.scrollTop + el.clientHeight >= el.scrollHeight - 24) setCanAccept(true); }; return (
e.stopPropagation()} style={{ width: "min(680px, 100%)", maxHeight: "86vh", background: "var(--paper)", borderRadius: 14, border: "1px solid var(--hairline)", boxShadow: "0 40px 120px rgba(14,14,12,0.28)", display: "flex", flexDirection: "column", padding: "clamp(24px,3.4vw,34px)" }}>

{t("auth.offerTitle")}

{(Array.isArray(paras) ? paras : [String(paras)]).map((p, i) => (

{p}

))}
{canAccept ? "" : t("auth.offerReadHint")}
); } /* ============================================================ AccountChip — «Войти» / телефон+выход (для шапки подстраниц) ============================================================ */ function AccountChip({ height = 44 }) { const { t } = useT(); const { phone, user, logout } = useAuth(); const [open, setOpen] = React.useState(false); const dot = Math.min(28, height - 6); // размер круглой кнопки выхода под высоту чипа if (phone) { const who = (user && (user.name || user.email)) || "Профиль"; return (
{t("learn.title")}
{who}
); } return ( setOpen(false)} /> ); } Object.assign(window, { COUNTRY_CODES, normalizePhone, authApi, useAuth, AuthModal, OfferModal, AccountChip, });