comparison 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
comparison
equal deleted inserted replaced
263:ee04e4e69fed 264:04fee26ecce0
1 <!DOCTYPE html>
2 <html lang="en" data-zen-theme="cyberpunk">
3 <head>
4 {{/parts/base_head.html}}
5 <title>Sign in — MrJuneJune</title>
6 <style>
7 .login-page main {
8 display: flex;
9 flex-direction: column;
10 align-items: center;
11 justify-content: center;
12 min-height: 60vh;
13 padding: var(--zenbu-sys-padding-lg) var(--zenbu-sys-padding-md);
14 }
15
16 .login-card {
17 width: 100%;
18 max-width: 360px;
19 }
20
21 .login-card h2 {
22 margin: 0 0 var(--zenbu-sys-padding-md);
23 font-size: 1.4rem;
24 font-weight: 700;
25 text-align: center;
26 letter-spacing: 0.08em;
27 }
28
29 .login-form {
30 display: flex;
31 flex-direction: column;
32 gap: var(--zenbu-sys-padding-sm);
33 }
34
35 .login-actions {
36 display: flex;
37 justify-content: flex-end;
38 gap: var(--zenbu-sys-space-control);
39 margin-top: var(--zenbu-sys-padding-xs, 0.5rem);
40 }
41
42 #loginError {
43 display: none;
44 padding: 0.6rem 0.75rem;
45 border-left: var(--zenbu-sys-stroke-width-emphasis, 2px) solid var(--zenbu-sys-color-danger-foreground);
46 background: color-mix(in srgb, var(--zenbu-sys-color-danger-foreground) 10%, transparent);
47 color: var(--zenbu-sys-color-danger-foreground);
48 font-size: 0.875rem;
49 }
50
51 #loginError[aria-hidden="false"] {
52 display: block;
53 }
54 </style>
55 </head>
56 <body class="login-page">
57 {{/parts/header.html}}
58
59 <main>
60 <div class="login-card">
61 <zen-heading size="xl">
62 <h2>Sign in</h2>
63 </zen-heading>
64
65 <div id="loginError" role="alert" aria-live="assertive" aria-hidden="true"></div>
66
67 <form class="login-form" id="loginForm" novalidate>
68 <zen-field appearance="plain" size="md">
69 <label for="username">Username</label>
70 <input
71 id="username"
72 name="username"
73 type="text"
74 autocomplete="username"
75 autocapitalize="none"
76 spellcheck="false"
77 required
78 maxlength="32"
79 >
80 </zen-field>
81
82 <zen-field appearance="plain" size="md">
83 <label for="password">Password</label>
84 <input
85 id="password"
86 name="password"
87 type="password"
88 autocomplete="current-password"
89 required
90 minlength="12"
91 maxlength="1024"
92 >
93 </zen-field>
94
95 <div class="login-actions">
96 <zen-button appearance="plain" size="md">
97 <button type="submit" id="loginSubmit">Sign in</button>
98 </zen-button>
99 </div>
100 </form>
101 </div>
102 </main>
103
104 <script>
105 (function () {
106 'use strict';
107
108 const form = document.getElementById('loginForm');
109 const errorEl = document.getElementById('loginError');
110 const submitBtn = document.getElementById('loginSubmit');
111
112 function showError(message) {
113 errorEl.textContent = message;
114 errorEl.setAttribute('aria-hidden', 'false');
115 }
116
117 function hideError() {
118 errorEl.textContent = '';
119 errorEl.setAttribute('aria-hidden', 'true');
120 }
121
122 async function fetchSession() {
123 const resp = await fetch('/api/auth/session', {
124 credentials: 'same-origin',
125 });
126 if (!resp.ok) throw new Error('session unavailable');
127 return resp.json();
128 }
129
130 form.addEventListener('submit', async function (evt) {
131 evt.preventDefault();
132 hideError();
133
134 const username = form.username.value.trim();
135 const password = form.password.value;
136
137 if (!username || !password) {
138 showError('Please enter your username and password.');
139 return;
140 }
141
142 submitBtn.disabled = true;
143
144 try {
145 const session = await fetchSession();
146 const csrfToken = session.csrfToken;
147
148 const resp = await fetch('/api/auth/login', {
149 method: 'POST',
150 headers: {
151 'Content-Type': 'application/json',
152 'Origin': window.location.origin,
153 },
154 credentials: 'same-origin',
155 body: JSON.stringify({ username, password, csrfToken }),
156 });
157
158 form.password.value = '';
159
160 let data = await resp.json().catch(function () { return null; });
161
162 if (!resp.ok) {
163 showError(resp.status === 429
164 ? 'Too many attempts. Please try again later.'
165 : 'Invalid username or password.');
166 return;
167 }
168
169 if (!data) {
170 data = await fetchSession();
171 if (data.kind !== 'user') {
172 window.location.reload();
173 return;
174 }
175 }
176
177 if (data.mustChangePassword) {
178 window.location.href = '/account/password';
179 return;
180 }
181
182 function safeNext(n) {
183 if (!n || typeof n !== 'string') return null;
184 if (!n.startsWith('/') || n.startsWith('//') || n.startsWith('/\\')) return null;
185 if (n.includes('\\')) return null;
186 try {
187 const url = new URL(n, window.location.origin);
188 if (url.origin !== window.location.origin) return null;
189 return url.pathname + url.search + url.hash;
190 } catch (_) {
191 return null;
192 }
193 }
194 const next = safeNext(new URLSearchParams(window.location.search).get('next'));
195 window.location.href = next || '/jrpg';
196 } catch (_) {
197 form.password.value = '';
198 showError('Sign-in failed. Please try again.');
199 } finally {
200 submitBtn.disabled = false;
201 }
202 });
203 })();
204 </script>
205 </body>
206 </html>