Files
portal/web/01-login.html
T
2026-05-06 01:39:59 +07:00

1071 lines
41 KiB
HTML

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0" />
<title>Лидпоток — Вход</title>
<!-- ============================================================
Прил. Л — Прототип №1: Логин / Регистрация / 2FA / Recovery
Версия: v0.1 от 05.05.2026
Источники: brandbook.md v1.1, narrative §22.4, §18.4
Стек: чистый HTML + CSS + JS, без зависимостей.
Шрифт Inter — через Google Fonts (с кириллицей).
Этот файл — самодостаточен. Можно открыть двойным кликом.
============================================================ -->
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap&subset=cyrillic" rel="stylesheet" />
<style>
/* ============ Брендовые токены (brandbook.md §3, §4, §5, §8) ============ */
:root {
/* Teal — основная палитра */
--teal-900: #04342C;
--teal-600: #0F6E56;
--teal-400: #1D9E75;
--teal-200: #5DCAA5;
--teal-100: #9FE1CB;
--teal-50: #E1F5EE;
/* Нейтральная */
--gray-900: #1A1A1A;
--gray-700: #444441;
--gray-500: #888780;
--gray-300: #B4B2A9;
--gray-200: #D3D1C7;
--gray-100: #F1EFE8;
--gray-50: #FAFAF8;
--white: #FFFFFF;
/* Семантические */
--success: #22C55E;
--warning: #F59E0B;
--danger: #EF4444;
--info: #3B82F6;
/* Размерная сетка (brandbook §5) */
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
--space-8: 32px;
--space-12: 48px;
--radius-sm: 6px;
--radius-md: 8px;
--radius-lg: 12px;
--radius-pill: 999px;
--font-sans: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-mono: 'JetBrains Mono', Menlo, Monaco, Consolas, monospace;
--shadow-sm: 0 1px 2px rgba(4, 52, 44, 0.06);
--shadow-md: 0 4px 12px rgba(4, 52, 44, 0.08);
--shadow-lg: 0 12px 32px rgba(4, 52, 44, 0.12);
}
/* ============ Reset ============ */
*, *::before, *::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: var(--font-sans);
font-size: 14px;
line-height: 1.5;
color: var(--gray-900);
background: var(--gray-100);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
button { font: inherit; cursor: pointer; }
input, button { font-family: inherit; }
a { color: var(--teal-600); text-decoration: none; }
a:hover { color: var(--teal-400); text-decoration: underline; }
/* ============ Layout ============ */
.page {
min-height: 100vh;
display: grid;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 960px) {
.page { grid-template-columns: 1fr; }
.panel-visual { display: none; }
}
/* -------- Левая панель: визуальный поток -------- */
.panel-visual {
position: relative;
background: linear-gradient(135deg, var(--teal-900) 0%, var(--teal-600) 100%);
color: var(--white);
overflow: hidden;
padding: var(--space-12);
display: flex;
flex-direction: column;
justify-content: space-between;
}
/* Декоративные круги — концепция «поток» (brandbook §1.2) */
.flow-canvas {
position: absolute;
inset: 0;
pointer-events: none;
}
.flow-circle {
position: absolute;
border-radius: 50%;
opacity: 0;
animation: flow 8s linear infinite;
}
@keyframes flow {
0% { transform: translateX(-80px) scale(0.8); opacity: 0; }
10% { opacity: 0.9; }
90% { opacity: 0.9; }
100% { transform: translateX(calc(50vw + 80px)) scale(1); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.flow-circle { animation: none; opacity: 0.4; }
}
.brand-mark {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: var(--space-3);
}
.brand-mark-circles {
display: flex;
align-items: center;
gap: 4px;
}
.brand-mark-circles span {
display: block;
border-radius: 50%;
background: var(--white);
}
.brand-mark-circles span:nth-child(1) { width: 8px; height: 8px; opacity: 0.6; }
.brand-mark-circles span:nth-child(2) { width: 14px; height: 14px; opacity: 0.8; }
.brand-mark-circles span:nth-child(3) { width: 20px; height: 20px; opacity: 1; }
.brand-mark-text {
font-size: 20px;
font-weight: 600;
letter-spacing: -0.01em;
}
.visual-content {
position: relative;
z-index: 1;
max-width: 480px;
}
.visual-content h1 {
font-size: 36px;
font-weight: 700;
line-height: 1.15;
margin: 0 0 var(--space-4);
letter-spacing: -0.02em;
}
.visual-content p {
font-size: 16px;
line-height: 1.6;
color: var(--teal-100);
margin: 0 0 var(--space-8);
}
.visual-features {
display: flex;
flex-direction: column;
gap: var(--space-4);
list-style: none;
padding: 0;
margin: 0;
}
.visual-features li {
display: flex;
align-items: flex-start;
gap: var(--space-3);
color: var(--teal-50);
font-size: 14px;
}
.visual-features li::before {
content: '';
flex: 0 0 6px;
width: 6px;
height: 6px;
margin-top: 8px;
border-radius: 50%;
background: var(--teal-200);
}
.visual-footer {
position: relative;
z-index: 1;
display: flex;
gap: var(--space-6);
color: var(--teal-100);
font-size: 12px;
opacity: 0.8;
}
/* -------- Правая панель: форма -------- */
.panel-form {
display: flex;
align-items: center;
justify-content: center;
padding: var(--space-12) var(--space-8);
background: var(--white);
}
.form-shell {
width: 100%;
max-width: 420px;
}
.form-tabs {
display: flex;
gap: 0;
border-bottom: 1px solid var(--gray-200);
margin-bottom: var(--space-8);
}
.form-tab {
flex: 1;
padding: var(--space-3) var(--space-2);
background: transparent;
border: 0;
border-bottom: 2px solid transparent;
color: var(--gray-500);
font-size: 14px;
font-weight: 500;
transition: color .15s ease, border-color .15s ease;
margin-bottom: -1px;
}
.form-tab:hover { color: var(--gray-700); }
.form-tab.is-active {
color: var(--teal-600);
border-bottom-color: var(--teal-600);
}
.form-pane { display: none; }
.form-pane.is-active { display: block; animation: fadeIn .2s ease; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
.form-title {
font-size: 24px;
font-weight: 600;
margin: 0 0 var(--space-2);
letter-spacing: -0.01em;
}
.form-subtitle {
font-size: 14px;
color: var(--gray-500);
margin: 0 0 var(--space-6);
}
.field {
display: flex;
flex-direction: column;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.field-label {
font-size: 13px;
font-weight: 500;
color: var(--gray-700);
}
.field-label .field-required { color: var(--danger); }
.field-input {
height: 40px;
padding: 0 var(--space-3);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
background: var(--white);
color: var(--gray-900);
font-size: 14px;
transition: border-color .15s ease, box-shadow .15s ease;
}
.field-input::placeholder { color: var(--gray-300); }
.field-input:hover { border-color: var(--gray-300); }
.field-input:focus {
outline: none;
border-color: var(--teal-600);
box-shadow: 0 0 0 3px rgba(15, 110, 86, 0.12);
}
.field-input[aria-invalid="true"] {
border-color: var(--danger);
}
.field-input[aria-invalid="true"]:focus {
box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.12);
}
.field-hint {
font-size: 12px;
color: var(--gray-500);
}
.field-hint.is-error { color: var(--danger); }
.field-hint.is-success { color: var(--success); }
/* Password input с кнопкой «показать» */
.password-wrap { position: relative; }
.password-wrap .field-input { padding-right: 44px; }
.password-toggle {
position: absolute;
right: 4px;
top: 4px;
width: 32px;
height: 32px;
border: 0;
background: transparent;
color: var(--gray-500);
border-radius: var(--radius-sm);
display: inline-flex;
align-items: center;
justify-content: center;
}
.password-toggle:hover { color: var(--gray-700); background: var(--gray-100); }
/* Индикатор силы пароля (zxcvbn 0..4) */
.strength {
display: flex;
gap: 4px;
margin-top: 6px;
}
.strength-bar {
flex: 1;
height: 4px;
border-radius: var(--radius-pill);
background: var(--gray-200);
transition: background .2s ease;
}
.strength[data-score="0"] .strength-bar:nth-child(-n+1),
.strength[data-score="1"] .strength-bar:nth-child(-n+1) { background: var(--danger); }
.strength[data-score="2"] .strength-bar:nth-child(-n+2) { background: var(--warning); }
.strength[data-score="3"] .strength-bar:nth-child(-n+3) { background: var(--teal-400); }
.strength[data-score="4"] .strength-bar:nth-child(-n+4) { background: var(--success); }
/* Кнопки */
.btn {
height: 40px;
padding: 0 var(--space-6);
border-radius: var(--radius-md);
border: 1px solid transparent;
font-size: 14px;
font-weight: 500;
transition: background-color .15s ease, border-color .15s ease, color .15s ease, transform .05s ease;
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--space-2);
}
.btn:active { transform: translateY(1px); }
.btn-primary {
background: var(--teal-600);
color: var(--white);
width: 100%;
}
.btn-primary:hover { background: var(--teal-400); }
.btn-primary:disabled {
background: var(--gray-300);
color: var(--white);
cursor: not-allowed;
transform: none;
}
.btn-link {
background: transparent;
border: 0;
color: var(--teal-600);
padding: 0;
height: auto;
font-size: 13px;
}
.btn-link:hover { color: var(--teal-400); text-decoration: underline; }
/* Ряд: чекбокс + ссылка */
.form-row-between {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: var(--space-6);
}
.checkbox {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-size: 13px;
color: var(--gray-700);
user-select: none;
}
.checkbox input { accent-color: var(--teal-600); }
/* Алерт-бар (попытки, ошибки) */
.alert {
padding: var(--space-3) var(--space-4);
border-radius: var(--radius-md);
font-size: 13px;
margin-bottom: var(--space-4);
display: flex;
align-items: flex-start;
gap: var(--space-2);
border: 1px solid transparent;
}
.alert-info {
background: var(--teal-50);
border-color: var(--teal-100);
color: var(--teal-900);
}
.alert-warning {
background: #FEF3C7;
border-color: #FDE68A;
color: #78350F;
}
.alert-danger {
background: #FEE2E2;
border-color: #FECACA;
color: #7F1D1D;
}
/* Форма 2FA — TOTP */
.totp-grid {
display: grid;
grid-template-columns: repeat(6, 1fr);
gap: var(--space-2);
margin: var(--space-4) 0 var(--space-2);
}
.totp-cell {
width: 100%;
height: 52px;
text-align: center;
font-family: var(--font-mono);
font-size: 22px;
font-weight: 500;
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
background: var(--white);
color: var(--gray-900);
transition: border-color .15s ease, box-shadow .15s ease;
}
.totp-cell:focus {
outline: none;
border-color: var(--teal-600);
box-shadow: 0 0 0 3px rgba(15, 110, 86, 0.12);
}
.totp-cell.is-filled { background: var(--teal-50); border-color: var(--teal-200); }
.totp-meta {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 12px;
color: var(--gray-500);
margin-bottom: var(--space-6);
}
.totp-timer {
display: inline-flex;
align-items: center;
gap: var(--space-2);
font-family: var(--font-mono);
}
.totp-timer-ring {
width: 14px;
height: 14px;
border-radius: 50%;
background: conic-gradient(var(--teal-600) calc(var(--p, 100) * 1%), var(--gray-200) 0);
transition: background 1s linear;
}
/* Recovery codes */
.recovery-codes {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: var(--space-2);
padding: var(--space-4);
background: var(--gray-50);
border: 1px solid var(--gray-200);
border-radius: var(--radius-md);
font-family: var(--font-mono);
font-size: 14px;
margin-bottom: var(--space-6);
}
.recovery-code {
padding: var(--space-2) var(--space-3);
background: var(--white);
border: 1px solid var(--gray-200);
border-radius: var(--radius-sm);
letter-spacing: 0.04em;
color: var(--gray-700);
text-align: center;
}
.recovery-actions {
display: flex;
gap: var(--space-3);
margin-bottom: var(--space-6);
}
.btn-secondary {
background: var(--white);
color: var(--gray-700);
border: 1px solid var(--gray-200);
flex: 1;
}
.btn-secondary:hover {
background: var(--gray-50);
border-color: var(--gray-300);
color: var(--gray-900);
}
/* Footer формы */
.form-foot {
margin-top: var(--space-8);
text-align: center;
font-size: 12px;
color: var(--gray-500);
}
.form-foot a { color: var(--gray-700); }
/* Spec-аннотация для дизайнера/frontend (Прил. Л — спецификации) */
.spec-toggle {
position: fixed;
right: 16px;
bottom: 16px;
z-index: 100;
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--gray-900);
color: var(--white);
border: 0;
box-shadow: var(--shadow-lg);
font-size: 18px;
transition: background-color .15s ease;
}
.spec-toggle:hover { background: var(--teal-600); }
.spec-overlay {
position: fixed;
inset: 0;
background: rgba(4, 52, 44, 0.6);
backdrop-filter: blur(2px);
display: none;
align-items: center;
justify-content: center;
padding: var(--space-6);
z-index: 99;
}
.spec-overlay.is-open { display: flex; }
.spec-card {
max-width: 720px;
width: 100%;
max-height: 80vh;
overflow-y: auto;
background: var(--white);
border-radius: var(--radius-lg);
padding: var(--space-8);
box-shadow: var(--shadow-lg);
}
.spec-card h2 {
margin: 0 0 var(--space-4);
font-size: 20px;
font-weight: 600;
}
.spec-card h3 {
margin: var(--space-6) 0 var(--space-3);
font-size: 14px;
font-weight: 600;
color: var(--teal-600);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.spec-card ul { margin: 0 0 var(--space-4); padding-left: var(--space-6); }
.spec-card li { margin-bottom: var(--space-2); font-size: 13px; line-height: 1.6; color: var(--gray-700); }
.spec-card code {
font-family: var(--font-mono);
font-size: 12px;
background: var(--gray-100);
padding: 2px 6px;
border-radius: var(--radius-sm);
color: var(--teal-900);
}
</style>
</head>
<body>
<main class="page">
<!-- ============ Визуальная панель ============ -->
<aside class="panel-visual">
<div class="flow-canvas" aria-hidden="true">
<!-- Анимированные круги — генерируются JS-ом ниже -->
</div>
<div class="brand-mark">
<div class="brand-mark-circles" aria-hidden="true">
<span></span><span></span><span></span>
</div>
<span class="brand-mark-text">Лидпоток</span>
</div>
<div class="visual-content">
<h1>Поток ваших лидов&nbsp;— под контролем.</h1>
<p>SaaS-CRM для отделов продаж, которые покупают лиды у агрегаторов и хотят учитывать их в собственной системе. Webhook-приёмник, расширенная аналитика, кошельковый биллинг.</p>
<ul class="visual-features">
<li>14 настраиваемых статусов воронки и канбан-доска</li>
<li>Идемпотентный приём лидов через Webhook</li>
<li>Шифрование чувствительных полей и 2FA по&nbsp;TOTP</li>
<li>Хранение данных в РФ (152-ФЗ, УЗ-4)</li>
</ul>
</div>
<footer class="visual-footer">
<span>© 2026 Лидпоток</span>
<a href="#" style="color: inherit;">Оферта</a>
<a href="#" style="color: inherit;">Политика конфиденциальности</a>
</footer>
</aside>
<!-- ============ Форма ============ -->
<section class="panel-form">
<div class="form-shell">
<!-- Табы для переключения между состояниями (только для прототипа) -->
<div class="form-tabs" role="tablist" aria-label="Состояния авторизации">
<button class="form-tab is-active" data-pane="login" role="tab" aria-selected="true">Вход</button>
<button class="form-tab" data-pane="register" role="tab" aria-selected="false">Регистрация</button>
<button class="form-tab" data-pane="totp" role="tab" aria-selected="false">2FA</button>
<button class="form-tab" data-pane="recovery" role="tab" aria-selected="false">Recovery</button>
</div>
<!-- ====== Вход ====== -->
<div class="form-pane is-active" id="pane-login" role="tabpanel">
<h2 class="form-title">Вход в Лидпоток</h2>
<p class="form-subtitle">Введите email и пароль, чтобы продолжить.</p>
<form id="form-login" novalidate>
<div class="field">
<label for="login-email" class="field-label">Email <span class="field-required">*</span></label>
<input id="login-email" name="email" type="email" autocomplete="email" required
class="field-input" placeholder="you@company.ru" />
</div>
<div class="field">
<label for="login-password" class="field-label">Пароль <span class="field-required">*</span></label>
<div class="password-wrap">
<input id="login-password" name="password" type="password" autocomplete="current-password"
required minlength="10" class="field-input" placeholder="Минимум 10 символов" />
<button type="button" class="password-toggle" aria-label="Показать пароль" data-toggle="login-password">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
</div>
<div class="form-row-between">
<label class="checkbox">
<input type="checkbox" name="remember" />
Запомнить на 30 дней
</label>
<button type="button" class="btn-link" data-pane-link="recovery">Забыли пароль?</button>
</div>
<button type="submit" class="btn btn-primary" id="login-submit">Войти</button>
</form>
<p class="form-foot">
Нет аккаунта?
<button type="button" class="btn-link" data-pane-link="register">Зарегистрироваться</button>
</p>
</div>
<!-- ====== Регистрация ====== -->
<div class="form-pane" id="pane-register" role="tabpanel">
<h2 class="form-title">Создать аккаунт</h2>
<p class="form-subtitle">Один email — один тенант. На всех тарифах доступна 2FA.</p>
<form id="form-register" novalidate>
<div class="field">
<label for="reg-name" class="field-label">Имя <span class="field-required">*</span></label>
<input id="reg-name" name="name" type="text" autocomplete="given-name" required
class="field-input" placeholder="Ваше имя" />
</div>
<div class="field">
<label for="reg-email" class="field-label">Рабочий email <span class="field-required">*</span></label>
<input id="reg-email" name="email" type="email" autocomplete="email" required
class="field-input" placeholder="you@company.ru" />
<span class="field-hint">На этот адрес придёт подтверждение и 50 стартовых лидов.</span>
</div>
<div class="field">
<label for="reg-password" class="field-label">Пароль <span class="field-required">*</span></label>
<div class="password-wrap">
<input id="reg-password" name="password" type="password" autocomplete="new-password"
required minlength="10" class="field-input" placeholder="Минимум 10 символов" />
<button type="button" class="password-toggle" aria-label="Показать пароль" data-toggle="reg-password">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/>
</svg>
</button>
</div>
<div class="strength" id="reg-strength" data-score="0" aria-hidden="true">
<span class="strength-bar"></span>
<span class="strength-bar"></span>
<span class="strength-bar"></span>
<span class="strength-bar"></span>
</div>
<span class="field-hint" id="reg-strength-hint">Сложность будет проверена через&nbsp;zxcvbn (минимум&nbsp;3&nbsp;из&nbsp;4).</span>
</div>
<div class="form-row-between" style="align-items: flex-start; gap: var(--space-3);">
<label class="checkbox">
<input type="checkbox" name="agree" required />
<span>Я принимаю <a href="#">оферту</a> и <a href="#">политику&nbsp;конфиденциальности</a></span>
</label>
</div>
<button type="submit" class="btn btn-primary" id="reg-submit" disabled>Создать аккаунт</button>
</form>
<p class="form-foot">
Уже зарегистрированы?
<button type="button" class="btn-link" data-pane-link="login">Войти</button>
</p>
</div>
<!-- ====== 2FA TOTP ====== -->
<div class="form-pane" id="pane-totp" role="tabpanel">
<h2 class="form-title">Подтвердите вход</h2>
<p class="form-subtitle">Введите 6-значный код из вашего приложения-аутентификатора.</p>
<div class="alert alert-info" role="status">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink: 0; margin-top: 2px;">
<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>
</svg>
<span>Окно действия кода — 30&nbsp;секунд. Window&nbsp;=&nbsp;1: примем коды&nbsp;±30&nbsp;сек от текущего.</span>
</div>
<form id="form-totp" novalidate>
<div class="totp-grid" id="totp-cells">
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" autocomplete="one-time-code" aria-label="Цифра 1" />
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Цифра 2" />
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Цифра 3" />
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Цифра 4" />
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Цифра 5" />
<input class="totp-cell" inputmode="numeric" pattern="[0-9]" maxlength="1" aria-label="Цифра 6" />
</div>
<div class="totp-meta" aria-live="polite">
<span>Попыток осталось: <strong id="totp-attempts">5</strong></span>
<span class="totp-timer">
<span class="totp-timer-ring" id="totp-ring" style="--p: 100;" aria-hidden="true"></span>
<span id="totp-seconds">30</span>&nbsp;сек
</span>
</div>
<button type="submit" class="btn btn-primary" id="totp-submit" disabled>Подтвердить</button>
</form>
<p class="form-foot">
Нет доступа к аутентификатору?
<button type="button" class="btn-link" data-pane-link="recovery">Использовать резервный код</button>
</p>
</div>
<!-- ====== Recovery codes ====== -->
<div class="form-pane" id="pane-recovery" role="tabpanel">
<h2 class="form-title">Резервные коды</h2>
<p class="form-subtitle">Сохраните их в безопасном месте. Каждый код одноразовый, всего&nbsp;8.</p>
<div class="alert alert-warning" role="alert">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" style="flex-shrink: 0; margin-top: 2px;">
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>Эти коды показаны один раз. После перехода на другой экран&nbsp;— восстановить будет нельзя.</span>
</div>
<div class="recovery-codes" id="recovery-list">
<span class="recovery-code">A4F2-9KQT</span>
<span class="recovery-code">7BR3-XW8D</span>
<span class="recovery-code">M5L9-2HVN</span>
<span class="recovery-code">PG6Y-CT4Z</span>
<span class="recovery-code">RJ8K-W3QF</span>
<span class="recovery-code">N1HE-5DUP</span>
<span class="recovery-code">VS2A-7XBL</span>
<span class="recovery-code">YT9C-K4ME</span>
</div>
<div class="recovery-actions">
<button type="button" class="btn btn-secondary" id="recovery-copy">Скопировать все</button>
<button type="button" class="btn btn-secondary" id="recovery-download">Скачать .txt</button>
</div>
<button type="button" class="btn btn-primary" data-pane-link="login">Я сохранил коды, продолжить</button>
</div>
</div>
</section>
</main>
<!-- ============ Spec-аннотация для дизайнера ============ -->
<button class="spec-toggle" id="spec-toggle" aria-label="Открыть спецификацию экрана">i</button>
<div class="spec-overlay" id="spec-overlay" role="dialog" aria-labelledby="spec-title">
<div class="spec-card">
<h2 id="spec-title">Спецификация экрана №1 — Логин/регистрация/2FA</h2>
<p style="margin: 0 0 var(--space-4); font-size: 13px; color: var(--gray-500);">
Прил. Л v0.1, 05.05.2026. Источник истины: <code>brandbook.md</code> v1.1, narrative §22.4, §18.4.
</p>
<h3>Состояния</h3>
<ul>
<li><strong>Вход</strong> — email + пароль, чекбокс «Запомнить на 30 дней» (<code>SESSION_REMEMBER_LIFETIME=43200 мин</code>).</li>
<li><strong>Регистрация</strong> — имя + email + пароль (min&nbsp;10, zxcvbn ≥&nbsp;3) + согласие на оферту/политику.</li>
<li><strong>2FA TOTP</strong> — 6 ячеек, окно ±30&nbsp;сек (window=1), отсчёт оставшихся попыток (<code>LOGIN_THROTTLE_MAX_ATTEMPTS=5</code>).</li>
<li><strong>Recovery</strong> — 8 одноразовых кодов формата <code>XXXX-XXXX</code>, hash в <code>user_recovery_codes</code>.</li>
</ul>
<h3>Брендовые токены</h3>
<ul>
<li>Primary: <code>--teal-600 #0F6E56</code>, hover <code>--teal-400 #1D9E75</code>.</li>
<li>Background page: <code>--gray-100 #F1EFE8</code>, surface card: <code>#FFFFFF</code>.</li>
<li>Шрифт UI: <code>Inter</code>, шрифт кодов 2FA/Recovery: <code>JetBrains Mono</code>.</li>
<li>Радиусы: input/button — 8&nbsp;px, чип кода — 6&nbsp;px.</li>
<li>Высота input/button: 40&nbsp;px (brandbook §5).</li>
</ul>
<h3>API контракты (для backend)</h3>
<ul>
<li><code>POST /api/v1/auth/login</code><code>{email, password, remember}</code>. 200&nbsp;OK + 2FA-флаг или 401 «invalid credentials» / 429 «rate limited».</li>
<li><code>POST /api/v1/auth/register</code><code>{name, email, password}</code>. Применяется <code>BCRYPT_ROUNDS</code>, <code>PASSWORD_MIN_ZXCVBN_SCORE</code>.</li>
<li><code>POST /api/v1/auth/totp/verify</code><code>{code}</code>. Использует <code>pragmarx/google2fa-laravel</code>, <code>TOTP_WINDOW=1</code>.</li>
<li><code>POST /api/v1/auth/recovery/redeem</code><code>{code}</code>. Помечает <code>user_recovery_codes.used_at</code>.</li>
</ul>
<h3>Безопасность</h3>
<ul>
<li>Anti-bruteforce (§22.4.4): 5 неудачных на email → блок 15 мин; 10 с одного IP за час → блок IP на час.</li>
<li>Email-уведомление пользователю при ≥3 неудачных попыток.</li>
<li>При смене пароля — все остальные сессии инвалидируются (§22.4.3).</li>
<li>Recovery-коды показываются <strong>один раз</strong> при включении 2FA. Повторно — только regenerate с инвалидацией старых.</li>
</ul>
<h3>Доступность</h3>
<ul>
<li>WCAG 2.1 AA. Контраст Teal&nbsp;600 на белом — 6.42:1 ✅.</li>
<li>Все интерактивные элементы доступны через <code>tab</code>.</li>
<li><code>aria-label</code> на цифрах TOTP, <code>role="alert"</code> на warning recovery.</li>
<li><code>prefers-reduced-motion</code>: анимация потока кругов отключается.</li>
</ul>
<h3>Что не реализовано в прототипе</h3>
<ul>
<li>QR-код для активации TOTP (нужен <code>BaconQrCode</code> на бэкенде).</li>
<li>Реальная zxcvbn-проверка (в прототипе — упрощённая heuristic).</li>
<li>Капча Yandex SmartCaptcha после 2 неудач (Прил. Г §0).</li>
<li>SSO Yandex 360 (DO-5 — для админки SaaS, не клиента).</li>
</ul>
<button type="button" class="btn btn-secondary" id="spec-close" style="margin-top: var(--space-4); width: 100%;">Закрыть</button>
</div>
</div>
<script>
// =========================================================================
// Прил. Л — прототип №1: stub-логика
// =========================================================================
// ---- Анимация потока кругов в визуальной панели ----
(function initFlow() {
const canvas = document.querySelector('.flow-canvas');
if (!canvas) return;
const palette = ['var(--teal-200)', 'var(--teal-400)', 'var(--teal-100)', 'var(--white)'];
const sizes = [10, 14, 18, 22, 28, 36];
for (let i = 0; i < 14; i++) {
const c = document.createElement('span');
c.className = 'flow-circle';
const size = sizes[Math.floor(Math.random() * sizes.length)];
c.style.width = size + 'px';
c.style.height = size + 'px';
c.style.top = Math.random() * 100 + '%';
c.style.background = palette[Math.floor(Math.random() * palette.length)];
c.style.opacity = (0.3 + Math.random() * 0.5).toFixed(2);
c.style.animationDuration = (6 + Math.random() * 6).toFixed(1) + 's';
c.style.animationDelay = '-' + (Math.random() * 8).toFixed(1) + 's';
canvas.appendChild(c);
}
})();
// ---- Переключение табов и pane-link кнопок ----
function activate(paneId) {
document.querySelectorAll('.form-pane').forEach(p => {
p.classList.toggle('is-active', p.id === 'pane-' + paneId);
});
document.querySelectorAll('.form-tab').forEach(t => {
const isActive = t.dataset.pane === paneId;
t.classList.toggle('is-active', isActive);
t.setAttribute('aria-selected', isActive ? 'true' : 'false');
});
if (paneId === 'totp') startTotpTimer();
}
document.querySelectorAll('[data-pane]').forEach(b => {
b.addEventListener('click', () => activate(b.dataset.pane));
});
document.querySelectorAll('[data-pane-link]').forEach(b => {
b.addEventListener('click', () => activate(b.dataset.paneLink));
});
// ---- Toggle password visibility ----
document.querySelectorAll('[data-toggle]').forEach(btn => {
btn.addEventListener('click', () => {
const input = document.getElementById(btn.dataset.toggle);
if (!input) return;
const isPwd = input.type === 'password';
input.type = isPwd ? 'text' : 'password';
btn.setAttribute('aria-label', isPwd ? 'Скрыть пароль' : 'Показать пароль');
});
});
// ---- Strength meter (упрощённая heuristic, в проде — zxcvbn-ts) ----
function estimateScore(pwd) {
if (!pwd) return 0;
let score = 0;
if (pwd.length >= 10) score++;
if (/[A-Z]/.test(pwd) && /[a-z]/.test(pwd)) score++;
if (/\d/.test(pwd)) score++;
if (/[^A-Za-z0-9]/.test(pwd) || pwd.length >= 16) score++;
return Math.min(score, 4);
}
const regPwd = document.getElementById('reg-password');
const regStrength = document.getElementById('reg-strength');
const regHint = document.getElementById('reg-strength-hint');
const regAgree = document.querySelector('input[name="agree"]');
const regSubmit = document.getElementById('reg-submit');
function refreshRegState() {
const score = estimateScore(regPwd.value);
regStrength.dataset.score = score;
const labels = [
'Сложность: слишком слабый',
'Сложность: слабый',
'Сложность: средний',
'Сложность: достаточный ✓',
'Сложность: отличный ✓'
];
regHint.textContent = labels[score];
regHint.className = 'field-hint ' + (score >= 3 ? 'is-success' : score === 2 ? '' : 'is-error');
const ok = regPwd.value.length >= 10 && score >= 3 && regAgree.checked;
regSubmit.disabled = !ok;
}
regPwd && regPwd.addEventListener('input', refreshRegState);
regAgree && regAgree.addEventListener('change', refreshRegState);
// ---- TOTP: ввод по 1 цифре, переход к следующей, активация submit ----
const cells = document.querySelectorAll('.totp-cell');
const totpSubmit = document.getElementById('totp-submit');
function refreshTotpState() {
let allFilled = true;
cells.forEach(c => {
c.classList.toggle('is-filled', c.value.length === 1);
if (c.value.length !== 1) allFilled = false;
});
totpSubmit.disabled = !allFilled;
}
cells.forEach((cell, i) => {
cell.addEventListener('input', () => {
cell.value = cell.value.replace(/\D/g, '').slice(0, 1);
if (cell.value && i < cells.length - 1) cells[i + 1].focus();
refreshTotpState();
});
cell.addEventListener('keydown', (e) => {
if (e.key === 'Backspace' && !cell.value && i > 0) cells[i - 1].focus();
});
cell.addEventListener('paste', (e) => {
const text = (e.clipboardData || window.clipboardData).getData('text').replace(/\D/g, '').slice(0, 6);
if (!text) return;
e.preventDefault();
[...text].forEach((d, j) => { if (cells[j]) cells[j].value = d; });
refreshTotpState();
(cells[Math.min(text.length, 5)] || cells[5]).focus();
});
});
// ---- TOTP timer (30-сек окно) ----
let totpInterval = null;
function startTotpTimer() {
if (totpInterval) clearInterval(totpInterval);
const ring = document.getElementById('totp-ring');
const sec = document.getElementById('totp-seconds');
let remaining = 30 - (Math.floor(Date.now() / 1000) % 30);
function tick() {
sec.textContent = remaining;
ring.style.setProperty('--p', Math.round((remaining / 30) * 100));
remaining--;
if (remaining < 0) remaining = 29;
}
tick();
totpInterval = setInterval(tick, 1000);
}
// ---- Form submits — заглушки ----
['form-login', 'form-register', 'form-totp'].forEach(id => {
const f = document.getElementById(id);
if (!f) return;
f.addEventListener('submit', (e) => {
e.preventDefault();
const btn = f.querySelector('.btn-primary');
const original = btn.textContent;
btn.disabled = true;
btn.textContent = 'Отправка…';
setTimeout(() => {
btn.textContent = original;
if (id === 'form-login') activate('totp');
if (id === 'form-totp') {
alert('🟢 Вход выполнен (заглушка). В проде — редирект на /dashboard.');
btn.disabled = false;
}
if (id === 'form-register') {
alert('🟢 Регистрация (заглушка). В проде — отправка email-подтверждения.');
activate('login');
btn.disabled = false;
}
}, 600);
});
});
// ---- Recovery codes: copy / download ----
function getCodes() {
return [...document.querySelectorAll('.recovery-code')].map(el => el.textContent.trim());
}
document.getElementById('recovery-copy').addEventListener('click', async () => {
try {
await navigator.clipboard.writeText(getCodes().join('\n'));
alert('Скопировано в буфер обмена.');
} catch { alert('Не удалось скопировать. Скопируйте вручную.'); }
});
document.getElementById('recovery-download').addEventListener('click', () => {
const blob = new Blob(
['Лидпоток — резервные коды\n\n' + getCodes().join('\n') + '\n\nХраните в безопасном месте.\n'],
{ type: 'text/plain;charset=utf-8' }
);
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = 'lidpotok-recovery-codes.txt';
a.click();
URL.revokeObjectURL(a.href);
});
// ---- Spec dialog ----
const specToggle = document.getElementById('spec-toggle');
const specOverlay = document.getElementById('spec-overlay');
const specClose = document.getElementById('spec-close');
specToggle.addEventListener('click', () => specOverlay.classList.add('is-open'));
specClose.addEventListener('click', () => specOverlay.classList.remove('is-open'));
specOverlay.addEventListener('click', (e) => {
if (e.target === specOverlay) specOverlay.classList.remove('is-open');
});
document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') specOverlay.classList.remove('is-open');
});
</script>
</body>
</html>