Админка, настройки, логин, кабинеты преподавателя и студента полностью переведены на React 19 (модель strangler, docs/REACT_MIGRATION.md). - 15 вкладок админки: lazy-загрузка, общий error boundary, сервисный слой - Удалены legacy-views и переходный слой LegacyTabView (45 файлов) - Сборка esbuild (frontend/scripts/build-react.mjs) с бюджетами gzip на чанки - CSP-хэши, кэширование: chunk'и immutable, CSS/HTML no-cache (react-cache.conf) - Фиксы: lazy-импорт именованных экспортов (краш после авторизации), растяжение #admin-root/#settings-root на всю ширину, возврат subjects/lessonTypes из справочников (краш просмотра расписаний), отказ от заведомо широкого поиска расписания без выбранной цели, центрирование карточки логина - Тесты: 237/237 (npm run check), регрессионные на lazy-вкладки и ширину - Документация: REACT_MIGRATION.md, REACT_MIGRATION_RESULT.md, FRONTEND.md
213 lines
8.9 KiB
JavaScript
213 lines
8.9 KiB
JavaScript
import { useEffect, useRef, useState } from 'react';
|
|
|
|
import { clearAuthState, storeAuthState } from '../../auth-session.js';
|
|
import { initializeTelemetry } from '../../telemetry.js';
|
|
import {
|
|
LOGIN_ERROR_MESSAGES,
|
|
requestLogin,
|
|
validateLogin
|
|
} from './login-service.js';
|
|
import { FormAlert } from '../shared/ui/FormAlert.jsx';
|
|
|
|
void initializeTelemetry();
|
|
|
|
const EMPTY_CREDENTIALS = Object.freeze({ password: '', username: '' });
|
|
|
|
function BrandIcon() {
|
|
return (
|
|
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
|
<rect width="40" height="40" rx="12" fill="url(#logo-gradient)" />
|
|
<path d="M12 20L18 26L28 14" stroke="white" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
|
<defs>
|
|
<linearGradient id="logo-gradient" x1="0" y1="0" x2="40" y2="40">
|
|
<stop stopColor="#6366f1" />
|
|
<stop offset="1" stopColor="#8b5cf6" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function UserIcon() {
|
|
return (
|
|
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
|
<circle cx="12" cy="7" r="4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function LockIcon() {
|
|
return (
|
|
<svg className="input-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
|
|
<rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
|
|
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
function EyeIcon() {
|
|
return (
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="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>
|
|
);
|
|
}
|
|
|
|
function Spinner() {
|
|
return (
|
|
<svg className="spinner" width="20" height="20" viewBox="0 0 24 24" aria-hidden="true">
|
|
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" fill="none" strokeDasharray="31.4 31.4" strokeLinecap="round" />
|
|
</svg>
|
|
);
|
|
}
|
|
|
|
export function LoginApp() {
|
|
const [credentials, setCredentials] = useState(EMPTY_CREDENTIALS);
|
|
const [errors, setErrors] = useState({});
|
|
const [alert, setAlert] = useState(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [passwordVisible, setPasswordVisible] = useState(false);
|
|
const [rippleVisible, setRippleVisible] = useState(false);
|
|
const redirectTimer = useRef(null);
|
|
|
|
useEffect(() => () => {
|
|
if (redirectTimer.current) {
|
|
window.clearTimeout(redirectTimer.current);
|
|
}
|
|
}, []);
|
|
|
|
const updateField = event => {
|
|
const { name, value } = event.target;
|
|
setCredentials(current => ({ ...current, [name]: value }));
|
|
setErrors(current => ({ ...current, [name]: undefined }));
|
|
};
|
|
|
|
const submit = async event => {
|
|
event.preventDefault();
|
|
const nextErrors = validateLogin(credentials);
|
|
setErrors(nextErrors);
|
|
setAlert(null);
|
|
|
|
if (Object.keys(nextErrors).length > 0) {
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
try {
|
|
const result = await requestLogin(credentials);
|
|
if (!result.ok) {
|
|
setAlert({ message: result.message, type: 'error' });
|
|
return;
|
|
}
|
|
|
|
clearAuthState();
|
|
const session = storeAuthState(result.data);
|
|
if (!session) {
|
|
setAlert({ message: LOGIN_ERROR_MESSAGES.invalidResponse, type: 'error' });
|
|
return;
|
|
}
|
|
|
|
setAlert({ message: 'Вход выполнен успешно!', type: 'success' });
|
|
redirectTimer.current = window.setTimeout(() => {
|
|
window.location.assign(session.redirect || '/');
|
|
}, 400);
|
|
} catch (error) {
|
|
setAlert({ message: LOGIN_ERROR_MESSAGES.connection, type: 'error' });
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const showRipple = () => {
|
|
setRippleVisible(false);
|
|
window.requestAnimationFrame(() => setRippleVisible(true));
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div className="background" aria-hidden="true">
|
|
<div className="shape shape-1" />
|
|
<div className="shape shape-2" />
|
|
<div className="shape shape-3" />
|
|
</div>
|
|
|
|
<main className="login-container">
|
|
<div className="login-card">
|
|
<header className="login-header">
|
|
<div className="logo"><BrandIcon /></div>
|
|
<h1>Добро пожаловать</h1>
|
|
<p className="subtitle">Войдите в свой аккаунт</p>
|
|
</header>
|
|
|
|
<form id="login-form" noValidate onSubmit={submit}>
|
|
<div className={`form-group stagger-1${errors.username ? ' has-error shake' : ''}`}>
|
|
<label htmlFor="username">Имя пользователя</label>
|
|
<div className="input-wrapper">
|
|
<UserIcon />
|
|
<input
|
|
type="text"
|
|
id="username"
|
|
name="username"
|
|
value={credentials.username}
|
|
onChange={updateField}
|
|
placeholder="Введите имя пользователя"
|
|
autoComplete="username"
|
|
aria-invalid={Boolean(errors.username)}
|
|
aria-describedby="username-error"
|
|
required
|
|
/>
|
|
</div>
|
|
<span className="error-message" id="username-error">{errors.username || ''}</span>
|
|
</div>
|
|
|
|
<div className={`form-group stagger-2${errors.password ? ' has-error shake' : ''}`}>
|
|
<label htmlFor="password">Пароль</label>
|
|
<div className="input-wrapper">
|
|
<LockIcon />
|
|
<input
|
|
type={passwordVisible ? 'text' : 'password'}
|
|
id="password"
|
|
name="password"
|
|
value={credentials.password}
|
|
onChange={updateField}
|
|
placeholder="Введите пароль"
|
|
autoComplete="current-password"
|
|
aria-invalid={Boolean(errors.password)}
|
|
aria-describedby="password-error"
|
|
required
|
|
/>
|
|
<button
|
|
type="button"
|
|
className="btn btn-icon-md btn-secondary toggle-password"
|
|
id="toggle-password"
|
|
aria-label={passwordVisible ? 'Скрыть пароль' : 'Показать пароль'}
|
|
aria-pressed={passwordVisible}
|
|
onClick={() => setPasswordVisible(visible => !visible)}
|
|
>
|
|
<EyeIcon />
|
|
</button>
|
|
</div>
|
|
<span className="error-message" id="password-error">{errors.password || ''}</span>
|
|
</div>
|
|
|
|
<FormAlert alert={alert} className="stagger-3" id="form-alert" />
|
|
|
|
<button
|
|
type="submit"
|
|
className="btn btn-lg btn-primary btn-submit stagger-4"
|
|
id="btn-submit"
|
|
disabled={loading}
|
|
onPointerDown={showRipple}
|
|
>
|
|
{loading ? <Spinner /> : <span className="btn-text">Войти</span>}
|
|
{rippleVisible && <span className="ripple" onAnimationEnd={() => setRippleVisible(false)} />}
|
|
</button>
|
|
</form>
|
|
</div>
|
|
</main>
|
|
</>
|
|
);
|
|
}
|