Files
Learn_System/frontend/login.html
T
Maxim Dolgolyov 952a54f97c security+perf: полное ревью — 17 фиксов P0/P1 (XSS, IDOR, race conditions, rate limits, TURN, WAL)
## P0
- admin.html:2608, red-book-ecosystem.html:489-495 — XSS: u.name/node.name_ru/description обернуты в LS.esc()
- classController.js getAnnouncements — добавлена проверка teacher_id (B14: учитель A не может читать объявления класса B)

## P1 — auth & validation
- authController.js — минимум пароля 6→8 символов (register + change password + login.html)
- gamificationController adminAward — валидация max XP/coins (1M), Number coercion
- shopController adminAwardCoins — валидация max + проверка changes>0

## P1 — race conditions
- petController.buyBg — atomic UPDATE WHERE coins>=? (race-safe)
- shopController.purchaseItem — atomic conditional UPDATE
- liveController — добавлен question_id в live_answers (миграция с пересозданием таблицы), история ответов сохраняется при смене вопроса учителем
- ws-server: invalidateDrawCache экспортирован, classroomController grant/revoke вызывают его → permission revoke применяется мгновенно (раньше до 10s stale)

## P1 — rate limits & retry
- rateLimit middleware: новый параметр byUser=true (использует req.user.id вместо IP — не блокирует пользователей за NAT)
- routes/classroom.js: reactionLimiter (15/5s) на /chat/:msgId/react, handLimiter (5/5s) на raise/lower hand
- api.js sendAnswer — retry 3x с exp backoff (300/1200/2700ms), не повторяет на 4xx (F5)

## P1 — performance
- classroomController.getStrokes — LIMIT 5000 + флаг hasMore (защита от OOM на 10K+ strokes)
- whiteboard.js _liveStrokes — TTL 1.5s на каждый live preview (auto-cleanup при крашe ремоут юзера)

## Infrastructure
- config.js: TURN_URL/USER/PASS env vars
- server.js: GET /api/ice-servers возвращает STUN + опциональный TURN из env
- classroom-rtc.js: фетчит /api/ice-servers вместо хардкода (поддержка TURN для NAT/CGNAT школьных сетей)
- .env.example: документация TURN
- db.js: PRAGMA synchronous=NORMAL (5x быстрее с WAL), cache_size 16MB, temp_store=MEMORY
- ws-server.js closeAll() + server.js shutdown — graceful WS shutdown при SIGTERM

## False positives (не баги, агенты ошиблись)
- assignmentController FK на tests — на самом деле users (migrate.js:317-318)
- .env в git — gitignore корректно исключает
- admin.html без requireAuth — есть LS.initPage() который вызывает requireAuth
- submissionsController IDOR — обе ручки уже проверяют teacher_id
- screenSender = null inside try/catch — на самом деле снаружи
- SSE без backoff — есть exponential 2s→30s
- sessionController NOT IN на пустом массиве — есть guard usedIds.length>0
- getChat без LIMIT — есть LIMIT 100/200
- trust proxy — установлен на server.js:105

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-23 12:16:08 +03:00

1017 lines
44 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Вход — LearnSpace</title>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link href="https://fonts.googleapis.com/css2?family=Unbounded:wght@400;700;800&family=Manrope:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/css/ls.css" />
<style>
/* ── Layout ── */
.login-layout { display: flex; min-height: 100vh; }
/* ── Left panel ── */
.login-left {
flex: 1;
background: linear-gradient(145deg, #0b0920 0%, #160d3a 45%, #1e1250 100%);
position: relative; overflow: hidden;
display: flex; align-items: center;
padding: 60px 56px;
}
.login-left::before {
content: '';
position: absolute; inset: 0;
background-image: radial-gradient(circle, rgba(255,255,255,0.04) 1px, transparent 1px);
background-size: 24px 24px;
}
/* animated glow blobs */
.ll-blob { position: absolute; border-radius: 50%; pointer-events: none; animation: blobFloat 8s ease-in-out infinite; }
.ll-blob1 { width: 560px; height: 560px; background: radial-gradient(circle, rgba(155,93,229,0.28), transparent 68%); top: -180px; right: -120px; animation-delay: 0s; }
.ll-blob2 { width: 400px; height: 400px; background: radial-gradient(circle, rgba(6,214,224,0.18), transparent 68%); bottom: -120px; left: 10%; animation-delay: -3s; }
.ll-blob3 { width: 240px; height: 240px; background: radial-gradient(circle, rgba(241,91,181,0.14), transparent 68%); top: 40%; left: -80px; animation-delay: -5s; }
@keyframes blobFloat {
0%, 100% { transform: translate(0, 0) scale(1); }
33% { transform: translate(20px, -30px) scale(1.04); }
66% { transform: translate(-15px, 20px) scale(0.97); }
}
.ll-inner { position: relative; z-index: 1; max-width: 440px; }
.ll-logo {
font-family: 'Unbounded', sans-serif;
font-size: 1.3rem; font-weight: 800;
color: #fff; margin-bottom: 56px;
display: flex; align-items: center; gap: 10px;
}
.ll-logo-mark {
width: 36px; height: 36px; border-radius: 10px;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
display: flex; align-items: center; justify-content: center;
font-size: 1rem;
}
.ll-logo span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.ll-headline {
font-family: 'Unbounded', sans-serif;
font-size: 1.85rem; font-weight: 800; line-height: 1.28;
color: #fff; margin-bottom: 16px;
}
.ll-headline em { font-style: normal; background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.ll-tagline { font-size: 0.875rem; color: rgba(255,255,255,0.5); font-weight: 500; margin-bottom: 44px; line-height: 1.7; }
/* stats row */
.ll-stats { display: flex; gap: 20px; margin-bottom: 36px; }
.ll-stat {
flex: 1; padding: 14px 16px;
background: rgba(255,255,255,0.06);
border: 1px solid rgba(255,255,255,0.09);
border-radius: 14px;
}
.ll-stat-val { font-family: 'Unbounded', sans-serif; font-size: 1.3rem; font-weight: 800; color: #fff; }
.ll-stat-val span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
.ll-stat-lbl { font-size: 0.75rem; color: rgba(255,255,255,0.45); font-weight: 500; margin-top: 2px; }
/* feature list */
.ll-features { display: flex; flex-direction: column; gap: 9px; }
.ll-feat {
display: flex; align-items: center; gap: 12px;
font-size: 0.84rem; color: rgba(255,255,255,0.78); font-weight: 600;
padding: 11px 16px;
background: rgba(255,255,255,0.055);
border: 1px solid rgba(255,255,255,0.08);
border-radius: 12px;
transition: background .2s, border-color .2s;
}
.ll-feat:hover { background: rgba(255,255,255,0.09); border-color: rgba(255,255,255,0.14); }
.ll-feat-icon {
width: 28px; height: 28px; border-radius: 8px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
font-size: 0.9rem;
}
.ll-feat-icon.v { background: rgba(155,93,229,0.22); }
.ll-feat-icon.c { background: rgba(6,214,224,0.18); }
.ll-feat-icon.g { background: rgba(6,214,100,0.18); }
.ll-feat-icon.p { background: rgba(241,91,181,0.18); }
/* ── Right panel ── */
.login-right {
width: 480px; flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
padding: 48px 52px;
background: linear-gradient(145deg, #0e0824 0%, #1a0d38 55%, #160e3a 100%);
position: relative;
}
.login-right::before {
content: '';
position: absolute; top: 0; bottom: 0; left: -28px;
width: 56px; background: inherit;
clip-path: polygon(28px 0, 56px 0, 56px 100%, 0 100%);
z-index: 0;
}
.auth-wrap {
width: 100%; max-width: 380px;
position: relative; z-index: 1;
animation: slideUp .45s cubic-bezier(.4,0,.2,1) both;
}
@keyframes slideUp { from { opacity: 0; transform: translateY(22px); } to { opacity: 1; transform: translateY(0); } }
/* ── Лого-метка ── */
.auth-logo { display: flex; align-items: center; gap: 10px; margin-bottom: 32px; }
.auth-logo-mark {
width: 34px; height: 34px; border-radius: 10px; flex-shrink: 0;
background: linear-gradient(135deg, #06D6E0, #9B5DE5);
display: flex; align-items: center; justify-content: center;
}
.auth-logo-mark svg { width: 17px; height: 17px; stroke: #fff; stroke-width: 2; fill: none; stroke-linecap: round; stroke-linejoin: round; }
.auth-logo-name { font-family: 'Unbounded', sans-serif; font-size: 0.88rem; font-weight: 800; color: #fff; letter-spacing: -.01em; }
.auth-logo-name span { background: var(--grad-1); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; }
/* ── Заголовок ── */
.auth-header { margin-bottom: 30px; }
.auth-hello {
font-family: 'Unbounded', sans-serif; font-size: 1.55rem; font-weight: 800; line-height: 1.2;
background: linear-gradient(135deg, #fff 30%, rgba(155,93,229,0.85) 100%);
-webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text;
}
.auth-sub { font-size: 0.84rem; color: rgba(255,255,255,0.38); margin-top: 8px; font-weight: 500; }
/* ── Табы — underline ── */
.auth-tabs {
display: flex;
border-bottom: 1.5px solid rgba(255,255,255,0.08);
margin-bottom: 30px;
position: relative;
background: none; padding: 0;
}
.auth-tab {
flex: 1; padding: 0 4px 14px;
border: none; background: transparent;
font-family: 'Manrope', sans-serif;
font-size: 0.88rem; font-weight: 700;
color: rgba(255,255,255,0.32); cursor: pointer;
transition: color .22s; position: relative; z-index: 1;
}
.auth-tab.active { color: #fff; }
.auth-tab-slider {
position: absolute; bottom: -1.5px; left: 0;
width: 50%; height: 2px;
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
border-radius: 2px;
box-shadow: 0 0 12px rgba(155,93,229,0.7);
transition: transform .28s cubic-bezier(.4,0,.2,1);
}
.auth-tabs.on-reg .auth-tab-slider { transform: translateX(100%); }
/* ── Форма ── */
.auth-form { display: flex; flex-direction: column; gap: 22px; }
.auth-form.hidden { display: none; }
.auth-field { display: flex; flex-direction: column; }
.auth-label {
font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.38);
letter-spacing: .06em; text-transform: uppercase; margin-bottom: 8px;
}
/* инпут — только нижняя линия */
.auth-input-wrap { position: relative; }
.auth-input-icon {
position: absolute; left: 0; top: 50%; transform: translateY(-50%);
color: rgba(255,255,255,0.22); pointer-events: none; display: flex;
transition: color .2s;
}
.auth-input-wrap:focus-within .auth-input-icon { color: rgba(155,93,229,0.75); }
.auth-input {
width: 100%;
padding: 10px 30px 10px 26px;
border: none;
border-bottom: 1.5px solid rgba(255,255,255,0.12);
border-radius: 0;
background: transparent;
font-family: 'Manrope', sans-serif;
font-size: 0.92rem; font-weight: 500; color: #fff;
outline: none;
transition: border-color .2s;
}
.auth-input::placeholder { color: rgba(255,255,255,0.18); }
.auth-input:focus { border-color: transparent; }
/* анимированная линия фокуса */
.auth-input-wrap::after {
content: '';
position: absolute; bottom: 0; left: 0;
width: 0; height: 2px;
background: linear-gradient(90deg, #9B5DE5, #06D6E0);
border-radius: 2px;
box-shadow: 0 0 8px rgba(155,93,229,0.5);
transition: width .28s cubic-bezier(.4,0,.2,1);
}
.auth-input-wrap:focus-within::after { width: 100%; }
/* кнопка пароля */
.auth-eye {
position: absolute; right: 0; top: 50%; transform: translateY(-50%);
background: none; border: none; cursor: pointer; padding: 4px;
color: rgba(255,255,255,0.22); display: flex; border-radius: 6px;
transition: color .2s;
}
.auth-eye:hover { color: rgba(155,93,229,0.85); }
.auth-input.has-eye { padding-right: 30px; }
/* ── Ошибка — левый акцент ── */
.auth-error {
display: flex; align-items: flex-start; gap: 9px;
padding: 11px 14px;
background: rgba(249,65,68,0.09);
border-left: 3px solid #F94144;
border-radius: 0 10px 10px 0;
font-size: 0.81rem; color: #ff8090; font-weight: 600;
display: none;
}
.auth-error.visible { display: flex; }
.auth-error-icon { flex-shrink: 0; margin-top: 1px; color: #F94144; }
/* ── Кнопка ── */
.auth-btn {
margin-top: 6px;
padding: 14px;
border: none; border-radius: 14px;
background: linear-gradient(100deg, #7c3aed 0%, #9B5DE5 45%, #06D6E0 100%);
background-size: 200% 100%; background-position: 0% 0%;
color: #fff;
font-family: 'Manrope', sans-serif;
font-size: 0.95rem; font-weight: 700; letter-spacing: .01em;
cursor: pointer; position: relative; overflow: hidden;
transition: background-position .45s ease, transform .15s, box-shadow .15s;
}
.auth-btn::before {
content: '';
position: absolute; top: 0; left: -80%; width: 50%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,0.13), transparent);
transition: left .55s ease;
}
.auth-btn:hover:not(:disabled) { background-position: 100% 0%; transform: translateY(-1px); box-shadow: 0 10px 32px rgba(124,58,237,0.5); }
.auth-btn:hover:not(:disabled)::before { left: 130%; }
.auth-btn:active:not(:disabled) { transform: translateY(0); }
.auth-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.auth-btn-inner { display: flex; align-items: center; justify-content: center; gap: 8px; }
.auth-spinner {
width: 16px; height: 16px;
border: 2px solid rgba(255,255,255,0.3); border-top-color: #fff;
border-radius: 50%; animation: spin .7s linear infinite; display: none;
}
.auth-btn.loading .auth-spinner { display: block; }
@keyframes spin { to { transform: rotate(360deg); } }
/* ── Футер ── */
.auth-footer { margin-top: 28px; text-align: center; }
.auth-footer a {
display: inline-flex; align-items: center; gap: 6px;
font-size: 0.79rem; font-weight: 600;
color: rgba(255,255,255,0.3); text-decoration: none;
transition: color .2s;
}
.auth-footer a:hover { color: #c084fc; }
.auth-footer a svg { width: 14px; height: 14px; stroke: currentColor; stroke-width: 2; fill: none; }
/* ── Разделитель ── */
.auth-divider {
display: flex; align-items: center; gap: 10px;
color: rgba(255,255,255,0.18); font-size: 0.74rem; font-weight: 600;
}
.auth-divider::before, .auth-divider::after { content: ''; flex: 1; height: 1px; background: rgba(255,255,255,0.07); }
/* ── Password strength ── */
.pw-strength { display: flex; align-items: center; gap: 9px; margin-top: 8px; }
.pw-bars { display: flex; gap: 4px; flex: 1; }
.pw-seg { flex: 1; height: 2px; border-radius: 99px; background: rgba(255,255,255,0.1); transition: background .25s; }
.pw-seg.lit.s1 { background: #ef4444; }
.pw-seg.lit.s2 { background: #f59e0b; }
.pw-seg.lit.s3 { background: #22c55e; }
.pw-seg.lit.s4 { background: #06d6a0; }
.pw-lbl { font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.3); min-width: 52px; text-align: right; transition: color .25s; }
.pw-lbl.s1 { color: #ef4444; } .pw-lbl.s2 { color: #f59e0b; } .pw-lbl.s3 { color: #22c55e; } .pw-lbl.s4 { color: #06d6a0; }
/* ── Shake on error ── */
@keyframes authShake { 0%,100%{transform:translateX(0)} 20%{transform:translateX(-6px)} 40%{transform:translateX(6px)} 60%{transform:translateX(-4px)} 80%{transform:translateX(4px)} }
.auth-form.shake { animation: authShake .35s ease; }
/* ── Input error state ── */
.auth-input.invalid { border-color: rgba(249,65,68,0.6); }
/* ── Progress bar ── */
#auth-progress { position: fixed; top: 0; left: 0; height: 3px; width: 0%;
background: linear-gradient(90deg, #9B5DE5, #06D6E0, #F9C74F);
z-index: 9999; border-radius: 0 2px 2px 0;
transition: width .25s ease; pointer-events: none; display: none; }
/* ── Success button ── */
.auth-btn.success { background: linear-gradient(135deg, #22c55e, #06d6a0) !important; }
.auth-btn.success:hover { box-shadow: 0 10px 32px rgba(34,197,94,0.4); }
/* ── Remember me ── */
.auth-remember { display: flex; align-items: center; gap: 9px; margin-top: -6px; }
.auth-remember input[type=checkbox] { width: 14px; height: 14px; accent-color: #9B5DE5; cursor: pointer; flex-shrink: 0; }
.auth-remember label { font-size: 0.79rem; color: rgba(255,255,255,0.35); cursor: pointer; user-select: none; }
/* ── Caps lock warning ── */
.caps-warn { display: none; align-items: center; gap: 5px;
font-size: 0.72rem; font-weight: 700; color: #fbbf24;
padding: 4px 0; margin-top: 4px; }
.caps-warn.show { display: flex; }
/* ── Input icon transition ── */
.auth-input-icon { transition: color .2s; }
/* ── Canvas in left panel ── */
#ll-canvas { position: absolute; inset: 0; pointer-events: none; z-index: 0; }
/* responsive */
@media (max-width: 860px) {
.login-left { padding: 40px 28px; }
.login-right { width: 100%; padding: 36px 24px; }
.ll-headline { font-size: 1.4rem; }
.ll-stats { gap: 12px; }
}
@media (max-width: 640px) {
.login-layout { flex-direction: column; }
.login-left { min-height: auto; padding: 36px 24px 32px; }
.ll-stats, .ll-features { display: none; }
.ll-tagline { margin-bottom: 0; }
.ll-headline { font-size: 1.25rem; }
.login-right { padding: 32px 20px 40px; }
}
@media (max-width: 400px) {
.login-left { display: none; }
.login-right { min-height: 100vh; align-items: flex-start; padding-top: 32px; }
}
</style>
</head>
<body>
<div class="login-layout">
<!-- ── Left panel ── -->
<div class="login-left">
<canvas id="ll-canvas"></canvas>
<div class="ll-blob ll-blob1"></div>
<div class="ll-blob ll-blob2"></div>
<div class="ll-blob ll-blob3"></div>
<div class="ll-inner">
<div class="ll-logo">
<div class="ll-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div>
Learn<span>Space</span>
</div>
<h1 class="ll-headline">Платформа<br>нового поколения<br>для <em>подготовки к ЦТ</em></h1>
<p class="ll-tagline">Умные задания · Прогресс в реальном времени<br>Аналитика для учителей</p>
<div class="ll-stats">
<div class="ll-stat">
<div class="ll-stat-val"><span id="stat-q">1000+</span></div>
<div class="ll-stat-lbl">вопросов в банке</div>
</div>
<div class="ll-stat">
<div class="ll-stat-val"><span id="stat-s">4</span></div>
<div class="ll-stat-lbl">предмета ЦТ/ЦЭ</div>
</div>
<div class="ll-stat">
<div class="ll-stat-val"><span></span></div>
<div class="ll-stat-lbl">попыток</div>
</div>
</div>
<div class="ll-features">
<div class="ll-feat">
<div class="ll-feat-icon v"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="6"/><circle cx="12" cy="12" r="2"/></svg></div>
Персональные задания по каждому предмету
</div>
<div class="ll-feat">
<div class="ll-feat-icon c"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M12 20V10M18 20V4M6 20v-4"/></svg></div>
Детальная аналитика прогресса ученика
</div>
<div class="ll-feat">
<div class="ll-feat-icon g"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M3 21V9l9-6 9 6v12"/><path d="M9 21V12h6v9"/></svg></div>
Управление классами и заданиями
</div>
<div class="ll-feat">
<div class="ll-feat-icon p"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="M16 4h2a2 2 0 012 2v14a2 2 0 01-2 2H6a2 2 0 01-2-2V6a2 2 0 012-2h2"/><rect x="8" y="2" width="8" height="4" rx="1"/></svg></div>
Доска активности и объявлений класса
</div>
</div>
</div>
</div>
<!-- ── Right panel ── -->
<div class="login-right">
<div class="auth-wrap">
<div class="auth-logo">
<div class="auth-logo-mark">
<svg viewBox="0 0 24 24"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</div>
<div class="auth-logo-name">Learn<span>Space</span></div>
</div>
<div class="auth-header">
<div class="auth-hello" id="auth-hello">С возвращением!</div>
<div class="auth-sub" id="auth-sub">Войдите в свой аккаунт</div>
</div>
<div class="auth-tabs" id="auth-tabs">
<div class="auth-tab-slider"></div>
<button class="auth-tab active" data-tab="login" onclick="switchTab('login')">Войти</button>
<button class="auth-tab" data-tab="register" onclick="switchTab('register')">Регистрация</button>
</div>
<!-- ── Вход ── -->
<form class="auth-form" id="form-login" autocomplete="on">
<div class="auth-error" id="err-login">
<span class="auth-error-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="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 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span id="err-login-text"></span>
</div>
<div class="auth-field">
<label class="auth-label" for="login-email">Email</label>
<div class="auth-input-wrap">
<span class="auth-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
</span>
<input class="auth-input" type="email" id="login-email" name="email" placeholder="you@example.com" autocomplete="email" required />
</div>
</div>
<div class="auth-field">
<label class="auth-label" for="login-pass">Пароль</label>
<div class="auth-input-wrap">
<span class="auth-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input class="auth-input has-eye" type="password" id="login-pass" name="password" placeholder="••••••••" autocomplete="current-password" required />
<button type="button" class="auth-eye" onclick="toggleEye('login-pass', this)" aria-label="Показать пароль">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<div class="caps-warn" id="caps-login-pass">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Caps Lock включён
</div>
</div>
<div class="auth-remember">
<input type="checkbox" id="remember-me" />
<label for="remember-me">Запомнить меня</label>
</div>
<button class="auth-btn" type="submit" id="btn-login">
<div class="auth-btn-inner">
<div class="auth-spinner"></div>
<span class="auth-btn-lbl">Войти</span>
</div>
</button>
</form>
<!-- ── Регистрация ── -->
<form class="auth-form hidden" id="form-register" autocomplete="on">
<div class="auth-error" id="err-reg">
<span class="auth-error-icon"><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="display:inline-block;vertical-align:middle"><path d="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 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg></span>
<span id="err-reg-text"></span>
</div>
<div class="auth-field">
<label class="auth-label" for="reg-name">Имя и фамилия</label>
<div class="auth-input-wrap">
<span class="auth-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 20c0-4 3.6-7 8-7s8 3 8 7"/></svg>
</span>
<input class="auth-input" type="text" id="reg-name" name="name" placeholder="Иван Иванов" autocomplete="name" required />
</div>
</div>
<div class="auth-field">
<label class="auth-label" for="reg-email">Email</label>
<div class="auth-input-wrap">
<span class="auth-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="20" height="16" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>
</span>
<input class="auth-input" type="email" id="reg-email" name="email" placeholder="you@example.com" autocomplete="email" required />
</div>
</div>
<div class="auth-field">
<label class="auth-label" for="reg-pass">Пароль</label>
<div class="auth-input-wrap">
<span class="auth-input-icon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
</span>
<input class="auth-input has-eye" type="password" id="reg-pass" name="password" placeholder="Минимум 8 символов" autocomplete="new-password" required minlength="8" />
<button type="button" class="auth-eye" onclick="toggleEye('reg-pass', this)" aria-label="Показать пароль">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>
</button>
</div>
<div class="caps-warn" id="caps-reg-pass">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5M2 12l10 5 10-5"/></svg>
Caps Lock включён
</div>
<div class="pw-strength" id="pw-strength" style="visibility:hidden">
<div class="pw-bars">
<div class="pw-seg" id="pw-s1"></div>
<div class="pw-seg" id="pw-s2"></div>
<div class="pw-seg" id="pw-s3"></div>
<div class="pw-seg" id="pw-s4"></div>
</div>
<span class="pw-lbl" id="pw-lbl"></span>
</div>
</div>
<button class="auth-btn" type="submit" id="btn-reg">
<div class="auth-btn-inner">
<div class="auth-spinner"></div>
<span class="auth-btn-lbl">Создать аккаунт</span>
</div>
</button>
</form>
<div class="auth-footer">
<a href="/">
<svg viewBox="0 0 24 24"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
На главную
</a>
</div>
</div>
</div>
</div>
<div id="auth-progress"></div>
<script src="/js/api.js"></script>
<script>
if (LS.isLoggedIn()) window.location.href = '/dashboard';
let _tab = 'login';
// ── Remember Me ──
const RM_KEY = 'ls_remember_email';
const savedEmail = localStorage.getItem(RM_KEY);
if (savedEmail) {
document.getElementById('login-email').value = savedEmail;
document.getElementById('remember-me').checked = true;
}
// ── Count-up animation ──
function countUp(el, target, suffix) {
const start = Date.now();
const dur = 1200;
function tick() {
const t = Math.min(1, (Date.now() - start) / dur);
const ease = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2;
el.textContent = Math.round(ease * target) + suffix;
if (t < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const statQ = document.getElementById('stat-q');
const statS = document.getElementById('stat-s');
if (statQ) countUp(statQ, 1000, '+');
if (statS) countUp(statS, 4, '');
// ── Progress bar ──
let _progTimer = null;
function startProgress() {
const bar = document.getElementById('auth-progress');
bar.style.display = 'block';
bar.style.width = '0%';
bar.style.transition = 'none';
requestAnimationFrame(() => {
bar.style.transition = 'width 2.5s cubic-bezier(.1,0,.05,1)';
bar.style.width = '80%';
});
}
function finishProgress() {
const bar = document.getElementById('auth-progress');
bar.style.transition = 'width .2s ease';
bar.style.width = '100%';
setTimeout(() => { bar.style.display = 'none'; bar.style.width = '0%'; }, 300);
}
// ── Success button ──
function showBtnSuccess(btnId, label) {
const btn = document.getElementById(btnId);
btn.classList.remove('loading');
btn.classList.add('success');
btn.querySelector('.auth-btn-lbl').textContent = '✓ ' + label;
}
const HELLO = { login: 'С возвращением!', register: 'Создать аккаунт' };
const SUB = { login: 'Войдите в свой аккаунт', register: 'Регистрация займёт 30 секунд' };
function switchTab(tab) {
if (_tab === tab) return;
_tab = tab;
document.getElementById('auth-hello').textContent = HELLO[tab];
document.getElementById('auth-sub').textContent = SUB[tab];
document.querySelectorAll('.auth-tab').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.getElementById('auth-tabs').classList.toggle('on-reg', tab === 'register');
// Slide transition
const formOut = document.getElementById(tab === 'login' ? 'form-register' : 'form-login');
const formIn = document.getElementById('form-' + tab);
formOut.classList.add('hidden');
formIn.classList.remove('hidden');
formIn.style.opacity = '0';
formIn.style.transform = 'translateX(' + (tab === 'login' ? '-12px' : '12px') + ')';
requestAnimationFrame(() => {
formIn.style.transition = 'opacity .22s ease, transform .22s ease';
formIn.style.opacity = '1';
formIn.style.transform = 'translateX(0)';
});
hideError('err-login'); hideError('err-reg');
const f = document.querySelector('#form-' + tab + ' .auth-input');
if (f) setTimeout(() => f.focus(), 60);
}
function showError(id, msg) {
const el = document.getElementById(id);
document.getElementById(id + '-text').textContent = msg;
el.classList.add('visible');
const form = el.closest('.auth-form');
if (form) {
form.classList.remove('shake');
void form.offsetWidth;
form.classList.add('shake');
form.addEventListener('animationend', () => form.classList.remove('shake'), { once: true });
}
form?.querySelectorAll('.auth-input').forEach(inp => inp.classList.add('invalid'));
}
function hideError(id) {
document.getElementById(id).classList.remove('visible');
document.getElementById(id)?.closest('.auth-form')?.querySelectorAll('.auth-input.invalid').forEach(inp => inp.classList.remove('invalid'));
}
function setLoading(btnId, loading) {
const btn = document.getElementById(btnId);
btn.disabled = loading;
btn.classList.toggle('loading', loading);
if (loading) btn.classList.remove('success');
}
function toggleEye(inputId, btn) {
const inp = document.getElementById(inputId);
const isText = inp.type === 'text';
inp.type = isText ? 'password' : 'text';
btn.innerHTML = isText
? `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M2 12s3-7 10-7 10 7 10 7-3 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>`
: `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`;
btn.style.color = isText ? '' : 'var(--violet)';
}
// ── Caps Lock detection ──
function watchCaps(inputId, warnId) {
const inp = document.getElementById(inputId);
const warn = document.getElementById(warnId);
if (!inp || !warn) return;
inp.addEventListener('keyup', e => {
warn.classList.toggle('show', e.getModifierState && e.getModifierState('CapsLock'));
});
inp.addEventListener('blur', () => warn.classList.remove('show'));
}
watchCaps('login-pass', 'caps-login-pass');
watchCaps('reg-pass', 'caps-reg-pass');
// ── Email icon validation ──
function watchEmail(inputId) {
const inp = document.getElementById(inputId);
if (!inp) return;
const icon = inp.closest('.auth-input-wrap')?.querySelector('.auth-input-icon');
if (!icon) return;
inp.addEventListener('input', () => {
const valid = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inp.value);
icon.style.color = inp.value.length > 3 ? (valid ? '#22c55e' : '#ef4444') : '';
});
}
watchEmail('login-email');
watchEmail('reg-email');
// ── Login ──
document.getElementById('form-login').addEventListener('submit', async e => {
e.preventDefault();
hideError('err-login');
setLoading('btn-login', true);
startProgress();
try {
const email = document.getElementById('login-email').value;
const pass = document.getElementById('login-pass').value;
if (document.getElementById('remember-me').checked) {
localStorage.setItem(RM_KEY, email);
} else {
localStorage.removeItem(RM_KEY);
}
await LS.login(email, pass);
finishProgress();
showBtnSuccess('btn-login', 'Входим...');
const u = LS.getUser();
setTimeout(() => {
window.location.href = ['teacher', 'admin'].includes(u?.role) ? '/classes' : '/dashboard';
}, 400);
} catch (err) {
finishProgress();
showError('err-login', err.message);
setLoading('btn-login', false);
}
});
// ── Register ──
document.getElementById('form-register').addEventListener('submit', async e => {
e.preventDefault();
hideError('err-reg');
setLoading('btn-reg', true);
startProgress();
try {
await LS.register(
document.getElementById('reg-email').value,
document.getElementById('reg-pass').value,
document.getElementById('reg-name').value
);
finishProgress();
showBtnSuccess('btn-reg', 'Аккаунт создан!');
const u = LS.getUser();
setTimeout(() => { window.location.href = ['teacher', 'admin'].includes(u?.role) ? '/classes' : '/dashboard'; }, 500);
} catch (err) {
finishProgress();
showError('err-reg', err.message);
setLoading('btn-reg', false);
}
});
// ── Password strength ──
const STRENGTH_LABELS = ['', 'Слабый', 'Средний', 'Хороший', 'Надёжный'];
function calcStrength(pw) {
if (!pw) return 0;
let s = 0;
if (pw.length >= 6) s++;
if (pw.length >= 10) s++;
if (/[A-Z]/.test(pw) && /[a-z]/.test(pw)) s++;
if (/[\d!@#$%^&*()_\-+=[\]{};:'",.<>?/\\|`~]/.test(pw)) s++;
return s;
}
document.getElementById('reg-pass').addEventListener('input', function() {
const pw = this.value;
const str = calcStrength(pw);
const wrap = document.getElementById('pw-strength');
wrap.style.visibility = pw ? 'visible' : 'hidden';
for (let i = 1; i <= 4; i++) {
const seg = document.getElementById(`pw-s${i}`);
seg.className = `pw-seg${i <= str ? ` lit s${str}` : ''}`;
}
const lbl = document.getElementById('pw-lbl');
lbl.className = `pw-lbl${str ? ` s${str}` : ''}`;
lbl.textContent = pw ? STRENGTH_LABELS[str] : '';
});
// Clear invalid on typing
document.querySelectorAll('.auth-input').forEach(inp => {
inp.addEventListener('input', () => inp.classList.remove('invalid'));
});
// ── Neural Network canvas ──
(function() {
const canvas = document.getElementById('ll-canvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
let W, H, mouse = { x: -999, y: -999 }, tick = 0;
const COLORS = ['155,93,229', '6,214,224', '241,91,181', '56,189,248'];
const CONN_D = 145;
const N_NODES = 90;
const nodes = [];
const signals = []; // pulses traveling along edges
function resize() {
const p = canvas.parentElement;
W = canvas.width = p.clientWidth || p.offsetWidth || 800;
H = canvas.height = p.clientHeight || p.offsetHeight || 600;
}
resize();
new ResizeObserver(resize).observe(canvas.parentElement);
function mkNode(x, y, burst) {
const hub = !burst && Math.random() < 0.13;
return {
x: x ?? Math.random() * W,
y: y ?? Math.random() * H,
vx: (Math.random() - 0.5) * (burst ? 2.0 : 0.45),
vy: (Math.random() - 0.5) * (burst ? 2.0 : 0.45),
r: hub ? 3.8 + Math.random() * 1.4 : 1.4 + Math.random() * 1.6,
hub,
color: COLORS[Math.floor(Math.random() * COLORS.length)],
pulse: Math.random() * Math.PI * 2,
life: burst ? 1 : null
};
}
for (let i = 0; i < N_NODES; i++) nodes.push(mkNode());
const par = canvas.parentElement;
par.addEventListener('mousemove', e => {
const r = canvas.getBoundingClientRect();
mouse.x = e.clientX - r.left; mouse.y = e.clientY - r.top;
});
par.addEventListener('mouseleave', () => { mouse.x = -999; mouse.y = -999; });
par.addEventListener('click', e => {
const r = canvas.getBoundingClientRect();
const cx = e.clientX - r.left, cy = e.clientY - r.top;
for (let i = 0; i < 8; i++) {
const n = mkNode(cx, cy, true);
const a = (i / 8) * Math.PI * 2;
n.vx = Math.cos(a) * (1.6 + Math.random());
n.vy = Math.sin(a) * (1.6 + Math.random());
nodes.push(n);
}
});
const TAIL_LEN = 10; // comet tail steps
function spawnSignal(ax, ay, bx, by, color, nodeB) {
signals.push({
ax, ay, bx, by,
t: 0,
spd: 0.007 + Math.random() * 0.007,
color,
nodeB, // destination node ref for flash (B)
tail: [], // comet tail history (A)
edgeActive: false
});
}
// active edge map: "i,j" → brightness boost (C)
const activeEdges = new Map();
function draw() {
ctx.clearRect(0, 0, W, H);
tick++;
// ── update nodes ──
for (let i = nodes.length - 1; i >= 0; i--) {
const n = nodes[i];
n.pulse += 0.02;
if (n.flash > 0) n.flash -= 0.06; // B: decay flash
const mdx = mouse.x - n.x, mdy = mouse.y - n.y;
const md = Math.sqrt(mdx * mdx + mdy * mdy) || 1;
if (md < 190) {
const f = (190 - md) / 190;
if (md < 65) { n.vx -= (mdx / md) * f * 0.042; n.vy -= (mdy / md) * f * 0.042; }
else { n.vx += (mdx / md) * f * 0.020; n.vy += (mdy / md) * f * 0.020; }
}
n.vx *= 0.986; n.vy *= 0.986;
n.x += n.vx; n.y += n.vy;
if (n.x < 0) { n.x = 0; n.vx = Math.abs(n.vx); }
if (n.x > W) { n.x = W; n.vx = -Math.abs(n.vx); }
if (n.y < 0) { n.y = 0; n.vy = Math.abs(n.vy); }
if (n.y > H) { n.y = H; n.vy = -Math.abs(n.vy); }
if (n.life !== null) {
n.life -= 0.018;
if (n.life <= 0) { nodes.splice(i, 1); continue; }
}
}
// ── build active edge set from signals (C) ──
activeEdges.clear();
for (const s of signals) {
if (s._edgeKey) activeEdges.set(s._edgeKey, Math.max(activeEdges.get(s._edgeKey) || 0, 1 - Math.abs(s.t - 0.5) * 2));
}
// ── draw edges + collect pairs ──
const pairs = [];
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = nodes[i], b = nodes[j];
if (a.life !== null || b.life !== null) continue;
const dx = a.x - b.x, dy = a.y - b.y;
const d = Math.sqrt(dx * dx + dy * dy);
if (d < CONN_D) {
pairs.push([a, b, i, j]);
const key = i + ',' + j;
const boost = activeEdges.get(key) || 0; // C: active boost
const baseAlpha = (1 - d / CONN_D) * (a.hub || b.hub ? 0.28 : 0.16);
const alpha = baseAlpha + boost * 0.38;
const lw = (a.hub || b.hub ? 1.1 : 0.65) + boost * 0.8;
ctx.beginPath();
ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y);
ctx.strokeStyle = boost > 0.05
? `rgba(${a.color},${Math.min(alpha, 0.7)})`
: `rgba(155,93,229,${alpha})`;
ctx.lineWidth = lw;
ctx.stroke();
}
}
}
// ── spawn signals ──
if (tick % 16 === 0 && signals.length < 30 && pairs.length > 0) {
const idx = Math.floor(Math.random() * pairs.length);
const [a, b, ni, nj] = pairs[idx];
const color = Math.random() < 0.5 ? a.color : b.color;
const sig = { ax: a.x, ay: a.y, bx: b.x, by: b.y,
t: 0, spd: 0.007 + Math.random() * 0.007,
color, nodeB: b, tail: [], _edgeKey: ni + ',' + nj };
signals.push(sig);
}
// ── draw signals: comet tail + head ──
for (let i = signals.length - 1; i >= 0; i--) {
const s = signals[i];
s.t += s.spd;
// B: flash destination node on arrival
if (s.t >= 1) {
if (s.nodeB && s.nodeB.flash === undefined) s.nodeB.flash = 0;
if (s.nodeB) s.nodeB.flash = 1.0;
signals.splice(i, 1);
continue;
}
const x = s.ax + (s.bx - s.ax) * s.t;
const y = s.ay + (s.by - s.ay) * s.t;
const fade = s.t < 0.12 ? s.t / 0.12 : s.t > 0.88 ? (1 - s.t) / 0.12 : 1;
// A: push tail, limit length
s.tail.push({ x, y });
if (s.tail.length > TAIL_LEN) s.tail.shift();
// A: draw comet tail
for (let k = 0; k < s.tail.length - 1; k++) {
const tf = (k / TAIL_LEN) * fade;
ctx.beginPath();
ctx.moveTo(s.tail[k].x, s.tail[k].y);
ctx.lineTo(s.tail[k + 1].x, s.tail[k + 1].y);
ctx.strokeStyle = `rgba(${s.color},${tf * 0.55})`;
ctx.lineWidth = 1.5 * (k / TAIL_LEN);
ctx.stroke();
}
// head outer glow
const g = ctx.createRadialGradient(x, y, 0, x, y, 9);
g.addColorStop(0, `rgba(${s.color},${0.8 * fade})`);
g.addColorStop(1, `rgba(${s.color},0)`);
ctx.beginPath();
ctx.arc(x, y, 9, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
// head core
ctx.beginPath();
ctx.arc(x, y, 2.5, 0, Math.PI * 2);
ctx.fillStyle = `rgba(255,255,255,${0.97 * fade})`;
ctx.fill();
}
// ── draw nodes ──
for (const n of nodes) {
const flash = Math.max(0, n.flash || 0); // B
const baseA = n.life !== null ? n.life * 0.9 : 0.5 + Math.sin(n.pulse) * 0.12 + flash * 0.5;
const radius = n.r * (0.93 + Math.sin(n.pulse) * 0.07 + flash * 0.5);
const glowR = radius * (n.hub ? 5.5 : 4) * (1 + flash * 0.8);
// glow halo (brighter on flash)
const g = ctx.createRadialGradient(n.x, n.y, 0, n.x, n.y, glowR);
g.addColorStop(0, `rgba(${n.color},${Math.min(baseA * 0.38 + flash * 0.4, 0.9)})`);
g.addColorStop(1, `rgba(${n.color},0)`);
ctx.beginPath();
ctx.arc(n.x, n.y, glowR, 0, Math.PI * 2);
ctx.fillStyle = g;
ctx.fill();
// B: flash ring
if (flash > 0.05) {
ctx.beginPath();
ctx.arc(n.x, n.y, radius + flash * 10, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${n.color},${flash * 0.6})`;
ctx.lineWidth = flash * 2;
ctx.stroke();
}
// core dot
ctx.beginPath();
ctx.arc(n.x, n.y, radius, 0, Math.PI * 2);
ctx.fillStyle = `rgba(${n.color},${Math.min(baseA, 1)})`;
ctx.fill();
// hub: outer ring
if (n.hub) {
ctx.beginPath();
ctx.arc(n.x, n.y, radius + 3, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(${n.color},${baseA * 0.38})`;
ctx.lineWidth = 1;
ctx.stroke();
}
}
requestAnimationFrame(draw);
}
draw();
})();
// auto-focus: if email was pre-filled, focus password
if (savedEmail) {
document.getElementById('login-pass').focus();
} else {
document.getElementById('login-email').focus();
}
</script>
</body>
</html>