/* 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","Этот способ входа не настроен для данного запроса авторизации.")}
go(back)}> {tr("common.back","Назад")}
);
}
/* ---- 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 {skin === "paper" ? "☽ Тёмная" : "☀ Светлая"} ;
}
/* ---- left brand pane ---- */
function LoginAside() {
return (
Один вход — весь доступ организации.
Безопасный вход по OIDC / OAuth 2.1. Passkey, SSO и 2FA из коробки.
{[["OIDC / OAuth 2.1","shield"],["Passkey · SSO","key"],["RFC-совместимо","check"]].map(([t,ic]) => (
{t}
))}
);
}
/* ---- 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 (
go("email")}>
{p.label || tr("main.email","Войти по e-mail")}
);
if (p.kind === "passkey") return (
pick(p)}>
{p.label || "Passkey / WebAuthn"}
{tr("main.recommended","Рекомендовано")}
);
if (p.kind === "magic") return (
go("magic")}>
{p.label || tr("main.magic","Одноразовый код на почту")}
);
/* kind=oidc (и неизвестные kind будущих версий контракта) — внешний провайдер */
return (
pick(p)}>
{p.iconHint === "google" ? : }
{p.label}
);
})}
{provErr &&
{provErr.message || tr("main.providerFail","Не удалось перейти к провайдеру входа")}
}
Входя, вы принимаете e.preventDefault()} style={{ color:"var(--accent)" }}>условия использования и e.preventDefault()} style={{ color:"var(--accent)" }}>политику конфиденциальности .
{tr("main.or","или")}
{tr("main.noAccount","Нет аккаунта?")} go("register")}>{tr("main.create","Создать")}
);
}
/* ---- 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 (
go("main")}> Назад
{tr("email.step","Шаг 1 из 2 · e-mail")}
Ваш e-mail
Определим 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 (
);
}
/* ---- 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} проверит вашу личность и вернёт вас для подтверждения доступа.`}
{iconHint === "google" ? : provider === "passkey" ? : }
{meta}
{RP.initial}
{RP.name.split(" ")[1] || RP.name}
go("main")}>Отменить
);
}
/* ---- 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}
go("main")}>{tr("chooseorg.switch","Сменить")}
{orgs.map(o => {
const busy = busyId === o.id;
return (
pick(o)} disabled={!!busyId} aria-busy={busy || undefined}
style={busyId && !busy ? { opacity:.55 } : busy ? { borderColor:"var(--accent)" } : null}>
{o.initials || (o.name || "").slice(0, 2).toUpperCase()}
{o.name}
{o.role}
{busy
? {tr("chooseorg.selecting","Выбираем…")}
: }
);
})}
{!orgs.length &&
{tr("chooseorg.empty","Нет доступных организаций.")}
}
{err &&
{err.message}
}
go("main")} style={{ justifyContent:"center" }}>
{tr("chooseorg.other","Другой аккаунт")}
);
}
/* ---- 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}
go("main")}>Сменить
{org && (
{org.initials}
{tr("consent.org","Организация")} · {org.name}
{loginOrgs().length > 1 &&
go("chooseorg")}>{tr("consent.changeOrg","Изменить")} }
)}
{ROWS.map(r => (
toggle(r.k)}>
{sel[r.k] && }
{r.required &&
required }
))}
decide(false, sel)}>{busy === "deny" ? "…" : tr("consent.deny","Отклонить")}
needMailVerify ? go("mailverify") : decide(true, sel)}>
{busy === "allow" ? tr("consent.processing","Обрабатываем…") : tr("consent.allow","Разрешить")}
);
}
/* ---- 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} }
go("consent")} disabled={busy}> Назад
go("main")} disabled={busy}>Другой способ
);
}
/* ---- 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 (
);
}
/* ---- 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}
}
);
})()}
go("main")}>В приложение
go("main")}>Повторить
);
}
/* ---- 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 минут.
go("main")}>← На главную
{AUTH.DEV_FLOWS &&
go("reset")}>Сбросить пароль → }
);
return (
go("password")}> Назад
Восстановление доступа
Забыли пароль?
Отправим ссылку для сброса на ваш e-mail.
E-mail
setEm(e.target.value)} placeholder="you@company.com" autoFocus />
{busy ? tr("forgot.sending","Отправка…") : tr("forgot.send","Отправить ссылку")}
{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 (
go("main")}>Назад
Новый аккаунт
Создать аккаунт
Заполните данные для регистрации в организации.
{tr("register.have","Уже есть аккаунт?")} go("email")}>{tr("register.signin","Войти")}
);
}
/* ---- 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} }
go("register")} disabled={busy}>Назад
{resent ? <>Отправлено> : resendBusy ? "Отправляем…" : "Отправить ещё раз"}
);
}
/* ---- 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 (
Восстановление доступа
Новый пароль
Придумайте надёжный пароль для вашего аккаунта.
);
}
/* ---- 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} }
setPhase("email")} disabled={busy}> {tr("common.back","Назад")}
go("main")} disabled={busy}>{tr("common.cancel","Отмена")}
);
}
return (
go("main")}> {tr("common.back","Назад")}
{tr("magic.eyebrow","Вход без пароля")}
Код на почту
Отправим одноразовый код — пароль не нужен.
);
}
/* ---- 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 (
go("main")}> {tr("common.back","Назад")}
{tr("device.eyebrow","Вход на устройстве")}
Код устройства
{tr("device.hint","Введите код, показанный на устройстве (ТВ, консоль, CLI).")}
user_code
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}
}
{busy ? tr("password.checking","Проверка…") : <>{tr("device.approve","Подтвердить устройство")} >}
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 (
setOpen(o=>!o)} onBlur={()=>setTimeout(()=>setOpen(false),150)}
style={{ display:"inline-flex", alignItems:"center", gap:6 }}>
{L.label()}
{open && (
{L.locales.map(l => {
const on = l.code === L.locale;
return (
e.preventDefault()} onClick={()=>{ L.setLocale(l.code); setOpen(false); }}
style={{ width:"100%", display:"flex", alignItems:"center", gap:8, padding:"8px 10px", border:0, borderRadius:7, cursor:"pointer", fontWeight:600, fontSize:"calc(var(--u) - 1.5px)", textAlign:"left",
background:on?"var(--accent-soft)":"transparent", color:on?"var(--accent)":"var(--ink-2)" }}>
{l.label}
{l.code}
{on && }
);
})}
)}
);
}
/* ---- 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}
}
{contBusy
? <> {tr("mailverify.continuing","Продолжаем…")}>
: <>{tr("mailverify.continue","Продолжить авторизацию")} >}
);
if (phase === "fatal") return (
{MV_ERRORS.challenge_expired}
go("main")}>{tr("mailverify.restart","Войти заново")}
);
return (
{tr("mailverify.eyebrow","Доступ к почте · подтверждение адреса")}
Подтвердите e-mail
{RP.name} запрашивает доступ к вашей почте, но адрес {target} ещё не подтверждён.
{(phase === "sent" || phase === "confirming")
? {" "}Мы отправили на него 8-значный код подтверждения.
: {" "}Отправим на него код подтверждения — без этого авторизация не продолжится. }
{phase === "initial" && (
{tr("mailverify.send","Отправить код")}
go("consent")}> {tr("common.back","Назад")}
)}
{phase === "requesting" && (
{tr("mailverify.sending","Отправляем код…")}
)}
{phase === "reqfail" && (
{MV_ERRORS[err] || MV_ERRORS.verification_unavailable}{err === "rate_limited" && secs > 0 ? " Повторите через " + secs + " с." : ""}
0}>
{tr("mailverify.retry","Отправить код ещё раз")}
)}
{(phase === "sent" || phase === "confirming") && (
)}
{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]) => (
{label}
))}
);
}
/* 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 (
);
}
ReactDOM.createRoot(document.getElementById("root")).render( );