ログイン画面(認証エラー・ロック・再設定導線)

応用例 中級

この画面パターンについて

ログインは1つのフォームで完結するように見えて、実際には「認証エラーの伝え方・失敗が続いたときの扱い・パスワードを忘れた人の逃げ道・ログイン後にどこへ着地させるか」までを1画面パターンとして設計する必要があります。 この事例では、ログイン画面・パスワード再設定画面・ログイン後ホームの3画面を1つの状態オブジェクトで切り替え、認証フロー全体をまとめて実装します。 個々の入力部品ではなく、失敗回数の管理と画面遷移のつなぎ方を見せることが目的です。

こんな場面で使えます

  • 社内向け業務システム — ID/パスワード認証と、総当たり対策のアカウントロックが要件になる
  • 会員向けWebサービス — パスワードを忘れた人の再設定導線を画面内に用意する
  • 管理画面・バックオフィス — ログイン後に管理ホームへ着地させるまでを1画面パターンとして持つ

この画面で使っているUIコンポーネント

#パーツこの画面での役割
1テキストボックスメールアドレスの入力と形式チェック
2パスワード入力(表示切替)目アイコンで入力値の表示/非表示を切替
3チェックボックス「ログイン状態を保持する」の選択
4インラインバリデーション未入力・形式エラーをフィールド直下に表示
5エラーバナー認証失敗と残り試行回数をフォーム上部に通知
6送信ボタン(ローディング)認証中のスピナー表示と二重送信の防止
7カウントダウン表示ロック解除までの残り秒数を毎秒更新
8トースト通知再設定リンク送信の完了を通知

実装のポイント・注意点

3つの画面は別ファイルに分けず、state.view の値で切り替えて render() が毎回描き直します。 要はログイン処理の副作用ではなく、状態が変わった結果として画面が決まる形にします。 失敗回数を扱うときの肝は、加算するのは認証エラーのときだけという線引きです。メール未入力のような形式エラーで加算すると、打ち間違えただけの利用者がロックされてしまいます。

ロック状態は「残り回数」ではなく解除時刻(lockedUntilで保持するのが定石で、こうしておくと再描画や他画面への往復を挟んでも残り秒数が常に正しく計算できます。 パスワード再設定は、登録の有無にかかわらず同じ完了画面を返します。応答を出し分けるとメールアドレスの存在確認に使われてしまうためです。

8個のUIコンポーネントをHTML・CSS・バニラJavaScriptのみで組み合わせており、 フレームワーク不要で画面ごとコピペして使えます。

動作サンプル

ログイン画面(認証エラー・ロック・再設定導線)のデモ画面 動作サンプルを別ウィンドウで確認 ↗

試してみる:

  • でたらめなパスワードで3回続けて送信し、残り回数の警告からアカウントロックまでの流れを確認
  • ロック中の「解除まであと◯秒」が0になり、フォームが自動で操作可能に戻るところを確認
  • メールを空のまま送信し、形式エラーでは失敗回数が増えない(ロックに近づかない)ことを確認

そのほかの操作も自由に試してみてください。

サンプルソース

3つのファイルを同じフォルダに保存し、ブラウザで index.html を開くと動作確認できます。
ファイル名:index.html / style.css / script.js
保存時の文字コードは UTF-8 を指定してください。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>ログイン画面 サンプル</title>
  <link rel="stylesheet" href="./style.css">
</head>
<body>

<div class="login-screen" id="app">

  <!-- ===== 画面1:ログイン ===== -->
  <section class="auth-card" id="loginView">
    <h1 class="auth-title">Sample App</h1>
    <p class="auth-lead">サインインしてください</p>

    <!-- デモ用アカウント案内(クリックで自動入力) -->
    <button type="button" class="demo-account" id="demoAccountBtn">
      <span class="demo-account-label">デモ用アカウント(クリックで入力)</span>
      <span class="demo-account-value">[email protected] / Passw0rd!</span>
    </button>

    <!-- 認証エラー/ロックのバナー -->
    <div class="auth-banner" id="authBanner" role="alert" hidden></div>

    <form class="auth-form" id="loginForm" novalidate>
      <div class="field">
        <label class="field-label" for="loginEmail">メールアドレス</label>
        <input type="email" id="loginEmail" autocomplete="username">
        <p class="field-error" data-error-for="loginEmail" hidden></p>
      </div>

      <div class="field">
        <label class="field-label" for="loginPassword">パスワード</label>
        <div class="password-wrap">
          <input type="password" id="loginPassword" autocomplete="current-password">
          <button type="button" class="password-toggle" id="passwordToggle"
                  aria-label="パスワードを表示" aria-pressed="false"></button>
        </div>
        <p class="field-error" data-error-for="loginPassword" hidden></p>
      </div>

      <label class="remember">
        <input type="checkbox" id="rememberCheck">
        <span>ログイン状態を保持する</span>
      </label>

      <button type="submit" class="btn-primary" id="loginSubmit">
        <span class="btn-spinner" hidden></span>
        <span class="btn-text">ログイン</span>
      </button>
    </form>

    <p class="auth-links">
      <a href="#" id="toResetLink">パスワードをお忘れですか?</a>
    </p>
  </section>

  <!-- ===== 画面2:パスワード再設定 ===== -->
  <section class="auth-card" id="resetView" hidden>
    <p class="auth-back"><a href="#" id="toLoginLink">← ログインに戻る</a></p>

    <!-- 入力パネル -->
    <div id="resetFormPanel">
      <h1 class="auth-title">パスワードの再設定</h1>
      <p class="auth-lead">ご登録のメールアドレスを入力してください。再設定用のリンクをお送りします。</p>

      <form class="auth-form" id="resetForm" novalidate>
        <div class="field">
          <label class="field-label" for="resetEmail">メールアドレス</label>
          <input type="email" id="resetEmail" autocomplete="username">
          <p class="field-error" data-error-for="resetEmail" hidden></p>
        </div>
        <button type="submit" class="btn-primary" id="resetSubmit">
          <span class="btn-spinner" hidden></span>
          <span class="btn-text">再設定リンクを送信</span>
        </button>
      </form>
    </div>

    <!-- 完了パネル -->
    <div class="reset-done" id="resetDonePanel" hidden>
      <p class="reset-done-icon" aria-hidden="true">✉</p>
      <h1 class="auth-title">送信しました</h1>
      <p class="auth-lead"><span id="resetSentTo"></span> 宛に再設定リンクを送信しました。メールをご確認ください。</p>
      <button type="button" class="btn-secondary" id="backToLoginBtn">ログイン画面に戻る</button>
    </div>
  </section>

  <!-- ===== 画面3:ログイン後ホーム ===== -->
  <section class="home-view" id="homeView" hidden>
    <header class="home-header">
      <span class="home-brand">Sample App</span>
      <span class="home-user"><span id="homeUserName"></span> さん</span>
      <button type="button" class="btn-text-only" id="logoutBtn">ログアウト</button>
    </header>
    <div class="home-body">
      <p class="home-check" aria-hidden="true">✓</p>
      <h1 class="home-title">ログインしました</h1>
      <dl class="home-info">
        <dt>ユーザー</dt><dd id="homeUser"></dd>
        <dt>ログイン日時</dt><dd id="homeLoginAt"></dd>
        <dt>ログイン状態の保持</dt><dd id="homeRemember"></dd>
      </dl>
    </div>
  </section>

  <!-- ===== トースト ===== -->
  <div class="toast" id="toast" role="status" hidden></div>
</div>

<script src="./script.js"></script>
</body>
</html>
/* =====================================================
   ログイン画面デモ — スタンドアロンスタイル
   ===================================================== */

:root {
  --login-primary: #2B7FE8;
  --login-primary-dark: #1E63BF;
  --login-danger: #D64545;
  --login-danger-bg: #FDECEC;
  --login-locked-bg: #3A3F4B;
  --login-success: #1E9E5A;
  --login-border: #D0D7E0;
  --login-text: #1F2937;
  --login-text-sub: #5A6A7A;
  --login-card-bg: #FFFFFF;
  --login-bg: #F4F6F9;
}

* { box-sizing: border-box; }

body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Hiragino Sans, Meiryo, sans-serif;
  color: var(--login-text);
  background: var(--login-bg);
}

/* ===== 全体レイアウト ===== */
.login-screen {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px 16px;
}

.login-screen:has(#homeView:not([hidden])) {
  align-items: flex-start;
}

/* ===== 認証カード(ログイン・再設定 共通) ===== */
.auth-card {
  width: 100%;
  max-width: 400px;
  background: var(--login-card-bg);
  border-radius: 12px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  padding: 32px 28px;
}

@media (max-width: 480px) {
  .auth-card {
    max-width: none;
    padding: 24px 16px;
  }
}

.auth-title {
  margin: 0 0 4px;
  font-size: 20px;
  font-weight: 700;
  text-align: center;
}

.auth-lead {
  margin: 0 0 20px;
  font-size: 13px;
  color: var(--login-text-sub);
  text-align: center;
  line-height: 1.6;
}

.auth-back {
  margin: 0 0 12px;
}

.auth-back a,
.auth-links a {
  color: var(--login-primary);
  font-size: 13px;
  text-decoration: none;
}

.auth-back a:hover,
.auth-links a:hover {
  text-decoration: underline;
}

.auth-links {
  margin: 16px 0 0;
  text-align: center;
}

/* ===== デモ用アカウント案内 ===== */
.demo-account {
  display: block;
  width: 100%;
  margin: 0 0 16px;
  padding: 10px 12px;
  background: #F4F8FF;
  border: 1.5px dashed var(--login-primary);
  border-radius: 8px;
  cursor: pointer;
  text-align: left;
  font-family: inherit;
  transition: background 0.15s;
}

.demo-account:hover {
  background: #E8F1FE;
}

.demo-account-label {
  display: block;
  font-size: 11px;
  color: var(--login-primary-dark);
  font-weight: 700;
  margin-bottom: 2px;
}

.demo-account-value {
  display: block;
  font-size: 13px;
  color: var(--login-text);
  font-family: "SFMono-Regular", Consolas, monospace;
}

/* ===== 認証エラー/ロックバナー ===== */
.auth-banner {
  margin: 0 0 16px;
  padding: 10px 14px;
  border-radius: 8px;
  font-size: 13px;
  line-height: 1.6;
}

.auth-banner[hidden] { display: none; }

.auth-banner.is-error {
  background: var(--login-danger-bg);
  color: var(--login-danger);
}

.auth-banner.is-locked {
  background: var(--login-locked-bg);
  color: #fff;
}

/* ===== フォーム共通 ===== */
.auth-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.field { display: flex; flex-direction: column; gap: 4px; }

.field-label {
  font-size: 13px;
  font-weight: 600;
  color: var(--login-text-sub);
}

.field input[type="email"],
.field input[type="password"],
.field input[type="text"] {
  width: 100%;
  padding: 10px 12px;
  font-size: 14px;
  border: 1.5px solid var(--login-border);
  border-radius: 6px;
  font-family: inherit;
}

.field input:focus {
  outline: none;
  border-color: var(--login-primary);
}

.field input.is-invalid {
  border-color: var(--login-danger);
}

.field input:disabled {
  background: #F4F6F9;
  color: #9AA5B4;
  cursor: not-allowed;
}

.field-error {
  margin: 0;
  font-size: 12px;
  color: var(--login-danger);
}

.field-error[hidden] { display: none; }

/* ===== パスワード表示切替 ===== */
.password-wrap {
  position: relative;
}

.password-wrap input {
  padding-right: 44px;
}

.password-toggle {
  position: absolute;
  top: 50%;
  right: 8px;
  transform: translateY(-50%);
  width: 32px;
  height: 32px;
  border: none;
  background: transparent;
  cursor: pointer;
  border-radius: 6px;
  color: var(--login-text-sub);
}

.password-toggle::before {
  content: "👁";
  font-size: 16px;
}

.password-toggle[aria-pressed="true"]::before {
  content: "🙈";
}

.password-toggle:hover {
  background: #F4F6F9;
}

.password-toggle:disabled {
  cursor: not-allowed;
  opacity: 0.5;
}

/* ===== ログイン状態を保持 ===== */
.remember {
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 13px;
  color: var(--login-text-sub);
  cursor: pointer;
}

.remember input {
  width: 16px;
  height: 16px;
  cursor: pointer;
}

/* ===== ボタン共通 ===== */
.btn-primary,
.btn-secondary {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  width: 100%;
  padding: 11px 16px;
  font-size: 14px;
  font-weight: 700;
  border-radius: 8px;
  cursor: pointer;
  font-family: inherit;
  transition: background 0.15s, border-color 0.15s;
}

.btn-primary {
  color: #fff;
  background: var(--login-primary);
  border: 1.5px solid var(--login-primary);
}

.btn-primary:hover:not(:disabled) {
  background: var(--login-primary-dark);
}

.btn-primary:disabled {
  opacity: 0.6;
  cursor: not-allowed;
}

.btn-secondary {
  color: var(--login-primary);
  background: #fff;
  border: 1.5px solid var(--login-primary);
}

.btn-secondary:hover {
  background: #F4F8FF;
}

.btn-text-only {
  border: none;
  background: transparent;
  color: var(--login-primary);
  font-size: 13px;
  cursor: pointer;
  padding: 6px 8px;
}

.btn-text-only:hover {
  text-decoration: underline;
}

/* ===== ボタン内スピナー ===== */
.btn-spinner {
  width: 16px;
  height: 16px;
  border: 2px solid rgba(255, 255, 255, 0.4);
  border-top-color: #fff;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
}

.btn-spinner[hidden] { display: none; }

@keyframes spin {
  to { transform: rotate(360deg); }
}

/* ===== パスワード再設定:完了パネル ===== */
.reset-done {
  text-align: center;
}

.reset-done[hidden] { display: none; }

.reset-done-icon {
  font-size: 40px;
  margin: 0 0 8px;
}

/* ===== ログイン後ホーム ===== */
.home-view {
  width: 100%;
  max-width: 640px;
  background: var(--login-card-bg);
  border-radius: 12px;
  box-shadow: 0 4px 16px rgba(0, 0, 0, 0.08);
  overflow: hidden;
}

.home-view[hidden] { display: none; }

.home-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 20px;
  background: #F4F6F9;
  border-bottom: 1px solid var(--login-border);
}

.home-brand {
  font-weight: 700;
  font-size: 14px;
}

.home-user {
  flex: 1;
  margin-left: 16px;
  font-size: 13px;
  color: var(--login-text-sub);
}

.home-body {
  padding: 40px 24px;
  text-align: center;
}

.home-check {
  font-size: 36px;
  color: var(--login-success);
  margin: 0 0 8px;
}

.home-title {
  margin: 0 0 20px;
  font-size: 18px;
}

.home-info {
  display: inline-grid;
  grid-template-columns: auto auto;
  gap: 6px 16px;
  text-align: left;
  margin: 0;
  font-size: 13px;
}

.home-info dt {
  color: var(--login-text-sub);
}

.home-info dd {
  margin: 0;
  font-weight: 600;
}

/* ===== トースト ===== */
.toast {
  position: fixed;
  bottom: 24px;
  right: 24px;
  padding: 12px 18px;
  background: #1F2937;
  color: #fff;
  border-radius: 8px;
  font-size: 13px;
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.2s, transform 0.2s;
}

.toast[hidden] { display: none; }

.toast.is-visible {
  opacity: 1;
  transform: translateY(0);
}

@media (max-width: 480px) {
  .toast {
    left: 16px;
    right: 16px;
    bottom: 16px;
  }
}
/* =====================================================
   ログイン画面デモのスクリプト

   仕組み:state.view('login' | 'reset' | 'home')で画面を切り替える。
   すべての操作は state を書き換えて render() を呼ぶだけ。
   render() が3つの <section> の hidden を付け外しし、
   バナー・ボタンの disabled 状態もまとめて反映する。

   失敗回数は「認証エラー」のときだけ加算する(形式エラーでは増やさない)。
   ロックは「解除時刻(lockedUntil)」で保持し、残り秒数は
   毎回 lockedUntil - Date.now() から計算する(デクリメントしない)。
   ===================================================== */

(function () {
  'use strict';

  // ===== 設定値 =====
  var DEMO_EMAIL        = '[email protected]'; // デモ用アカウント(実際の認証は行わない)
  var DEMO_PASSWORD     = 'Passw0rd!';
  var MAX_FAIL_COUNT    = 3;      // この回数連続で失敗するとロック
  var LOCK_DURATION_MS  = 20000;  // ロック時間(実務では5〜15分が目安。デモのため20秒)
  var AUTH_DELAY_MS     = 800;    // 認証処理の擬似待ち時間
  var TOAST_DURATION_MS = 3000;   // トーストの表示時間
  var TICK_MS           = 1000;   // ロック残り秒数の更新間隔

  // ===== state =====
  var state = {
    view: 'login',      // 'login' | 'reset' | 'home'
    failCount: 0,        // 認証失敗の連続回数(形式エラーでは増やさない)
    lockedUntil: 0,       // ロック解除時刻(ms・0ならロックなし)
    user: null            // { name, loginAt, remember }
  };

  var lockTimer = null;
  var toastTimer = null;

  // ===== DOM参照 =====
  var loginView = document.getElementById('loginView');
  var resetView = document.getElementById('resetView');
  var homeView = document.getElementById('homeView');

  var demoAccountBtn = document.getElementById('demoAccountBtn');
  var authBanner = document.getElementById('authBanner');

  var loginForm = document.getElementById('loginForm');
  var loginEmail = document.getElementById('loginEmail');
  var loginPassword = document.getElementById('loginPassword');
  var passwordToggle = document.getElementById('passwordToggle');
  var rememberCheck = document.getElementById('rememberCheck');
  var loginSubmit = document.getElementById('loginSubmit');
  var toResetLink = document.getElementById('toResetLink');

  var resetFormPanel = document.getElementById('resetFormPanel');
  var resetDonePanel = document.getElementById('resetDonePanel');
  var resetForm = document.getElementById('resetForm');
  var resetEmail = document.getElementById('resetEmail');
  var resetSubmit = document.getElementById('resetSubmit');
  var resetSentTo = document.getElementById('resetSentTo');
  var toLoginLink = document.getElementById('toLoginLink');
  var backToLoginBtn = document.getElementById('backToLoginBtn');

  var homeUserName = document.getElementById('homeUserName');
  var homeUser = document.getElementById('homeUser');
  var homeLoginAt = document.getElementById('homeLoginAt');
  var homeRemember = document.getElementById('homeRemember');
  var logoutBtn = document.getElementById('logoutBtn');

  var toast = document.getElementById('toast');

  // ===== 画面切替(render) =====
  function render() {
    loginView.hidden = state.view !== 'login';
    resetView.hidden = state.view !== 'reset';
    homeView.hidden = state.view !== 'home';
  }

  // ===== バリデーション(ログイン・再設定で共通) =====
  function validateEmail(el) {
    var value = el.value.trim();
    if (!value) return 'メールアドレスを入力してください';
    if (value.indexOf('@') === -1) return 'メールアドレスの形式が正しくありません';
    return '';
  }

  function showFieldError(el, message) {
    var errorEl = document.querySelector('[data-error-for="' + el.id + '"]');
    if (message) {
      el.classList.add('is-invalid');
      errorEl.textContent = message;
      errorEl.hidden = false;
    } else {
      el.classList.remove('is-invalid');
      errorEl.textContent = '';
      errorEl.hidden = true;
    }
  }

  function clearFieldErrors(fields) {
    fields.forEach(function (el) { showFieldError(el, ''); });
  }

  // ===== 認証エラー/ロックバナー =====
  function hideBanner() {
    authBanner.hidden = true;
    authBanner.className = 'auth-banner';
    authBanner.textContent = '';
  }

  function showErrorBanner(message) {
    authBanner.hidden = false;
    authBanner.className = 'auth-banner is-error';
    authBanner.textContent = message;
  }

  function showLockedBanner(remainingSec) {
    authBanner.hidden = false;
    authBanner.className = 'auth-banner is-locked';
    authBanner.textContent = '試行回数の上限に達しました/ロック解除まであと ' + remainingSec + '秒';
  }

  // ===== フォームのdisabled切り替え(ロック中) =====
  function setLoginFormDisabled(disabled) {
    loginEmail.disabled = disabled;
    loginPassword.disabled = disabled;
    passwordToggle.disabled = disabled;
    rememberCheck.disabled = disabled;
    loginSubmit.disabled = disabled;
  }

  // ===== アカウントロック =====
  function startLock() {
    state.lockedUntil = Date.now() + LOCK_DURATION_MS;
    setLoginFormDisabled(true);
    tickLock();
    lockTimer = setInterval(tickLock, TICK_MS);
  }

  // ロック残り秒数は毎回「解除時刻 - 現在時刻」から計算する(変数をデクリメントしない)
  function tickLock() {
    var remainingMs = state.lockedUntil - Date.now();
    if (remainingMs <= 0) {
      clearInterval(lockTimer);
      lockTimer = null;
      state.lockedUntil = 0;
      state.failCount = 0;
      setLoginFormDisabled(false);
      hideBanner();
      loginPassword.focus();
      return;
    }
    showLockedBanner(Math.ceil(remainingMs / 1000));
  }

  // ===== デモ用アカウントの自動入力 =====
  demoAccountBtn.addEventListener('click', function () {
    loginEmail.value = DEMO_EMAIL;
    loginPassword.value = DEMO_PASSWORD;
    clearFieldErrors([loginEmail, loginPassword]);
    hideBanner();
  });

  // ===== パスワード表示切替 =====
  passwordToggle.addEventListener('click', function () {
    var showing = loginPassword.type === 'text';
    loginPassword.type = showing ? 'password' : 'text';
    passwordToggle.setAttribute('aria-pressed', String(!showing));
    passwordToggle.setAttribute('aria-label', showing ? 'パスワードを表示' : 'パスワードを非表示');
  });

  // ===== ログイン送信 =====
  loginForm.addEventListener('submit', function (e) {
    e.preventDefault();

    var emailError = validateEmail(loginEmail);
    var passwordError = loginPassword.value ? '' : 'パスワードを入力してください';

    showFieldError(loginEmail, emailError);
    showFieldError(loginPassword, passwordError);

    // 形式エラーはここで return。失敗回数は増やさない
    if (emailError || passwordError) {
      (emailError ? loginEmail : loginPassword).focus();
      return;
    }

    hideBanner();
    loginSubmit.disabled = true;
    loginSubmit.querySelector('.btn-spinner').hidden = false;
    loginSubmit.querySelector('.btn-text').textContent = 'ログイン中...';

    setTimeout(function () {
      var matched = loginEmail.value.trim() === DEMO_EMAIL && loginPassword.value === DEMO_PASSWORD;

      loginSubmit.disabled = false;
      loginSubmit.querySelector('.btn-spinner').hidden = true;
      loginSubmit.querySelector('.btn-text').textContent = 'ログイン';

      if (matched) {
        state.failCount = 0;
        state.user = {
          name: loginEmail.value.split('@')[0],
          loginAt: new Date(),
          remember: rememberCheck.checked
        };
        showHome();
        return;
      }

      // 認証エラーのときだけ失敗回数を加算する
      state.failCount += 1;
      loginPassword.value = '';

      if (state.failCount >= MAX_FAIL_COUNT) {
        startLock();
      } else {
        var remaining = MAX_FAIL_COUNT - state.failCount;
        showErrorBanner('メールアドレスまたはパスワードが違います(あと' + remaining + '回)');
        loginPassword.focus();
      }
    }, AUTH_DELAY_MS);
  });

  // ===== パスワード再設定への画面遷移 =====
  toResetLink.addEventListener('click', function (e) {
    e.preventDefault();
    resetFormPanel.hidden = false;
    resetDonePanel.hidden = true;
    resetEmail.value = loginEmail.value;
    clearFieldErrors([resetEmail]);
    state.view = 'reset';
    render();
  });

  toLoginLink.addEventListener('click', function (e) {
    e.preventDefault();
    state.view = 'login';
    render();
  });

  backToLoginBtn.addEventListener('click', function () {
    resetForm.reset();
    state.view = 'login';
    render();
  });

  // ===== パスワード再設定の送信 =====
  resetForm.addEventListener('submit', function (e) {
    e.preventDefault();

    var emailError = validateEmail(resetEmail);
    showFieldError(resetEmail, emailError);
    if (emailError) {
      resetEmail.focus();
      return;
    }

    resetSubmit.disabled = true;
    resetSubmit.querySelector('.btn-spinner').hidden = false;
    resetSubmit.querySelector('.btn-text').textContent = '送信中...';

    setTimeout(function () {
      resetSubmit.disabled = false;
      resetSubmit.querySelector('.btn-spinner').hidden = true;
      resetSubmit.querySelector('.btn-text').textContent = '再設定リンクを送信';

      // 登録の有無にかかわらず同じ完了画面を返す(アカウント列挙を防ぐ)
      resetSentTo.textContent = resetEmail.value.trim();
      resetFormPanel.hidden = true;
      resetDonePanel.hidden = false;
      showToast('再設定リンクを送信しました');
    }, AUTH_DELAY_MS);
  });

  // ===== ログイン後ホーム =====
  function showHome() {
    homeUserName.textContent = state.user.name;
    homeUser.textContent = state.user.name;
    homeLoginAt.textContent = state.user.loginAt.toLocaleString('ja-JP');
    homeRemember.textContent = state.user.remember ? 'ON' : 'OFF';
    state.view = 'home';
    render();
  }

  logoutBtn.addEventListener('click', function () {
    state.view = 'login';
    state.failCount = 0;
    state.lockedUntil = 0;
    state.user = null;
    if (lockTimer) {
      clearInterval(lockTimer);
      lockTimer = null;
    }
    loginForm.reset();
    resetForm.reset();
    setLoginFormDisabled(false);
    clearFieldErrors([loginEmail, loginPassword, resetEmail]);
    hideBanner();
    render();
  });

  // ===== トースト =====
  function showToast(message) {
    if (toastTimer) clearTimeout(toastTimer);
    toast.textContent = message;
    toast.hidden = false;
    // 描画を1フレーム挟んでからクラスを付けるとフェードインが効く
    requestAnimationFrame(function () {
      toast.classList.add('is-visible');
    });
    toastTimer = setTimeout(function () {
      toast.classList.remove('is-visible');
      setTimeout(function () { toast.hidden = true; }, 200);
    }, TOAST_DURATION_MS);
  }

  render();
})();

AI用プロンプト

以下のプロンプトをコピーしてAIに渡すと、同様の画面パターンを生成できます。

ChatGPTやClaudeにこのプロンプトを渡すと、同様の画面をゼロから生成・カスタマイズできます。文言変更やロック時間の調整など、要件を追記して使うのがおすすめです。

※ このプロンプトを使ってもデモとまったく同じ動作にならない場合があります。AIの解釈や生成タイミングによって差が出ることをご了承ください。

💡 jQuery・Vue・React など特定のライブラリで実装したい場合は、プロンプトの末尾に「〇〇を使って実装してください」と追記してください。

# ログイン画面(認証エラー・アカウントロック・パスワード再設定)作成依頼

## 概要
ログイン画面・パスワード再設定画面・ログイン後ホームの3画面を1つのHTMLファイル内で
切り替える、認証フロー一式の画面パターンを作成してください。

## 要件
- ログイン画面:メールアドレス/パスワード(表示切替ボタン付き)/
  「ログイン状態を保持する」チェックボックス/ログインボタン/
  「パスワードをお忘れですか?」リンク
- デモ用アカウント([email protected] / Passw0rd!)を画面上に案内し、
  クリックすると各入力欄に自動入力する
- 未入力・メール形式エラーはフィールド直下にインライン表示し、認証は実行しない
- 認証に失敗したらフォーム上部にエラーバナーを出し、残り試行回数を表示する
- 3回連続で失敗したらアカウントロック状態にし、入力欄とボタンをすべて無効化して
  「ロック解除まであと◯秒」を毎秒更新する。20秒経過で自動的に解除し、失敗回数を0に戻す
- パスワード再設定画面:メールアドレスを送信すると
  「◯◯宛に再設定リンクを送信しました」の完了パネルに切り替え、同時にトーストを表示する
- ログイン後ホーム:ヘッダー(サービス名・ユーザー名・ログアウト)と、
  ユーザー名・ログイン日時・ログイン状態の保持設定を表示する

## 技術仕様
- HTML / CSS / バニラJavaScript で実装
- 外部ライブラリ:なし
- 実際の認証通信は行わない(UIデモとして完結させる)
- レスポンシブ対応:必要

## 動作詳細
- 画面切替は state.view('login' | 'reset' | 'home')で管理し、render() が毎回描き直す
- 失敗回数は認証エラーのときだけ加算する(入力形式エラーでは加算しない)
- ロック状態は残り回数ではなく解除時刻(lockedUntil)で保持し、
  残り秒数は「解除時刻 - 現在時刻」から毎回計算する
- パスワード再設定は、メールアドレスの登録有無にかかわらず同じ完了画面を表示する
  (アカウントの存在を推測されないようにするため)
- エラーメッセージは textContent で設定し、innerHTML に変数を結合しない

## 出力形式
HTML・CSS・JavaScriptを分けて出力してください。
各ファイルは単独でコピー&ペーストして使えるよう記述してください。