comparison mrjunejune/src/admin/users/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">
3 <head>
4 {{/parts/base_head.html}}
5 <title>Admin — Users — MrJuneJune</title>
6 <style>
7 main {
8 padding: var(--zenbu-sys-padding-lg, 2rem) var(--zenbu-sys-padding-md, 1rem);
9 max-width: 960px;
10 margin: 0 auto;
11 }
12
13 .admin-header {
14 display: flex;
15 align-items: baseline;
16 justify-content: space-between;
17 gap: var(--zenbu-sys-padding-md, 1rem);
18 flex-wrap: wrap;
19 margin-bottom: var(--zenbu-sys-padding-lg, 2rem);
20 }
21
22 #statusMessage {
23 display: none;
24 padding: 0.6rem 0.75rem;
25 border-radius: 6px;
26 font-size: 0.875rem;
27 margin-bottom: var(--zenbu-sys-padding-md, 1rem);
28 }
29
30 #statusMessage[data-kind="error"] {
31 background: color-mix(in srgb, var(--zenbu-sys-color-error, #c0392b) 12%, transparent);
32 color: var(--zenbu-sys-color-error, #c0392b);
33 }
34
35 #statusMessage[data-kind="success"] {
36 background: color-mix(in srgb, var(--zenbu-sys-color-success, #27ae60) 12%, transparent);
37 color: var(--zenbu-sys-color-success, #27ae60);
38 }
39
40 #statusMessage[aria-hidden="false"] {
41 display: block;
42 }
43
44 table {
45 width: 100%;
46 border-collapse: collapse;
47 font-size: 0.9rem;
48 }
49
50 th, td {
51 text-align: left;
52 padding: 0.5rem 0.75rem;
53 border-bottom: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5);
54 }
55
56 th {
57 font-weight: 600;
58 color: var(--zenbu-sys-color-on-surface-variant, #5c5a53);
59 }
60
61 .row-actions {
62 display: flex;
63 gap: 0.5rem;
64 flex-wrap: wrap;
65 }
66
67 details summary {
68 cursor: pointer;
69 list-style: none;
70 }
71
72 details summary::-webkit-details-marker {
73 display: none;
74 }
75
76 .create-form {
77 margin-top: var(--zenbu-sys-padding-lg, 2rem);
78 padding-top: var(--zenbu-sys-padding-md, 1rem);
79 border-top: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5);
80 }
81
82 .create-form-fields {
83 display: flex;
84 flex-direction: column;
85 gap: var(--zenbu-sys-padding-sm, 0.75rem);
86 max-width: 400px;
87 margin-top: var(--zenbu-sys-padding-sm, 0.75rem);
88 }
89
90 .create-form-actions {
91 display: flex;
92 gap: 0.5rem;
93 margin-top: var(--zenbu-sys-padding-sm, 0.75rem);
94 }
95
96 dialog {
97 border: none;
98 border-radius: 8px;
99 padding: var(--zenbu-sys-padding-lg, 2rem);
100 max-width: 400px;
101 width: 100%;
102 box-shadow: 0 4px 24px color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 12%, transparent);
103 }
104
105 dialog::backdrop {
106 background: color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 40%, transparent);
107 }
108
109 .dialog-title {
110 margin: 0 0 var(--zenbu-sys-padding-md, 1rem);
111 font-size: 1.1rem;
112 font-weight: 600;
113 }
114
115 .dialog-fields {
116 display: flex;
117 flex-direction: column;
118 gap: var(--zenbu-sys-padding-sm, 0.75rem);
119 }
120
121 .dialog-actions {
122 display: flex;
123 gap: 0.5rem;
124 justify-content: flex-end;
125 margin-top: var(--zenbu-sys-padding-md, 1rem);
126 }
127 </style>
128 </head>
129 <body>
130 {{/parts/header.html}}
131
132 <main>
133 <div class="admin-header">
134 <zen-heading size="xl">
135 <h1>Users</h1>
136 </zen-heading>
137 <zen-button appearance="plain" size="sm">
138 <a href="/jrpg">← Back to Shiba Quest</a>
139 </zen-button>
140 </div>
141
142 <div id="statusMessage" role="status" aria-live="polite" aria-hidden="true"></div>
143
144 <div id="userTableContainer">
145 <zen-text size="sm"><p aria-live="polite" id="loadingText">Loading…</p></zen-text>
146 </div>
147
148 <!-- Create user section -->
149 <section class="create-form" aria-labelledby="createHeading">
150 <zen-heading size="lg">
151 <h2 id="createHeading">Create user</h2>
152 </zen-heading>
153 <form id="createForm" class="create-form-fields" novalidate>
154 <zen-field size="md">
155 <label for="newUsername">Username</label>
156 <input
157 id="newUsername"
158 name="username"
159 type="text"
160 autocomplete="off"
161 minlength="3"
162 maxlength="32"
163 required
164 >
165 </zen-field>
166 <zen-field size="md">
167 <label for="newPassword">Temporary password</label>
168 <input
169 id="newPassword"
170 name="temporaryPassword"
171 type="password"
172 autocomplete="new-password"
173 minlength="12"
174 maxlength="1024"
175 required
176 >
177 </zen-field>
178 <zen-field size="md">
179 <label for="newRole">Role</label>
180 <select id="newRole" name="role">
181 <option value="member">member</option>
182 <option value="admin">admin</option>
183 </select>
184 </zen-field>
185 <div class="create-form-actions">
186 <zen-button size="md">
187 <button type="submit" id="createSubmit">Create user</button>
188 </zen-button>
189 </div>
190 </form>
191 </section>
192 </main>
193
194 <!-- Temp reset password dialog -->
195 <dialog id="resetDialog" aria-labelledby="resetDialogTitle" aria-modal="true">
196 <p class="dialog-title" id="resetDialogTitle">Reset temporary password</p>
197 <form id="resetForm" class="dialog-fields" novalidate>
198 <zen-field size="md">
199 <label for="resetPassword">New temporary password</label>
200 <input
201 id="resetPassword"
202 name="temporaryPassword"
203 type="password"
204 autocomplete="new-password"
205 minlength="12"
206 maxlength="1024"
207 required
208 >
209 </zen-field>
210 <div class="dialog-actions">
211 <zen-button appearance="plain" size="md">
212 <button type="button" id="resetCancel">Cancel</button>
213 </zen-button>
214 <zen-button size="md">
215 <button type="submit" id="resetSubmit">Reset</button>
216 </zen-button>
217 </div>
218 </form>
219 </dialog>
220
221 <script>
222 (function () {
223 'use strict';
224
225 let csrfToken = '';
226 /* userList holds user records; IDs stay in JS state, not rendered in DOM text. */
227 let userList = [];
228
229 const statusEl = document.getElementById('statusMessage');
230 const tableContainer = document.getElementById('userTableContainer');
231 const createForm = document.getElementById('createForm');
232 const createSubmit = document.getElementById('createSubmit');
233 const resetDialog = document.getElementById('resetDialog');
234 const resetForm = document.getElementById('resetForm');
235 const resetCancel = document.getElementById('resetCancel');
236 const resetSubmit = document.getElementById('resetSubmit');
237 let pendingResetIndex = -1;
238
239 function showStatus(message, kind) {
240 statusEl.textContent = message;
241 statusEl.setAttribute('data-kind', kind);
242 statusEl.setAttribute('aria-hidden', 'false');
243 }
244
245 function clearStatus() {
246 statusEl.setAttribute('aria-hidden', 'true');
247 statusEl.textContent = '';
248 }
249
250 function escapeHtml(str) {
251 return String(str)
252 .replace(/&/g, '&amp;')
253 .replace(/</g, '&lt;')
254 .replace(/>/g, '&gt;')
255 .replace(/"/g, '&quot;')
256 .replace(/'/g, '&#39;');
257 }
258
259 async function fetchSession() {
260 const resp = await fetch('/api/auth/session', { credentials: 'same-origin' });
261 if (!resp.ok) throw new Error('session unavailable');
262 return resp.json();
263 }
264
265 async function fetchUsers() {
266 const resp = await fetch('/api/admin/users', { credentials: 'same-origin' });
267 if (!resp.ok) throw new Error('Failed to load users');
268 return resp.json();
269 }
270
271 function renderTable() {
272 if (userList.length === 0) {
273 tableContainer.innerHTML =
274 '<zen-text size="sm"><p>No users found.</p></zen-text>';
275 return;
276 }
277
278 /* IDs are stored only in userList (JS state), not in rendered DOM attributes. */
279 let rows = userList.map((u, idx) => {
280 const statusLabel = u.status === 'active' ? 'active' : 'disabled';
281 const forcedLabel = u.mustChangePassword ? ' (must change password)' : '';
282 return `<tr>
283 <td>${escapeHtml(u.username)}</td>
284 <td>${escapeHtml(u.role)}</td>
285 <td>${escapeHtml(statusLabel)}${escapeHtml(forcedLabel)}</td>
286 <td>
287 <div class="row-actions" data-idx="${idx}">
288 ${u.status === 'active'
289 ? `<zen-button size="sm" appearance="plain">
290 <button type="button" class="action-disable" data-idx="${idx}">Disable</button>
291 </zen-button>`
292 : `<zen-button size="sm" appearance="plain">
293 <button type="button" class="action-enable" data-idx="${idx}">Enable</button>
294 </zen-button>`
295 }
296 ${u.role === 'member'
297 ? `<zen-button size="sm" appearance="plain">
298 <button type="button" class="action-promote" data-idx="${idx}">Make admin</button>
299 </zen-button>`
300 : `<zen-button size="sm" appearance="plain">
301 <button type="button" class="action-demote" data-idx="${idx}">Make member</button>
302 </zen-button>`
303 }
304 <zen-button size="sm" appearance="plain">
305 <button type="button" class="action-reset" data-idx="${idx}">Reset password</button>
306 </zen-button>
307 <zen-button size="sm" appearance="plain">
308 <button type="button" class="action-revoke" data-idx="${idx}">Revoke sessions</button>
309 </zen-button>
310 </div>
311 </td>
312 </tr>`;
313 }).join('');
314
315 tableContainer.innerHTML = `
316 <table>
317 <thead>
318 <tr>
319 <th scope="col">Username</th>
320 <th scope="col">Role</th>
321 <th scope="col">Status</th>
322 <th scope="col"><span class="visually-hidden">Actions</span></th>
323 </tr>
324 </thead>
325 <tbody>${rows}</tbody>
326 </table>`;
327
328 tableContainer.querySelectorAll('.action-disable').forEach(btn => {
329 btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'disable'));
330 });
331 tableContainer.querySelectorAll('.action-enable').forEach(btn => {
332 btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'enable'));
333 });
334 tableContainer.querySelectorAll('.action-promote').forEach(btn => {
335 btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'admin'));
336 });
337 tableContainer.querySelectorAll('.action-demote').forEach(btn => {
338 btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'member'));
339 });
340 tableContainer.querySelectorAll('.action-reset').forEach(btn => {
341 btn.addEventListener('click', () => openResetDialog(+btn.dataset.idx));
342 });
343 tableContainer.querySelectorAll('.action-revoke').forEach(btn => {
344 btn.addEventListener('click', () => handleRevokeSessions(+btn.dataset.idx));
345 });
346 }
347
348 async function patchUser(idx, payload) {
349 const user = userList[idx];
350 if (!user) return;
351 const resp = await fetch('/api/admin/users/' + encodeURIComponent(user.id), {
352 method: 'PATCH',
353 credentials: 'same-origin',
354 headers: {
355 'Content-Type': 'application/json',
356 'Origin': window.location.origin,
357 'X-CSRF-Token': csrfToken,
358 },
359 body: JSON.stringify(payload),
360 });
361 if (!resp.ok) {
362 const data = await resp.json().catch(() => ({}));
363 throw new Error((data.error && data.error.message) || 'Request failed');
364 }
365 return resp.json();
366 }
367
368 async function handleStatusChange(idx, op) {
369 clearStatus();
370 try {
371 await patchUser(idx, { op });
372 const data = await fetchUsers();
373 userList = data.users || [];
374 renderTable();
375 showStatus('User updated.', 'success');
376 } catch (e) {
377 showStatus(e.message || 'Update failed.', 'error');
378 }
379 }
380
381 async function handleRoleChange(idx, role) {
382 clearStatus();
383 try {
384 await patchUser(idx, { op: 'set_role', role });
385 const data = await fetchUsers();
386 userList = data.users || [];
387 renderTable();
388 showStatus('Role updated.', 'success');
389 } catch (e) {
390 showStatus(e.message || 'Update failed.', 'error');
391 }
392 }
393
394 function openResetDialog(idx) {
395 pendingResetIndex = idx;
396 resetForm.reset();
397 resetDialog.showModal();
398 document.getElementById('resetPassword').focus();
399 }
400
401 resetCancel.addEventListener('click', () => {
402 resetDialog.close();
403 pendingResetIndex = -1;
404 });
405
406 resetDialog.addEventListener('close', () => {
407 resetForm.reset();
408 pendingResetIndex = -1;
409 });
410
411 resetForm.addEventListener('submit', async (evt) => {
412 evt.preventDefault();
413 clearStatus();
414 const pw = document.getElementById('resetPassword').value;
415 if (!pw || pw.length < 12) {
416 showStatus('Password must be at least 12 characters.', 'error');
417 return;
418 }
419 const idx = pendingResetIndex;
420 resetSubmit.disabled = true;
421 try {
422 await patchUser(idx, { op: 'temp_reset', temporaryPassword: pw });
423 resetDialog.close();
424 showStatus('Password reset. User must change password on next login.', 'success');
425 } catch (e) {
426 showStatus(e.message || 'Reset failed.', 'error');
427 } finally {
428 resetSubmit.disabled = false;
429 /* Never retain the password value. */
430 resetForm.reset();
431 }
432 });
433
434 async function handleRevokeSessions(idx) {
435 clearStatus();
436 const user = userList[idx];
437 if (!user) return;
438 try {
439 const resp = await fetch(
440 '/api/admin/users/' + encodeURIComponent(user.id) + '/sessions',
441 {
442 method: 'DELETE',
443 credentials: 'same-origin',
444 headers: {
445 'Origin': window.location.origin,
446 'X-CSRF-Token': csrfToken,
447 },
448 }
449 );
450 if (!resp.ok) {
451 const data = await resp.json().catch(() => ({}));
452 throw new Error((data.error && data.error.message) || 'Revoke failed');
453 }
454 showStatus('Sessions revoked.', 'success');
455 } catch (e) {
456 showStatus(e.message || 'Revoke failed.', 'error');
457 }
458 }
459
460 createForm.addEventListener('submit', async (evt) => {
461 evt.preventDefault();
462 clearStatus();
463 const username = document.getElementById('newUsername').value.trim();
464 const password = document.getElementById('newPassword').value;
465 const role = document.getElementById('newRole').value;
466 if (!username) { showStatus('Username required.', 'error'); return; }
467 if (!password || password.length < 12) {
468 showStatus('Password must be at least 12 characters.', 'error');
469 return;
470 }
471 createSubmit.disabled = true;
472 try {
473 const resp = await fetch('/api/admin/users', {
474 method: 'POST',
475 credentials: 'same-origin',
476 headers: {
477 'Content-Type': 'application/json',
478 'Origin': window.location.origin,
479 'X-CSRF-Token': csrfToken,
480 },
481 body: JSON.stringify({ username, temporaryPassword: password, role }),
482 });
483 const data = await resp.json().catch(() => ({}));
484 if (resp.status === 409) {
485 showStatus('Username already exists.', 'error');
486 return;
487 }
488 if (!resp.ok) {
489 showStatus((data.error && data.error.message) || 'Create failed.', 'error');
490 return;
491 }
492 createForm.reset();
493 const listData = await fetchUsers();
494 userList = listData.users || [];
495 renderTable();
496 showStatus('User created.', 'success');
497 } catch (e) {
498 showStatus(e.message || 'Create failed.', 'error');
499 } finally {
500 createSubmit.disabled = false;
501 /* Ensure password field is cleared even on error. */
502 document.getElementById('newPassword').value = '';
503 }
504 });
505
506 async function init() {
507 try {
508 const session = await fetchSession();
509 if (session.kind !== 'user' || session.role !== 'admin') {
510 window.location.href = '/login';
511 return;
512 }
513 csrfToken = session.csrfToken || '';
514 const data = await fetchUsers();
515 userList = data.users || [];
516 document.getElementById('loadingText').textContent = '';
517 renderTable();
518 } catch (e) {
519 showStatus('Failed to load admin page. Please refresh.', 'error');
520 }
521 }
522
523 init();
524 })();
525 </script>
526 </body>
527 </html>