/* login-app.jsx — точка входа страницы логина */ const { useState, useEffect, useRef, useCallback } = React; /* ---- RP config (in production comes from query params) ---- */ const RP = AUTH.RP; /* i18n: window.tr задаётся контрактом i18n/i18n.js (ru — fallback-строки в коде) */ const tr = window.tr || ((k, f) => f); /* минимальная длина пароля — из hydrate-данных (AUTH.POLICY.passwordMin), fallback 8 */ const pwMin = () => ((AUTH.POLICY || {}).passwordMin) || 8; /* организации пользователя для мультиорг-чузера (login-hydrate.orgs или мок AUTH.ORGS) */ const loginOrgs = () => (window.LOGIN_HYDRATE || {}).orgs || (window.AUTH && window.AUTH.ORGS) || []; /* экран после успешной аутентификации: организация уже выбрана → согласие; несколько организаций и не выбрана → экран выбора; иначе → согласие */ function postAuthState(org) { return org ? "consent" : (loginOrgs().length > 1 ? "chooseorg" : "consent"); } /* canonical result действия: resolve {next?} — next говорит, каким шагом продолжить (standalone-journey); "postauth" → выбор организации/согласие. В prod адаптер делает реальный redirect и не возвращает next — UI никуда не переходит сам. */ function nextState(r, org) { const n = r && r.next; if (!n) return null; return n === "postauth" ? postAuthState(org) : n; } /* поток доступен только при точном action (fail-closed) */ const LA = () => window.LOGIN_ACTIONS || {}; function FlowUnavailable({ go, back = "main", label }) { return (
{tr("flow.unavailable.eyebrow","Поток недоступен")}

{label || tr("flow.unavailable","Этот способ входа не настроен для данного запроса авторизации.")}

); } /* ---- micro icon ---- */ const PATHS = { mail:"M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2zm0 2l8 5 8-5M4 6l8 5 8-5", key:"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0 3 3L22 7l-3-3m-3.5 3.5L19 4", shield:"M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z", check:"M20 6L9 17l-5-5", x:"M18 6L6 18M6 6l12 12", arrowR:"M5 12h14M12 5l7 7-7 7", back:"M19 12H5M12 19l-7-7 7-7", user:"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 7a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", bolt:"M13 2L3 14h9l-1 8 10-12h-9l1-8z", ok:"M22 11.08V12a10 10 0 1 1-5.93-9.14M22 4L12 14.01l-3-3", alert:"M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0zM12 9v4M12 17h.01", lock:"M19 11H5a2 2 0 00-2 2v7a2 2 0 002 2h14a2 2 0 002-2v-7a2 2 0 00-2-2zM7 11V7a5 5 0 0110 0v4", google:"", // handled separately command:"M18 3a3 3 0 0 0-3 3v12a3 3 0 0 0 3 3 3 3 0 0 0 3-3 3 3 0 0 0-3-3H6a3 3 0 0 0-3 3 3 3 0 0 0 3 3 3 3 0 0 0 3-3V6a3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3h12a3 3 0 0 0 3-3 3 3 0 0 0-3-3z", }; function Icon({ name, size = 18, style }) { const d = PATHS[name]; if (!d) return null; return ( {d.split("M").filter(Boolean).map((seg, i) => )} ); } function GoogleIcon({ size = 18 }) { return ( ); } function SkinToggle() { const [skin, setSkin] = useState(() => localStorage.getItem("ri_skin") || "paper"); function toggle() { const next = skin === "paper" ? "ink" : "paper"; setSkin(next); localStorage.setItem("ri_skin", next); document.documentElement.setAttribute("data-skin", next); } return ; } /* ---- left brand pane ---- */ function LoginAside() { return ( ); } /* ---- RP strip (shared) ---- */ function RPStrip() { return (
{RP.initial}
{tr("rp.eyebrow","Запрос авторизации")}
{RP.name}
OIDC
); } /* ---- state: MAIN (choose method) ---- */ /* Кнопки методов входа рендерятся ТОЛЬКО из window.LOGIN_HYDRATE.providers[] (контракт login-hydrate v1): kind=password → локальный переход go("email") (без selectProvider — реальный вызов один, на шаге пароля); kind=oidc → await LOGIN_ACTIONS.selectProvider(id) — реальный редирект делает адаптер, отказ → баннер под кнопками; kind=passkey / kind=magic — рендерятся только если сервер прислал такой элемент (сегодня — нет); providers:[] — валидно: ни одной кнопки метода, остаётся статический текст согласия. */ function MainPane({ go, provErr, setProvErr }) { const providers = (window.LOGIN_HYDRATE || {}).providers || []; async function pick(p) { setProvErr(null); const fn = LA().selectProvider; if (!fn) { setProvErr({ message: tr("main.providerUnavailable", "Провайдер входа недоступен: адаптер не подключён") }); return; } go("redirect", p.id); // busy-экран «Переход…», пока промис не разрешился try { const r = await fn(p.id); /* prod: адаптер уже увёл со страницы реальным redirect'ом (resolve без next); standalone: mock возвращает canonical next — продолжаем journey ПОСЛЕ resolve */ const n = nextState(r, null); if (n) go(n); } catch (e) { setProvErr(e && e.message ? e : { message: tr("main.providerFail", "Не удалось перейти к провайдеру входа") }); go("main"); } } const big = { height:"calc(var(--ctrl-h) + 8px)", justifyContent:"flex-start", gap:12, paddingLeft:14 }; return (
{tr("main.eyebrow","Войти в аккаунт")}

{tr("main.title","Добро пожаловать")}

{tr("main.sub","Выберите способ входа.")}

{providers.map(p => { if (p.kind === "password") return ( ); if (p.kind === "passkey") return ( ); if (p.kind === "magic") return ( ); /* kind=oidc (и неизвестные kind будущих версий контракта) — внешний провайдер */ return ( ); })}
{provErr &&

{provErr.message || tr("main.providerFail","Не удалось перейти к провайдеру входа")}

}

Входя, вы принимаете e.preventDefault()} style={{ color:"var(--accent)" }}>условия использования и e.preventDefault()} style={{ color:"var(--accent)" }}>политику конфиденциальности.

{tr("main.or","или")}

{tr("main.noAccount","Нет аккаунта?")}

); } /* ---- state: EMAIL ---- */ function EmailPane({ go, email, setEmail }) { const [touched, setTouched] = useState(false); const [shake, setShake] = useState(false); const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, []); const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email); // подсказка SSO-домена — из гидрации (LOGIN_HYDRATE.sso); fallback — мок AUTH.SSO_DOMAINS const SSO_DOMAINS = (window.LOGIN_HYDRATE || {}).sso || AUTH.SSO_DOMAINS || {}; const sso = valid ? SSO_DOMAINS[email.split("@")[1]] : null; function submit(e) { e.preventDefault(); setTouched(true); if (!valid) { setShake(true); ref.current?.focus(); setTimeout(() => setShake(false), 340); return; } go("password"); } return (
{tr("email.step","Шаг 1 из 2 · e-mail")}

Ваш e-mail

Определим SSO для вашей организации или запросим пароль.

{ setEmail(e.target.value); setTouched(false); }} onBlur={() => setTouched(true)} style={touched && email && !valid ? { borderColor:"var(--danger)" } : {}} />
{touched && email && !valid && {tr("email.invalid","Введите корректный адрес")}} {sso &&
Обнаружен {sso} для вашей организации
}
); } /* ---- state: PASSWORD ---- */ function PasswordPane({ go, email, org }) { const [pw, setPw] = useState(""); const [shake, setShake] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); // { code, message } из reject LOGIN_ACTIONS.submitPassword const ref = useRef(null); useEffect(() => { ref.current?.focus(); }, []); async function submit(e) { e.preventDefault(); if (!pw) { setShake(true); ref.current?.focus(); setTimeout(() => setShake(false), 340); return; } if (!LA().submitPassword) { setErr({ message: tr("password.unavailable", "Вход по паролю недоступен: адаптер не подключён") }); return; } setBusy(true); setErr(null); try { const r = await LA().submitPassword({ email, password: pw, sharedComputer: false }); /* в проде адаптер уже увёл со страницы реальным редиректом (resolve без next); в standalone mock возвращает canonical next — строго ПОСЛЕ await, не оптимистично */ const n = nextState(r, org); if (n) go(n); else setBusy(false); } catch (e2) { setBusy(false); setErr(e2 && e2.message ? e2 : { message: tr("password.fail", "Не удалось войти. Попробуйте ещё раз.") }); } } return (
{tr("password.step","Шаг 2 из 2 · пароль")}

Ваш пароль

{email[0]?.toUpperCase()}
{email}
{typeof LA().forgotPassword === "function" && }
setPw(e.target.value)} disabled={busy} />
{err &&

{err.message}

}

Проблемы со входом? e.preventDefault()} style={{ color:"var(--accent)" }}>Обратиться в поддержку

); } /* ---- state: SSO/PASSKEY REDIRECT ---- */ function RedirectPane({ go, provider, org }) { // метаданные провайдера — из гидрации (произвольные id), старые имена — fallback const provMeta = ((window.LOGIN_HYDRATE || {}).providers || []).find(x => x.id === provider); const meta = { passkey:"Passkey / WebAuthn", google:"Google", okta:"Okta SSO" }[provider] || (provMeta && provMeta.label ? provMeta.label.replace(/^Войти через\s+/i, "") : provider); const iconHint = (provMeta && provMeta.iconHint) || provider; /* исход (redirect или canonical next) полностью принадлежит runtime-action selectProvider — никакого таймерного успеха здесь нет */ return (

{provider === "passkey" ? <>Ожидание Passkey… : <>Переход в {meta}…}

{provider === "passkey" ? "Подтвердите вход отпечатком пальца, лицом или ключом безопасности." : `${meta} проверит вашу личность и вернёт вас для подтверждения доступа.`}

A
{AUTH.BRAND.org}
{iconHint === "google" ? : provider === "passkey" ? : }
{meta}
{RP.initial}
{RP.name.split(" ")[1] || RP.name}
); } /* ---- state: CHOOSE ORG — мультиорг-выбор (Google-style account/org chooser). Выбор ОТПРАВЛЯЕТСЯ на сервер тем же образцом, что пароль: канонический action LOGIN_ACTIONS.selectOrganization({ orgId }) → resolve {next?} решает, какой шаг дальше (жёсткого перехода на consent здесь НЕТ). Пока промис не разрешён — pending и блокировка повторных нажатий; reject — инлайн-ошибка на этом же экране (это не «неверный пароль», на шаг пароля не возвращаем). Список приходит готовым: элементы гарантированно имеют id, name, role; вход возможен в каждый — состояний «истекла»/«требуется вход» в системе нет. ---- */ function ChooseOrgPane({ go, setOrg }) { const orgs = loginOrgs(); const [busyId, setBusyId] = useState(null); const [err, setErr] = useState(null); // { code, message } из reject selectOrganization async function pick(o) { if (busyId) return; if (!LA().selectOrganization) { setErr({ message: tr("chooseorg.unavailable", "Выбор организации недоступен: адаптер не подключён") }); return; } setBusyId(o.id); setErr(null); try { const r = await LA().selectOrganization({ orgId: o.id }); if (setOrg) setOrg(o); /* следующий шаг — из ответа сервера; в проде адаптер уводит со страницы реальным редиректом (resolve без next) и UI остаётся в pending */ const n = nextState(r, o); if (n) go(n); else setBusyId(null); } catch (e2) { setBusyId(null); setErr(e2 && e2.message ? e2 : { message: tr("chooseorg.fail", "Не удалось выбрать организацию. Попробуйте ещё раз.") }); } } return (
{tr("chooseorg.eyebrow","Выбор организации")}

{tr("chooseorg.title","В какой организации продолжить?")}

{RP.name} {tr("chooseorg.sub","получит доступ в выбранной организации.")}

{AUTH.USER.initials}
{AUTH.USER.name}
{AUTH.USER.email}
{orgs.map(o => { const busy = busyId === o.id; return ( ); })} {!orgs.length &&

{tr("chooseorg.empty","Нет доступных организаций.")}

}
{err &&

{err.message}

}
); } /* ---- state: CONSENT ---- */ function ConsentPane({ go, twofa, org }) { const ROWS = AUTH.CONSENT_SCOPES; const [busy, setBusy] = useState(null); // "allow" | "deny" | null const [err, setErr] = useState(null); const hasDecision = !!LA().consentDecision; async function decide(approved, sel) { if (busy) return; if (!hasDecision) { setErr({ message: tr("consent.unavailable", "Решение по согласию недоступно: адаптер не подключён") }); return; } setBusy(approved ? "allow" : "deny"); setErr(null); try { const r = await LA().consentDecision({ approved, scopes: Object.keys(sel || {}).filter(k => sel[k]) }); const n = nextState(r, org); if (n) go(n === "twofa" && twofa === false ? "success" : n); else setBusy(null); } catch (e) { setErr(e && e.message ? e : { message: tr("consent.fail", "Не удалось обработать решение") }); setBusy(null); } } const [sel, setSel] = useState({ openid:true, profile:true, email:true, "projects:read":true }); const toggle = k => { if (ROWS.find(r=>r.k===k)?.required) return; setSel(s => ({...s, [k]:!s[k]})); }; function CheckMark() { return ; } /* scope `mail` запрошен, но email_verified=false (из гидрации) — вместо финального access_denied ведём на подтверждение адреса (проверка НЕ обходится: без подтверждения авторизация дальше не идёт) */ const needMailVerify = ((window.LOGIN_HYDRATE || {}).emailVerified === false) && ROWS.some(r => r.k === "mail"); return (
{tr("consent.eyebrow","Запрос доступа")}

{RP.name} запрашивает доступ

Вы всегда можете отозвать разрешения в настройках аккаунта.

{AUTH.USER.initials}
{AUTH.USER.name}
{AUTH.USER.email}
{org && (
{org.initials}
{tr("consent.org","Организация")} · {org.name}
{loginOrgs().length > 1 && }
)}
{ROWS.map(r => (
toggle(r.k)}>
{sel[r.k] && }
{r.n}
{r.d}
{r.required && required}
))}
{err &&

{err.message}

}
); } /* ---- state: 2FA ---- */ function TwoFAPane({ go }) { const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const ref = useRef(null); const avail = !!LA().submitTwoFactor; useEffect(() => { ref.current?.focus(); }, []); useEffect(() => { if (!avail || busy || code.length !== 6) return; let cancelled = false; (async () => { setBusy(true); setErr(null); try { const r = await LA().submitTwoFactor({ code }); if (cancelled) return; const n = nextState(r, null); if (n) go(n); else setBusy(false); } catch (e) { if (cancelled) return; setErr(e && e.message ? e.message : tr("twofa.fail", "Неверный код. Попробуйте ещё раз.")); setCode(""); setBusy(false); ref.current && ref.current.focus(); } })(); return () => { cancelled = true; }; }, [code, avail]); if (!avail) return ; const cells = Array.from({ length:6 }, (_, i) => code[i] || ""); return (
{tr("twofa.eyebrow","Двухфакторная аутентификация")}

Код подтверждения

Введите 6-значный код из приложения-аутентификатора.

ref.current?.focus()} style={{ cursor:"text" }}> {cells.map((c, i) => (
{c || (code.length === i ? : "")}
))}
setCode(e.target.value.replace(/\D/g,"").slice(0,6))} inputMode="numeric" style={{ position:"absolute", left:-9999, opacity:0 }} aria-label="код" />
{busy && {tr("twofa.checking","Проверяем…")}} {err && {err}}
); } /* ---- avatar with ok badge ---- */ function AvatarOk({ size = 76, name = AUTH.USER.name }) { const initials = name.split(" ").map(w => w[0]).join("").slice(0,2).toUpperCase(); const r = size * 0.22; return (
{initials}
); } /* ---- state: SUCCESS ---- */ function SuccessPane() { /* целевые URL — ТОЛЬКО из CONSOLE_URLS (runtime); пусто → нет авто-перехода. Никаких локальных признаков авторизации (localStorage) страница не пишет. */ const URLS = window.CONSOLE_URLS || {}; const appUrl = URLS.app || ""; const accountUrl = URLS.account || ""; const [c, setC] = useState(appUrl ? 3 : -1); const [fly, setFly] = useState(null); // {top,left,size,tx,ty,phase} const avRef = useRef(null); useEffect(() => { if (!appUrl || c < 0) return; if (c <= 0) { launch(); return; } const t = setTimeout(() => setC(c - 1), 1000); return () => clearTimeout(t); }, [c]); function launch() { if (fly || !appUrl) return; const el = avRef.current; if (!el) { window.location.href = appUrl; return; } const r = el.getBoundingClientRect(); const size = r.width; const cx = r.left + size / 2, cy = r.top + size / 2; // target = top-right corner (where Atlas profile lives) const tx = (window.innerWidth - 48) - cx; const ty = 28 - cy; try { sessionStorage.setItem('atlas_entrance', 'corner'); } catch (e) {} // косметика анимации, не признак авторизации setFly({ top: r.top, left: r.left, size, tx, ty, phase: 'placed' }); } // after clone mounted (placed) → next frame trigger flight useEffect(() => { if (!fly || fly.phase !== 'placed') return; const id = requestAnimationFrame(() => requestAnimationFrame(() => { setFly(f => ({ ...f, phase: 'flying' })); setTimeout(() => { if (appUrl) window.location.href = appUrl; }, 640); })); return () => cancelAnimationFrame(id); }, [fly]); const flying = !!fly; return ( <>
Вход выполнен

С возвращением, {AUTH.USER.firstName}

{RP.initial}
{RP.name}
Авторизация выдана · {RP.scopes.length} scope
{appUrl ? (
переход через
{Math.max(c,0)}
) : null}
{RP.scopes.map(s => ( {s} ))}
{(accountUrl || appUrl) && ( )}
{fly && ReactDOM.createPortal(
, document.body )} ); } /* ---- state: ERROR ---- */ function ErrorPane({ go }) { return (

Доступ запрещён

Запрос авторизации отменён или не может быть выполнен.

{(() => { /* контекст ошибки приходит ТОЛЬКО из гидрации (адаптер/runtime); локальных error/request ID страница не генерирует */ const er = (window.LOGIN_HYDRATE || {}).error; if (!er || (!er.code && !er.description && !er.requestId)) return null; return (
{er.code &&
error{" "}{er.code}
} {er.description &&
desc{" "}{er.description}
} {er.requestId &&
req_id{" "}{er.requestId}
}
); })()}
); } /* ---- state: FORGOT ---- */ function ForgotPane({ go, email }) { const [sent, setSent] = useState(false); const [em, setEm] = useState(email || ""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const avail = typeof LA().forgotPassword === "function"; async function send() { if (!em || busy || !avail) return; setBusy(true); setErr(null); try { await LA().forgotPassword(em); setSent(true); // строго после await — не оптимистично } catch (e2) { setErr(e2 && e2.message ? e2 : { message: tr("forgot.fail", "Не удалось отправить ссылку") }); } finally { setBusy(false); } } if (!avail) return ; if (sent) return (

Письмо отправлено

Проверьте {em}. Ссылка действует 15 минут.

{AUTH.DEV_FLOWS && }
); return (
Восстановление доступа

Забыли пароль?

Отправим ссылку для сброса на ваш e-mail.

setEm(e.target.value)} placeholder="you@company.com" autoFocus />
{err &&

{err.message}

}
); } /* ---- state: REGISTER ---- */ function RegisterPane({ go, email, setEmail }) { const [form, setForm] = useState({ name:"", email: email||"", pw:"", pw2:"" }); const [busy, setBusy] = useState(false); const [err, setErr] = useState({}); const avail = !!LA().register; function set(k,v){ setForm(p=>({...p,[k]:v})); setErr(e=>({...e,[k]:undefined})); } async function submit(e){ e.preventDefault(); if (busy) return; const er={}; if(!form.name.trim()) er.name="Введите имя"; if(!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(form.email)) er.email="Некорректный e-mail"; if(form.pw.length < pwMin()) er.pw="Минимум " + pwMin() + " символов"; if(form.pw !== form.pw2) er.pw2="Пароли не совпадают"; if(Object.keys(er).length){ setErr(er); return; } if(!avail){ setErr({ form: "Регистрация недоступна: адаптер не подключён" }); return; } setBusy(true); setErr({}); try { const r = await LA().register({ name: form.name.trim(), email: form.email, password: form.pw }); setEmail(form.email); const n = nextState(r, null); if (n) go(n); else setBusy(false); } catch (e2) { setErr({ form: (e2 && e2.message) || "Не удалось создать аккаунт" }); setBusy(false); } } if (!avail) return ; const pwStrength = form.pw.length === 0 ? 0 : form.pw.length < pwMin() ? 1 : form.pw.length < pwMin() + 4 ? 2 : 3; const pwColors = ["","var(--danger)","var(--warn)","var(--ok)"]; const pwLabels = ["","Слабый","Хороший","Надёжный"]; return (
Новый аккаунт

Создать аккаунт

Заполните данные для регистрации в организации.

set("name",e.target.value)} disabled={busy} style={err.name?{borderColor:"var(--danger)"}:{}} /> {err.name && {err.name}}
set("email",e.target.value)} disabled={busy} style={err.email?{borderColor:"var(--danger)"}:{}} /> {err.email && {err.email}}
set("pw",e.target.value)} disabled={busy} style={err.pw?{borderColor:"var(--danger)"}:{}} /> {form.pw &&
{pwLabels[pwStrength]}
} {err.pw && {err.pw}}
set("pw2",e.target.value)} disabled={busy} style={err.pw2?{borderColor:"var(--danger)"}:{}} /> {err.pw2 && {err.pw2}}
{err.form &&

{err.form}

}

{tr("register.have","Уже есть аккаунт?")}

); } /* ---- state: VERIFY EMAIL ---- */ function VerifyPane({ go, email, org }) { const [code, setCode] = useState(""); const [err, setErr] = useState(null); const [busy, setBusy] = useState(false); const [resent, setResent] = useState(false); const [resendBusy, setResendBusy] = useState(false); const ref = useRef(null); const avail = !!LA().confirmRegistrationEmail; useEffect(()=>{ ref.current?.focus(); },[]); useEffect(()=>{ if (!avail || busy || code.length !== 6) { if (code.length !== 6) setErr(null); return; } let cancelled = false; (async () => { setBusy(true); setErr(null); try { const r = await LA().confirmRegistrationEmail({ code }); if (cancelled) return; const n = nextState(r, org); if (n) go(n); else setBusy(false); } catch (e) { if (cancelled) return; setErr((e && e.message) || "Неверный код. Попробуйте ещё раз."); setCode(""); setBusy(false); ref.current && ref.current.focus(); } })(); return () => { cancelled = true; }; },[code, avail]); async function resend() { if (resendBusy || resent || !LA().resendRegistrationEmail) return; setResendBusy(true); try { await LA().resendRegistrationEmail(); setResent(true); } catch (e) { setErr((e && e.message) || "Не удалось отправить письмо"); } finally { setResendBusy(false); } } if (!avail) return ; const cells = Array.from({length:6},(_,i)=>code[i]||""); return (
Подтверждение e-mail

Проверьте почту

Отправили код на {email||"your@email.com"}. Введите его ниже.

ref.current?.focus()} style={{ cursor:"text",marginBottom:err?8:16 }}> {cells.map((c,i)=>(
{c||(code.length===i?:"")}
))}
setCode(e.target.value.replace(/\D/g,"").slice(0,6))} inputMode="numeric" style={{ position:"absolute",left:-9999,opacity:0 }} aria-label="код" />
{busy && {tr("verify.checking","Проверяем…")}} {err && {err}}
); } /* ---- state: RESET PASSWORD ---- */ function ResetPane({ go }) { const [pw, setPw] = useState(""); const [pw2, setPw2] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState({}); const pwStrength = pw.length === 0 ? 0 : pw.length < pwMin() ? 1 : pw.length < pwMin() + 4 ? 2 : 3; const pwColors = ["","var(--danger)","var(--warn)","var(--ok)"]; const pwLabels = ["","Слабый","Хороший","Надёжный"]; const avail = !!LA().resetPassword; async function submit(e){ e.preventDefault(); if (busy) return; const er={}; if(pw.length < pwMin()) er.pw="Минимум " + pwMin() + " символов"; if(pw !== pw2) er.pw2="Пароли не совпадают"; if(Object.keys(er).length){ setErr(er); return; } if(!avail){ setErr({ form: "Сброс пароля недоступен: адаптер не подключён" }); return; } setBusy(true); setErr({}); try { const r = await LA().resetPassword({ password: pw }); const n = nextState(r, null); if (n) go(n); else setBusy(false); } catch (e2) { setErr({ form: (e2 && e2.message) || "Не удалось сохранить пароль" }); setBusy(false); } } return (
Восстановление доступа

Новый пароль

Придумайте надёжный пароль для вашего аккаунта.

{ setPw(e.target.value); setErr(r=>({...r,pw:undefined})); }} disabled={busy} style={err.pw?{borderColor:"var(--danger)"}:{}} /> {pw &&
{pwLabels[pwStrength]}
} {err.pw && {err.pw}}
{ setPw2(e.target.value); setErr(r=>({...r,pw2:undefined})); }} disabled={busy} style={err.pw2?{borderColor:"var(--danger)"}:{}} /> {err.pw2 && {err.pw2}}
Ссылка для сброса верифицирована и действительна.
{err.form &&

{err.form}

}
); } /* ---- state: MAGIC — вход без пароля: одноразовый код на почту ---- */ function MagicPane({ go, email, setEmail, org }) { const [phase, setPhase] = useState("email"); // email | code const [em, setEm] = useState(email || ""); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const ref = useRef(null); const avail = !!(LA().requestMagicCode && LA().confirmMagicCode); useEffect(() => { ref.current?.focus(); }, [phase]); useEffect(() => { if (phase !== "code" || busy || code.length !== 6) return; let cancelled = false; (async () => { setBusy(true); setErr(null); try { const r = await LA().confirmMagicCode({ code }); if (cancelled) return; const n = nextState(r, org); if (n) go(n); else setBusy(false); } catch (e) { if (cancelled) return; setErr((e && e.message) || "Код недействителен или истёк."); setCode(""); setBusy(false); ref.current && ref.current.focus(); } })(); return () => { cancelled = true; }; }, [code, phase]); async function requestCode(e) { e.preventDefault(); if (!valid || busy) return; setBusy(true); setErr(null); try { await LA().requestMagicCode({ email: em }); setEmail(em); setPhase("code"); } catch (e2) { setErr((e2 && e2.message) || "Не удалось отправить код"); } finally { setBusy(false); } } const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(em); if (!avail) return ; if (phase === "code") { const cells = Array.from({ length: 6 }, (_, i) => code[i] || ""); return (
{tr("magic.sent.title","Проверьте почту")}

Код из письма

Отправили 6-значный код на {em}. Ссылка и код действуют 15 минут.

ref.current?.focus()} style={{ cursor:"text" }}> {cells.map((c, i) => (
{c || (code.length === i ? : "")}
))}
setCode(e.target.value.replace(/\D/g,"").slice(0,6))} inputMode="numeric" style={{ position:"absolute", left:-9999, opacity:0 }} aria-label="код" />
{busy && {tr("magic.checking","Проверяем…")}} {err && {err}}
); } return (
{tr("magic.eyebrow","Вход без пароля")}

Код на почту

Отправим одноразовый код — пароль не нужен.

setEm(e.target.value)} />
{err &&

{err}

}
); } /* ---- state: DEVICE — вход с устройства без браузера (RFC 8628, user_code) ---- */ function DevicePane({ go, org }) { const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const ref = useRef(null); const avail = !!LA().approveDeviceCode; useEffect(() => { ref.current?.focus(); }, []); const clean = code.replace(/[^A-Z0-9]/g, ""); const shown = clean.length > 4 ? clean.slice(0, 4) + "-" + clean.slice(4, 8) : clean; const full = clean.length === 8; async function approve() { if (!full || busy || !avail) return; setBusy(true); setErr(null); try { const r = await LA().approveDeviceCode({ user_code: clean }); const n = nextState(r, org); if (n) go(n); else setBusy(false); } catch (e) { setErr((e && e.message) || "Код устройства не распознан"); setBusy(false); } } if (!avail) return ; return (
{tr("device.eyebrow","Вход на устройстве")}

Код устройства

{tr("device.hint","Введите код, показанный на устройстве (ТВ, консоль, CLI).")}

setCode(e.target.value.toUpperCase())} placeholder="XXXX-XXXX" maxLength={9} style={{ letterSpacing:".22em", fontSize:"calc(var(--u) + 4px)", textAlign:"center", height:"calc(var(--ctrl-h) + 10px)" }} onKeyDown={e => { if (e.key === "Enter") approve(); }} />
{err &&

{err}

}

RFC 8628 · device authorization grant

); } /* ---- переключатель языка: дропдаун со списком локалей (N локалей) ---- */ function LangSwitch() { const [open, setOpen] = useState(false); const L = window.I18N; if (!L || (L.locales || []).length < 2) return null; return (
{open && (
{L.locales.map(l => { const on = l.code === L.locale; return ( ); })}
)}
); } /* ---- state: MAILVERIFY — подтверждение e-mail внутри OAuth-journey. Сценарий: пользователь вошёл, приложение запросило scope `mail`, но email_verified=false — вместо финального access_denied даём подтвердить адрес и безопасно продолжить исходную авторизацию (проверка НЕ ослабляется). Vendor JSX без сетевой/доменной логики, mock-таймеров и своих security-состояний: только LOGIN_ACTIONS.requestEmailVerification() / confirmEmailVerification({code}) / resendEmailVerification() / continueAuthorization(). Challenge, token, user_id, redirect URI и другие OAuth-параметры UI не получает и не передаёт — runtime берёт их из доверенного текущего контекста. Ошибки — только нормализованные машинные коды (raw-текст backend не рендерится); backend намеренно не различает wrong account / already used / invalid / expired — единое состояние invalid_or_expired («Код недействителен или истёк»). ---- */ const MV_ERRORS = { invalid_or_expired: "Код недействителен или истёк. Запросите новый ниже.", rate_limited: "Слишком много попыток.", verification_unavailable: "Сервис подтверждения временно недоступен. Попробуйте ещё раз чуть позже.", challenge_expired: "Сессия авторизации истекла. Начните вход заново.", }; /* легаси-коды схлопываются в единое безопасное состояние */ function mvNorm(c) { if (c === "invalid_code" || c === "expired_code" || c === "code_already_used" || c === "wrong_account") return "invalid_or_expired"; return (MV_ERRORS[c] || c === "already_verified") ? c : "verification_unavailable"; } function mvMask(em) { const parts = String(em || "").split("@"); const u = parts[0] || "", d = parts[1] || ""; if (!d) return "•••"; const mu = u ? u[0] + "•••" + (u.length > 1 ? u[u.length - 1] : "") : "•••"; const dp = d.split("."); const md = (dp[0] ? dp[0][0] + "•••" : "•••") + (dp.length > 1 ? "." + dp.slice(1).join(".") : ""); return mu + "@" + md; } function MailVerifyPane({ go }) { const A = window.LOGIN_ACTIONS || {}; const H = window.LOGIN_HYDRATE || {}; /* точный контракт (fail-closed): все три действия обязаны существовать; неполный контракт → честный unavailable state, никаких await(a && a()) */ const mvAvailable = typeof A.requestEmailVerification === "function" && typeof A.confirmEmailVerification === "function" && typeof A.resendEmailVerification === "function"; const [phase, setPhase] = useState("initial"); // initial|requesting|reqfail|sent|confirming|verified|fatal const [target, setTarget] = useState(H.maskedEmail || mvMask((AUTH.USER || {}).email)); // masked, готовит сервер const [code, setCode] = useState(""); const [err, setErr] = useState(null); // нормализованный машинный код const [notice, setNotice] = useState(""); // resend success const [cool, setCool] = useState(0); // cooldown повторной отправки, с const [wait, setWait] = useState(0); // rate-limit retry-after, с const [resendBusy, setResendBusy] = useState(false); const [contBusy, setContBusy] = useState(false); // continueAuthorization в полёте const [contErr, setContErr] = useState(false); const inRef = useRef(null); const okRef = useRef(null); useEffect(() => { if (cool <= 0) return; const t = setInterval(() => setCool(c => c - 1), 1000); return () => clearInterval(t); }, [cool > 0]); useEffect(() => { if (wait <= 0) return; const t = setInterval(() => setWait(w => { if (w <= 1) { setErr(e => e === "rate_limited" ? null : e); return 0; } return w - 1; }), 1000); return () => clearInterval(t); }, [wait > 0]); useEffect(() => { if (phase === "sent") inRef.current && inRef.current.focus(); }, [phase]); useEffect(() => { if (phase === "verified") okRef.current && okRef.current.focus(); }, [phase]); async function request() { if (!mvAvailable) return; setPhase("requesting"); setErr(null); setNotice(""); setCode(""); try { const r = (await A.requestEmailVerification()) || {}; if (r.maskedTarget) setTarget(r.maskedTarget); setCool(r.cooldown || 30); setPhase("sent"); } catch (e) { const c = mvNorm((e && e.code) || "verification_unavailable"); if (c === "already_verified") { setPhase("verified"); return; } if (c === "challenge_expired") { setErr(c); setPhase("fatal"); return; } if (c === "rate_limited") setWait((e && e.retryAfter) || 30); setErr(c); setPhase("reqfail"); } } async function confirm(ev) { if (ev) ev.preventDefault(); if (phase !== "sent" || code.length < 8 || wait > 0 || !mvAvailable) return; setPhase("confirming"); setErr(null); setNotice(""); try { await A.confirmEmailVerification({ code }); setPhase("verified"); } catch (e) { const c = mvNorm((e && e.code) || "verification_unavailable"); if (c === "already_verified") { setPhase("verified"); return; } if (c === "challenge_expired") { setErr(c); setPhase("fatal"); return; } if (c === "rate_limited") setWait((e && e.retryAfter) || 30); setErr(c); setCode(""); setPhase("sent"); if (inRef.current) inRef.current.focus(); } } async function resend() { if (cool > 0 || resendBusy || phase === "confirming" || !mvAvailable) return; setResendBusy(true); setErr(null); setNotice(""); try { const r = (await A.resendEmailVerification()) || {}; if (r.maskedTarget) setTarget(r.maskedTarget); setCool(r.cooldown || 30); setNotice(tr("mailverify.resent", "Код отправлен повторно.")); } catch (e) { const c = mvNorm((e && e.code) || "verification_unavailable"); if (c === "challenge_expired") { setErr(c); setPhase("fatal"); setResendBusy(false); return; } if (c === "rate_limited") setCool((e && e.retryAfter) || 30); setErr(c); } setResendBusy(false); } /* безопасное продолжение исходной авторизации: runtime сам знает challenge и redirect (в проде уводит на authorize-редирект); произвольного redirect-input нет. В dev-journey после resolve — следующий шаг цепочки. */ async function proceed() { if (contBusy) return; setContBusy(true); setContErr(false); try { const r = A.continueAuthorization ? await A.continueAuthorization() : null; /* prod: runtime уводит authorize-редиректом (resolve без next); standalone: canonical next продолжает journey */ const n = nextState(r, null); if (n) go(n); else setContBusy(false); } catch (e) { setContErr(true); setContBusy(false); } } const busy = phase === "confirming" || phase === "requesting"; const secs = wait > 0 ? wait : 0; if (!mvAvailable) return ( ); if (phase === "verified") return (

Адрес подтверждён

{target} подтверждён. Можно безопасно продолжить авторизацию {RP.name}.

{contErr &&

{MV_ERRORS.verification_unavailable}

}
); if (phase === "fatal") return (

{MV_ERRORS.challenge_expired}

); return (
{tr("mailverify.eyebrow","Доступ к почте · подтверждение адреса")}

Подтвердите e-mail

{RP.name} запрашивает доступ к вашей почте, но адрес {target} ещё не подтверждён. {(phase === "sent" || phase === "confirming") ? {" "}Мы отправили на него 8-значный код подтверждения. : {" "}Отправим на него код подтверждения — без этого авторизация не продолжится.}

{phase === "initial" && (
)} {phase === "requesting" && (

{tr("mailverify.sending","Отправляем код…")}

)} {phase === "reqfail" && (

{MV_ERRORS[err] || MV_ERRORS.verification_unavailable}{err === "rate_limited" && secs > 0 ? " Повторите через " + secs + " с." : ""}

)} {(phase === "sent" || phase === "confirming") && (
setCode(e.target.value.replace(/[^0-9]/g, "").slice(0, 8))} inputMode="numeric" autoComplete="one-time-code" placeholder="········" disabled={busy || secs > 0} aria-describedby="mv-msg" style={{ letterSpacing:".28em", fontSize:"calc(var(--u) + 4px)", textAlign:"center" }} />
{err && (

{MV_ERRORS[err] || MV_ERRORS.verification_unavailable}{err === "rate_limited" && secs > 0 ? " Повторите через " + secs + " с." : ""}

)} {!err && notice &&

{notice}

}
)} {phase !== "initial" && (

{tr("mailverify.hint","Код действует 15 минут. Письмо не пришло — проверьте папку «Спам».")}

)}
); } /* ---- dev-переключатель потоков (виден только при AUTH.DEV_FLOWS — mock/standalone) ---- */ function FlowPicker({ go, cur }) { const FLOWS = [ ["email", tr("picker.password","Пароль"), () => go("email")], ["passkey", tr("picker.passkey","Passkey"), () => go("redirect","passkey")], ["google", tr("picker.google","Google SSO"), () => go("redirect","google")], ["okta", tr("picker.okta","Okta SSO"), () => go("redirect","okta")], ["magic", tr("picker.magic","Код на почту"), () => go("magic")], ["device", tr("picker.device","Device code"), () => go("device")], ["chooseorg", tr("picker.chooseorg","Выбор организации"), () => go("chooseorg")], ["register", tr("picker.register","Регистрация"), () => go("register")], ["forgot", tr("picker.forgot","Восстановление"), () => go("forgot")], ["twofa", tr("picker.twofa","2FA"), () => go("twofa")], ["consent", tr("picker.consent","Согласие"), () => go("consent")], ["mailverify", tr("picker.mailverify","Подтверждение e-mail"), () => go("mailverify")], ["success", tr("picker.success","Успех"), () => go("success")], ["error", tr("picker.error","Ошибка"), () => go("error")], ["locked", tr("picker.locked","Блокировка"), () => { window.location.href = "Login locked.html"; }], ]; return (
{tr("picker.title","Потоки")} {FLOWS.map(([id, label, fn]) => ( ))}
); } /* direction map */ const FWD_MAP = { main:["email","register","redirect","magic","device","chooseorg"], email:["password","redirect"], password:["chooseorg","consent","forgot"], forgot:["reset"], register:["verify"], verify:["chooseorg","consent"], redirect:["chooseorg","consent"], magic:["chooseorg","consent"], device:["chooseorg","consent"], chooseorg:["consent","twofa","mailverify","success","redirect"], consent:["twofa","success","error","mailverify"], mailverify:["twofa","success"], twofa:["success","error"] }; const UP_STATES = ["success","error"]; function getDir(from, to) { if (UP_STATES.includes(to)) return "up"; if (FWD_MAP[from]?.includes(to)) return "fwd"; return "back"; } function App() { /* гидрация (login-hydrate v1): адаптер кладёт window.LOGIN_HYDRATE до загрузки приложения; providers читает MainPane напрямую — через стейт идут только entryState/email */ const H = window.LOGIN_HYDRATE || {}; const [cur, setCur] = useState(H.entryState || "main"); const [dir, setDir] = useState("fwd"); const [email, setEmail] = useState(H.email || ""); const [provider, setProvider] = useState(null); const [org, setOrg] = useState(null); // выбранная организация (мультиорг-чузер) const [provErr, setProvErr] = useState(null); // отказ selectProvider — баннер на MainPane // мост i18n: смена локали перерисовывает UI const [, _force] = useState(0); useEffect(() => { window.__loginBump = () => _force(n => n + 1); return () => { if (window.__loginBump) delete window.__loginBump; }; }, []); const go = useCallback((s, p) => { setDir(getDir(cur, s)); setCur(s); if (s === "main") setOrg(null); // «Сменить/другой аккаунт» — сбрасываем выбранную организацию if (p !== undefined) setProvider(p); }, [cur]); const comps = { main:MainPane, email:EmailPane, password:PasswordPane, redirect:RedirectPane, chooseorg:ChooseOrgPane, consent:ConsentPane, twofa:TwoFAPane, success:SuccessPane, error:ErrorPane, forgot:ForgotPane, register:RegisterPane, verify:VerifyPane, reset:ResetPane, magic:MagicPane, device:DevicePane, mailverify:MailVerifyPane }; const enterClass = dir==="up" ? "ln-enter-up" : dir==="back" ? "ln-enter-back" : "ln-enter-fwd"; const CurComp = comps[cur]; return (
{CurComp && (
)}
{AUTH.DEV_FLOWS && }
); } ReactDOM.createRoot(document.getElementById("root")).render();