1071 lines
41 KiB
HTML
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>Поток ваших лидов — под контролем.</h1>
|
|
<p>SaaS-CRM для отделов продаж, которые покупают лиды у агрегаторов и хотят учитывать их в собственной системе. Webhook-приёмник, расширенная аналитика, кошельковый биллинг.</p>
|
|
<ul class="visual-features">
|
|
<li>14 настраиваемых статусов воронки и канбан-доска</li>
|
|
<li>Идемпотентный приём лидов через Webhook</li>
|
|
<li>Шифрование чувствительных полей и 2FA по 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">Сложность будет проверена через zxcvbn (минимум 3 из 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="#">политику конфиденциальности</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 секунд. Window = 1: примем коды ±30 сек от текущего.</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> сек
|
|
</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">Сохраните их в безопасном месте. Каждый код одноразовый, всего 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>Эти коды показаны один раз. После перехода на другой экран — восстановить будет нельзя.</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 10, zxcvbn ≥ 3) + согласие на оферту/политику.</li>
|
|
<li><strong>2FA TOTP</strong> — 6 ячеек, окно ±30 сек (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 px, чип кода — 6 px.</li>
|
|
<li>Высота input/button: 40 px (brandbook §5).</li>
|
|
</ul>
|
|
|
|
<h3>API контракты (для backend)</h3>
|
|
<ul>
|
|
<li><code>POST /api/v1/auth/login</code> → <code>{email, password, remember}</code>. 200 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 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>
|