Mercurial
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>