view mrjunejune/src/login/index.html @ 272:41a49c29a28f

polish JRPG conversation experience Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 16:05:29 -0700
parents 04fee26ecce0
children
line wrap: on
line source

<!DOCTYPE html>
<html lang="en" data-zen-theme="cyberpunk">
<head>
  {{/parts/base_head.html}}
  <title>Sign in — MrJuneJune</title>
  <style>
    .login-page main {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 60vh;
      padding: var(--zenbu-sys-padding-lg) var(--zenbu-sys-padding-md);
    }

    .login-card {
      width: 100%;
      max-width: 360px;
    }

    .login-card h2 {
      margin: 0 0 var(--zenbu-sys-padding-md);
      font-size: 1.4rem;
      font-weight: 700;
      text-align: center;
      letter-spacing: 0.08em;
    }

    .login-form {
      display: flex;
      flex-direction: column;
      gap: var(--zenbu-sys-padding-sm);
    }

    .login-actions {
      display: flex;
      justify-content: flex-end;
      gap: var(--zenbu-sys-space-control);
      margin-top: var(--zenbu-sys-padding-xs, 0.5rem);
    }

    #loginError {
      display: none;
      padding: 0.6rem 0.75rem;
      border-left: var(--zenbu-sys-stroke-width-emphasis, 2px) solid var(--zenbu-sys-color-danger-foreground);
      background: color-mix(in srgb, var(--zenbu-sys-color-danger-foreground) 10%, transparent);
      color: var(--zenbu-sys-color-danger-foreground);
      font-size: 0.875rem;
    }

    #loginError[aria-hidden="false"] {
      display: block;
    }
  </style>
</head>
<body class="login-page">
  {{/parts/header.html}}

  <main>
    <div class="login-card">
      <zen-heading size="xl">
        <h2>Sign in</h2>
      </zen-heading>

      <div id="loginError" role="alert" aria-live="assertive" aria-hidden="true"></div>

      <form class="login-form" id="loginForm" novalidate>
        <zen-field appearance="plain" size="md">
          <label for="username">Username</label>
          <input
            id="username"
            name="username"
            type="text"
            autocomplete="username"
            autocapitalize="none"
            spellcheck="false"
            required
            maxlength="32"
          >
        </zen-field>

        <zen-field appearance="plain" size="md">
          <label for="password">Password</label>
          <input
            id="password"
            name="password"
            type="password"
            autocomplete="current-password"
            required
            minlength="12"
            maxlength="1024"
          >
        </zen-field>

        <div class="login-actions">
          <zen-button appearance="plain" size="md">
            <button type="submit" id="loginSubmit">Sign in</button>
          </zen-button>
        </div>
      </form>
    </div>
  </main>

  <script>
    (function () {
      'use strict';

      const form     = document.getElementById('loginForm');
      const errorEl  = document.getElementById('loginError');
      const submitBtn = document.getElementById('loginSubmit');

      function showError(message) {
        errorEl.textContent = message;
        errorEl.setAttribute('aria-hidden', 'false');
      }

      function hideError() {
        errorEl.textContent = '';
        errorEl.setAttribute('aria-hidden', 'true');
      }

      async function fetchSession() {
        const resp = await fetch('/api/auth/session', {
          credentials: 'same-origin',
        });
        if (!resp.ok) throw new Error('session unavailable');
        return resp.json();
      }

      form.addEventListener('submit', async function (evt) {
        evt.preventDefault();
        hideError();

        const username = form.username.value.trim();
        const password = form.password.value;

        if (!username || !password) {
          showError('Please enter your username and password.');
          return;
        }

        submitBtn.disabled = true;

        try {
          const session = await fetchSession();
          const csrfToken = session.csrfToken;

          const resp = await fetch('/api/auth/login', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Origin': window.location.origin,
            },
            credentials: 'same-origin',
            body: JSON.stringify({ username, password, csrfToken }),
          });

          form.password.value = '';

          let data = await resp.json().catch(function () { return null; });

          if (!resp.ok) {
            showError(resp.status === 429
              ? 'Too many attempts. Please try again later.'
              : 'Invalid username or password.');
            return;
          }

          if (!data) {
            data = await fetchSession();
            if (data.kind !== 'user') {
              window.location.reload();
              return;
            }
          }

          if (data.mustChangePassword) {
            window.location.href = '/account/password';
            return;
          }

          function safeNext(n) {
            if (!n || typeof n !== 'string') return null;
            if (!n.startsWith('/') || n.startsWith('//') || n.startsWith('/\\')) return null;
            if (n.includes('\\')) return null;
            try {
              const url = new URL(n, window.location.origin);
              if (url.origin !== window.location.origin) return null;
              return url.pathname + url.search + url.hash;
            } catch (_) {
              return null;
            }
          }
          const next = safeNext(new URLSearchParams(window.location.search).get('next'));
          window.location.href = next || '/jrpg';
        } catch (_) {
          form.password.value = '';
          showError('Sign-in failed. Please try again.');
        } finally {
          submitBtn.disabled = false;
        }
      });
    })();
  </script>
</body>
</html>