К содержимому

Окно авторизации на JavaScript: модалка, fetch и обработка ошибок

Окно входа поверх страницы удобнее отдельной страницы логина: пользователь не теряет место, где читал. В статье соберём такое окно на чистом JavaScript, без библиотек, а в конце покажем вариант на Bootstrap 5.3.

Серверная часть — обработчик login.php из статьи «Авторизация на чистом PHP». Он принимает POST с полями email, password, _token и при заголовке Accept: application/json отвечает так:

Успех (200)
{ "ok": true, "redirect": "/profile.php" }
Ошибка (422 или 429)
{ "ok": false, "message": "Неверный email или пароль" }
Задача 1 Средняя

Сверстать модальное окно

Сделайте окно с полями 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();
});
Задача 2 Средняя

Проверить поля до отправки

Не отправляйте запрос, если 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;
}

Клиентская проверка — только для удобства. Сервер всё равно проверяет всё заново: запрос можно отправить в обход формы.

Задача 3 Средняя

Отправить форму через 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 разблокирует кнопку в любом случае.
Задача 4 Средняя

Открывать окно при попытке открыть закрытый контент

На сайте есть ссылки на приватные статьи. Гость при клике должен видеть окно входа, а после входа попадать на нужную статью.

Показать решение Скрыть решение

Помечаем такие ссылки атрибутом 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, поэтому работает и для ссылок, добавленных на страницу позже (подгрузка списка, бесконечная прокрутка).

Задача 5 Средняя

Тот же функционал на Bootstrap 5.3

Если на сайте уже подключён Bootstrap, используйте его компонент Modal и классы валидации.

Показать решение Скрыть решение
Bootstrap-модалка
<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>
Логика для Bootstrap
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.
  • Блокируйте кнопку на время запроса и всегда показывайте понятную ошибку.