diff mrjunejune/src/login/index.html @ 264:04fee26ecce0

add authenticated JRPG conversation platform Add reusable auth/session storage, owned conversation recovery, guest quotas, admin workflows, URL-routed conversation UI, mobile frame support, and parallel browser acceptance. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 07:34:12 -0700
parents
children
line wrap: on
line diff
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/mrjunejune/src/login/index.html	Fri Aug 07 07:34:12 2026 -0700
@@ -0,0 +1,206 @@
+<!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>