Окно авторизации на JavaScript: модалка, fetch и обработка ошибок
Окно входа поверх страницы удобнее отдельной страницы логина: пользователь не теряет место, где читал. В статье соберём такое окно на чистом JavaScript, без библиотек, а в конце покажем вариант на Bootstrap 5.3.
Серверная часть — обработчик login.php из статьи «Авторизация на чистом PHP». Он принимает POST с полями email, password, _token и при заголовке Accept: application/json отвечает так:
{ "ok": true, "redirect": "/profile.php" }
{ "ok": false, "message": "Неверный email или пароль" }
Сверстать модальное окно
Сделайте окно с полями email и пароль, которое открывается по кнопке «Войти» и закрывается по Esc, крестику и клику по фону.
Показать решение Скрыть решение
Используем нативный элемент <dialog>. Метод showModal() сам блокирует страницу под окном, удерживает фокус внутри и закрывает окно по Esc.
<meta name="csrf-token" content="<?= csrf_token() ?>">
<button type="button" id="open-login">Войти</button>
<dialog id="login-dialog" aria-labelledby="login-title">
<form id="login-form" method="post" action="/login.php" novalidate>
<header class="dialog-header">
<h2 id="login-title">Вход</h2>
<button type="button" class="dialog-close" aria-label="Закрыть">×</button>
</header>
<div class="form-error" role="alert" hidden></div>
<label>
Email
<input type="email" name="email" autocomplete="email" required>
</label>
<label>
Пароль
<input type="password" name="password" autocomplete="current-password"
required minlength="8">
</label>
<button type="submit">Войти</button>
</form>
</dialog>
#login-dialog {
width: min(400px, calc(100vw - 32px));
border: none;
border-radius: 12px;
padding: 24px;
box-shadow: 0 20px 60px rgb(0 0 0 / .25);
}
#login-dialog::backdrop {
background: rgb(0 0 0 / .5);
}
#login-form { display: grid; gap: 16px; }
#login-form label { display: grid; gap: 4px; }
.dialog-header { display: flex; justify-content: space-between; align-items: center; }
.dialog-close { border: none; background: none; font-size: 24px; cursor: pointer; }
.form-error { padding: 8px 12px; border-radius: 6px; background: #fdecea; color: #b42318; }
input[aria-invalid="true"] { border-color: #b42318; }
const dialog = document.getElementById('login-dialog');
document.getElementById('open-login').addEventListener('click', () => {
dialog.showModal();
dialog.querySelector('input[name="email"]').focus();
});
dialog.querySelector('.dialog-close').addEventListener('click', () => dialog.close());
// Клик по фону: событие приходит на сам <dialog>, а не на его содержимое
dialog.addEventListener('click', (event) => {
if (event.target === dialog) dialog.close();
});
Проверить поля до отправки
Не отправляйте запрос, если email некорректен или пароль короче 8 символов. Ошибку покажите под полем.
Показать решение Скрыть решение
Используем встроенную валидацию браузера (required, type="email", minlength), но выводим сообщения сами. Атрибут novalidate на форме отключает стандартные всплывашки.
const messages = {
email: { valueMissing: 'Введите email', typeMismatch: 'Некорректный email' },
password: { valueMissing: 'Введите пароль', tooShort: 'Минимум 8 символов' },
};
function showFieldError(input, text) {
let hint = input.parentElement.querySelector('.field-hint');
if (!hint) {
hint = document.createElement('small');
hint.className = 'field-hint';
hint.id = `${input.name}-hint`;
input.parentElement.append(hint);
}
hint.textContent = text;
input.setAttribute('aria-invalid', text ? 'true' : 'false');
input.setAttribute('aria-describedby', hint.id);
}
function validate(form) {
let valid = true;
for (const input of form.querySelectorAll('input[name]')) {
const rules = messages[input.name];
if (!rules) continue;
const failed = Object.keys(rules).find((key) => input.validity[key]);
showFieldError(input, failed ? rules[failed] : '');
if (failed) valid = false;
}
return valid;
}
Клиентская проверка — только для удобства. Сервер всё равно проверяет всё заново: запрос можно отправить в обход формы.
Отправить форму через fetch
Отправьте данные без перезагрузки страницы. На время запроса заблокируйте кнопку. При успехе перейдите по адресу из ответа.
Показать решение Скрыть решение
const form = document.getElementById('login-form');
const errorBox = form.querySelector('.form-error');
const submitBtn = form.querySelector('button[type="submit"]');
const csrfToken = document.querySelector('meta[name="csrf-token"]').content;
function showFormError(text) {
errorBox.textContent = text;
errorBox.hidden = !text;
}
form.addEventListener('submit', async (event) => {
event.preventDefault();
showFormError('');
if (!validate(form)) return;
const body = new FormData(form);
body.append('_token', csrfToken);
body.append('redirect', location.pathname); // вернуться на текущую страницу
submitBtn.disabled = true;
submitBtn.textContent = 'Входим…';
try {
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
credentials: 'same-origin',
});
const data = await response.json().catch(() => ({}));
if (response.ok && data.ok) {
location.assign(data.redirect || '/');
return;
}
showFormError(data.message || errorText(response.status));
} catch {
showFormError('Нет соединения с сервером. Проверьте интернет.');
} finally {
submitBtn.disabled = false;
submitBtn.textContent = 'Войти';
}
});
function errorText(status) {
switch (status) {
case 419: return 'Страница устарела. Обновите её и попробуйте снова.';
case 429: return 'Слишком много попыток. Подождите немного.';
case 500: return 'Ошибка на сервере. Попробуйте позже.';
default: return 'Не удалось войти. Попробуйте ещё раз.';
}
}
Разберём важные моменты:
FormDataне требует заголовкаContent-Type— браузер выставит его сам вместе с границей multipart.fetchне выбрасывает ошибку на ответы 4xx/5xx — только на сетевые сбои. Поэтому статус проверяем вручную черезresponse.ok..catch(() => ({}))уresponse.json()нужен на случай, если сервер вернул HTML (например, страницу ошибки).finallyразблокирует кнопку в любом случае.
Открывать окно при попытке открыть закрытый контент
На сайте есть ссылки на приватные статьи. Гость при клике должен видеть окно входа, а после входа попадать на нужную статью.
Показать решение Скрыть решение
Помечаем такие ссылки атрибутом data-requires-auth и перехватываем клик:
<a href="/a/docker-production" data-requires-auth>🔒 Docker в продакшене</a>
let pendingUrl = null;
document.addEventListener('click', (event) => {
const link = event.target.closest('a[data-requires-auth]');
if (!link) return;
event.preventDefault();
pendingUrl = link.getAttribute('href');
dialog.showModal();
});
В обработчике отправки замените строку с redirect:
body.append('redirect', pendingUrl || location.pathname);
Обработчик делегирован на document, поэтому работает и для ссылок, добавленных на страницу позже (подгрузка списка, бесконечная прокрутка).
Тот же функционал на Bootstrap 5.3
Если на сайте уже подключён Bootstrap, используйте его компонент Modal и классы валидации.
Показать решение Скрыть решение
<button type="button" class="btn btn-primary" data-bs-toggle="modal" data-bs-target="#loginModal">
Войти
</button>
<div class="modal fade" id="loginModal" tabindex="-1" aria-labelledby="loginModalLabel" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered">
<form class="modal-content" id="login-form" action="/login.php" method="post" novalidate>
<div class="modal-header">
<h2 class="modal-title fs-5" id="loginModalLabel">Вход</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger d-none" role="alert"></div>
<div class="mb-3">
<label class="form-label" for="login-email">Email</label>
<input class="form-control" id="login-email" type="email" name="email" required>
<div class="invalid-feedback">Введите корректный email</div>
</div>
<div class="mb-3">
<label class="form-label" for="login-password">Пароль</label>
<input class="form-control" id="login-password" type="password" name="password" required minlength="8">
<div class="invalid-feedback">Минимум 8 символов</div>
</div>
</div>
<div class="modal-footer">
<button type="submit" class="btn btn-primary w-100">
<span class="spinner-border spinner-border-sm d-none" aria-hidden="true"></span>
Войти
</button>
</div>
</form>
</div>
</div>
const form = document.getElementById('login-form');
const alertBox = form.querySelector('.alert');
const spinner = form.querySelector('.spinner-border');
const modalEl = document.getElementById('loginModal');
// Фокус на email после анимации открытия
modalEl.addEventListener('shown.bs.modal', () => form.email.focus());
form.addEventListener('submit', async (event) => {
event.preventDefault();
alertBox.classList.add('d-none');
form.classList.add('was-validated'); // Bootstrap подсветит невалидные поля
if (!form.checkValidity()) return;
spinner.classList.remove('d-none');
form.querySelector('[type=submit]').disabled = true;
try {
const body = new FormData(form);
body.append('_token', document.querySelector('meta[name="csrf-token"]').content);
const response = await fetch(form.action, {
method: 'POST',
headers: { Accept: 'application/json' },
body,
});
const data = await response.json().catch(() => ({}));
if (response.ok && data.ok) {
location.assign(data.redirect || '/');
return;
}
alertBox.textContent = data.message || 'Не удалось войти';
alertBox.classList.remove('d-none');
} catch {
alertBox.textContent = 'Нет соединения с сервером';
alertBox.classList.remove('d-none');
} finally {
spinner.classList.add('d-none');
form.querySelector('[type=submit]').disabled = false;
}
});
Для Laravel вместо _token в теле можно передать заголовок X-CSRF-TOKEN. Ошибки валидации Laravel возвращает со статусом 422 в виде { "message": "...", "errors": { "email": ["..."] } } — их удобно раскладывать по полям через invalid-feedback.
Итог
<dialog>+showModal()дают модальное окно с правильным фокусом и закрытием по Esc без библиотек.fetchне считает 4xx/5xx ошибкой — проверяйтеresponse.ok.- CSRF-токен берите из
<meta>и отправляйте с каждым POST. - Блокируйте кнопку на время запроса и всегда показывайте понятную ошибку.