Mercurial
diff 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 |
line wrap: on
line diff
--- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/mrjunejune/src/admin/users/index.html Fri Aug 07 07:34:12 2026 -0700 @@ -0,0 +1,527 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + {{/parts/base_head.html}} + <title>Admin — Users — MrJuneJune</title> + <style> + main { + padding: var(--zenbu-sys-padding-lg, 2rem) var(--zenbu-sys-padding-md, 1rem); + max-width: 960px; + margin: 0 auto; + } + + .admin-header { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: var(--zenbu-sys-padding-md, 1rem); + flex-wrap: wrap; + margin-bottom: var(--zenbu-sys-padding-lg, 2rem); + } + + #statusMessage { + display: none; + padding: 0.6rem 0.75rem; + border-radius: 6px; + font-size: 0.875rem; + margin-bottom: var(--zenbu-sys-padding-md, 1rem); + } + + #statusMessage[data-kind="error"] { + background: color-mix(in srgb, var(--zenbu-sys-color-error, #c0392b) 12%, transparent); + color: var(--zenbu-sys-color-error, #c0392b); + } + + #statusMessage[data-kind="success"] { + background: color-mix(in srgb, var(--zenbu-sys-color-success, #27ae60) 12%, transparent); + color: var(--zenbu-sys-color-success, #27ae60); + } + + #statusMessage[aria-hidden="false"] { + display: block; + } + + table { + width: 100%; + border-collapse: collapse; + font-size: 0.9rem; + } + + th, td { + text-align: left; + padding: 0.5rem 0.75rem; + border-bottom: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5); + } + + th { + font-weight: 600; + color: var(--zenbu-sys-color-on-surface-variant, #5c5a53); + } + + .row-actions { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; + } + + details summary { + cursor: pointer; + list-style: none; + } + + details summary::-webkit-details-marker { + display: none; + } + + .create-form { + margin-top: var(--zenbu-sys-padding-lg, 2rem); + padding-top: var(--zenbu-sys-padding-md, 1rem); + border-top: 1px solid var(--zenbu-sys-color-outline-variant, #e0ddd5); + } + + .create-form-fields { + display: flex; + flex-direction: column; + gap: var(--zenbu-sys-padding-sm, 0.75rem); + max-width: 400px; + margin-top: var(--zenbu-sys-padding-sm, 0.75rem); + } + + .create-form-actions { + display: flex; + gap: 0.5rem; + margin-top: var(--zenbu-sys-padding-sm, 0.75rem); + } + + dialog { + border: none; + border-radius: 8px; + padding: var(--zenbu-sys-padding-lg, 2rem); + max-width: 400px; + width: 100%; + box-shadow: 0 4px 24px color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 12%, transparent); + } + + dialog::backdrop { + background: color-mix(in srgb, var(--zenbu-sys-color-on-surface, #1a1a18) 40%, transparent); + } + + .dialog-title { + margin: 0 0 var(--zenbu-sys-padding-md, 1rem); + font-size: 1.1rem; + font-weight: 600; + } + + .dialog-fields { + display: flex; + flex-direction: column; + gap: var(--zenbu-sys-padding-sm, 0.75rem); + } + + .dialog-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; + margin-top: var(--zenbu-sys-padding-md, 1rem); + } + </style> +</head> +<body> + {{/parts/header.html}} + + <main> + <div class="admin-header"> + <zen-heading size="xl"> + <h1>Users</h1> + </zen-heading> + <zen-button appearance="plain" size="sm"> + <a href="/jrpg">← Back to Shiba Quest</a> + </zen-button> + </div> + + <div id="statusMessage" role="status" aria-live="polite" aria-hidden="true"></div> + + <div id="userTableContainer"> + <zen-text size="sm"><p aria-live="polite" id="loadingText">Loading…</p></zen-text> + </div> + + <!-- Create user section --> + <section class="create-form" aria-labelledby="createHeading"> + <zen-heading size="lg"> + <h2 id="createHeading">Create user</h2> + </zen-heading> + <form id="createForm" class="create-form-fields" novalidate> + <zen-field size="md"> + <label for="newUsername">Username</label> + <input + id="newUsername" + name="username" + type="text" + autocomplete="off" + minlength="3" + maxlength="32" + required + > + </zen-field> + <zen-field size="md"> + <label for="newPassword">Temporary password</label> + <input + id="newPassword" + name="temporaryPassword" + type="password" + autocomplete="new-password" + minlength="12" + maxlength="1024" + required + > + </zen-field> + <zen-field size="md"> + <label for="newRole">Role</label> + <select id="newRole" name="role"> + <option value="member">member</option> + <option value="admin">admin</option> + </select> + </zen-field> + <div class="create-form-actions"> + <zen-button size="md"> + <button type="submit" id="createSubmit">Create user</button> + </zen-button> + </div> + </form> + </section> + </main> + + <!-- Temp reset password dialog --> + <dialog id="resetDialog" aria-labelledby="resetDialogTitle" aria-modal="true"> + <p class="dialog-title" id="resetDialogTitle">Reset temporary password</p> + <form id="resetForm" class="dialog-fields" novalidate> + <zen-field size="md"> + <label for="resetPassword">New temporary password</label> + <input + id="resetPassword" + name="temporaryPassword" + type="password" + autocomplete="new-password" + minlength="12" + maxlength="1024" + required + > + </zen-field> + <div class="dialog-actions"> + <zen-button appearance="plain" size="md"> + <button type="button" id="resetCancel">Cancel</button> + </zen-button> + <zen-button size="md"> + <button type="submit" id="resetSubmit">Reset</button> + </zen-button> + </div> + </form> + </dialog> + + <script> + (function () { + 'use strict'; + + let csrfToken = ''; + /* userList holds user records; IDs stay in JS state, not rendered in DOM text. */ + let userList = []; + + const statusEl = document.getElementById('statusMessage'); + const tableContainer = document.getElementById('userTableContainer'); + const createForm = document.getElementById('createForm'); + const createSubmit = document.getElementById('createSubmit'); + const resetDialog = document.getElementById('resetDialog'); + const resetForm = document.getElementById('resetForm'); + const resetCancel = document.getElementById('resetCancel'); + const resetSubmit = document.getElementById('resetSubmit'); + let pendingResetIndex = -1; + + function showStatus(message, kind) { + statusEl.textContent = message; + statusEl.setAttribute('data-kind', kind); + statusEl.setAttribute('aria-hidden', 'false'); + } + + function clearStatus() { + statusEl.setAttribute('aria-hidden', 'true'); + statusEl.textContent = ''; + } + + function escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + + async function fetchSession() { + const resp = await fetch('/api/auth/session', { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('session unavailable'); + return resp.json(); + } + + async function fetchUsers() { + const resp = await fetch('/api/admin/users', { credentials: 'same-origin' }); + if (!resp.ok) throw new Error('Failed to load users'); + return resp.json(); + } + + function renderTable() { + if (userList.length === 0) { + tableContainer.innerHTML = + '<zen-text size="sm"><p>No users found.</p></zen-text>'; + return; + } + + /* IDs are stored only in userList (JS state), not in rendered DOM attributes. */ + let rows = userList.map((u, idx) => { + const statusLabel = u.status === 'active' ? 'active' : 'disabled'; + const forcedLabel = u.mustChangePassword ? ' (must change password)' : ''; + return `<tr> + <td>${escapeHtml(u.username)}</td> + <td>${escapeHtml(u.role)}</td> + <td>${escapeHtml(statusLabel)}${escapeHtml(forcedLabel)}</td> + <td> + <div class="row-actions" data-idx="${idx}"> + ${u.status === 'active' + ? `<zen-button size="sm" appearance="plain"> + <button type="button" class="action-disable" data-idx="${idx}">Disable</button> + </zen-button>` + : `<zen-button size="sm" appearance="plain"> + <button type="button" class="action-enable" data-idx="${idx}">Enable</button> + </zen-button>` + } + ${u.role === 'member' + ? `<zen-button size="sm" appearance="plain"> + <button type="button" class="action-promote" data-idx="${idx}">Make admin</button> + </zen-button>` + : `<zen-button size="sm" appearance="plain"> + <button type="button" class="action-demote" data-idx="${idx}">Make member</button> + </zen-button>` + } + <zen-button size="sm" appearance="plain"> + <button type="button" class="action-reset" data-idx="${idx}">Reset password</button> + </zen-button> + <zen-button size="sm" appearance="plain"> + <button type="button" class="action-revoke" data-idx="${idx}">Revoke sessions</button> + </zen-button> + </div> + </td> + </tr>`; + }).join(''); + + tableContainer.innerHTML = ` + <table> + <thead> + <tr> + <th scope="col">Username</th> + <th scope="col">Role</th> + <th scope="col">Status</th> + <th scope="col"><span class="visually-hidden">Actions</span></th> + </tr> + </thead> + <tbody>${rows}</tbody> + </table>`; + + tableContainer.querySelectorAll('.action-disable').forEach(btn => { + btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'disable')); + }); + tableContainer.querySelectorAll('.action-enable').forEach(btn => { + btn.addEventListener('click', () => handleStatusChange(+btn.dataset.idx, 'enable')); + }); + tableContainer.querySelectorAll('.action-promote').forEach(btn => { + btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'admin')); + }); + tableContainer.querySelectorAll('.action-demote').forEach(btn => { + btn.addEventListener('click', () => handleRoleChange(+btn.dataset.idx, 'member')); + }); + tableContainer.querySelectorAll('.action-reset').forEach(btn => { + btn.addEventListener('click', () => openResetDialog(+btn.dataset.idx)); + }); + tableContainer.querySelectorAll('.action-revoke').forEach(btn => { + btn.addEventListener('click', () => handleRevokeSessions(+btn.dataset.idx)); + }); + } + + async function patchUser(idx, payload) { + const user = userList[idx]; + if (!user) return; + const resp = await fetch('/api/admin/users/' + encodeURIComponent(user.id), { + method: 'PATCH', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Origin': window.location.origin, + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify(payload), + }); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error((data.error && data.error.message) || 'Request failed'); + } + return resp.json(); + } + + async function handleStatusChange(idx, op) { + clearStatus(); + try { + await patchUser(idx, { op }); + const data = await fetchUsers(); + userList = data.users || []; + renderTable(); + showStatus('User updated.', 'success'); + } catch (e) { + showStatus(e.message || 'Update failed.', 'error'); + } + } + + async function handleRoleChange(idx, role) { + clearStatus(); + try { + await patchUser(idx, { op: 'set_role', role }); + const data = await fetchUsers(); + userList = data.users || []; + renderTable(); + showStatus('Role updated.', 'success'); + } catch (e) { + showStatus(e.message || 'Update failed.', 'error'); + } + } + + function openResetDialog(idx) { + pendingResetIndex = idx; + resetForm.reset(); + resetDialog.showModal(); + document.getElementById('resetPassword').focus(); + } + + resetCancel.addEventListener('click', () => { + resetDialog.close(); + pendingResetIndex = -1; + }); + + resetDialog.addEventListener('close', () => { + resetForm.reset(); + pendingResetIndex = -1; + }); + + resetForm.addEventListener('submit', async (evt) => { + evt.preventDefault(); + clearStatus(); + const pw = document.getElementById('resetPassword').value; + if (!pw || pw.length < 12) { + showStatus('Password must be at least 12 characters.', 'error'); + return; + } + const idx = pendingResetIndex; + resetSubmit.disabled = true; + try { + await patchUser(idx, { op: 'temp_reset', temporaryPassword: pw }); + resetDialog.close(); + showStatus('Password reset. User must change password on next login.', 'success'); + } catch (e) { + showStatus(e.message || 'Reset failed.', 'error'); + } finally { + resetSubmit.disabled = false; + /* Never retain the password value. */ + resetForm.reset(); + } + }); + + async function handleRevokeSessions(idx) { + clearStatus(); + const user = userList[idx]; + if (!user) return; + try { + const resp = await fetch( + '/api/admin/users/' + encodeURIComponent(user.id) + '/sessions', + { + method: 'DELETE', + credentials: 'same-origin', + headers: { + 'Origin': window.location.origin, + 'X-CSRF-Token': csrfToken, + }, + } + ); + if (!resp.ok) { + const data = await resp.json().catch(() => ({})); + throw new Error((data.error && data.error.message) || 'Revoke failed'); + } + showStatus('Sessions revoked.', 'success'); + } catch (e) { + showStatus(e.message || 'Revoke failed.', 'error'); + } + } + + createForm.addEventListener('submit', async (evt) => { + evt.preventDefault(); + clearStatus(); + const username = document.getElementById('newUsername').value.trim(); + const password = document.getElementById('newPassword').value; + const role = document.getElementById('newRole').value; + if (!username) { showStatus('Username required.', 'error'); return; } + if (!password || password.length < 12) { + showStatus('Password must be at least 12 characters.', 'error'); + return; + } + createSubmit.disabled = true; + try { + const resp = await fetch('/api/admin/users', { + method: 'POST', + credentials: 'same-origin', + headers: { + 'Content-Type': 'application/json', + 'Origin': window.location.origin, + 'X-CSRF-Token': csrfToken, + }, + body: JSON.stringify({ username, temporaryPassword: password, role }), + }); + const data = await resp.json().catch(() => ({})); + if (resp.status === 409) { + showStatus('Username already exists.', 'error'); + return; + } + if (!resp.ok) { + showStatus((data.error && data.error.message) || 'Create failed.', 'error'); + return; + } + createForm.reset(); + const listData = await fetchUsers(); + userList = listData.users || []; + renderTable(); + showStatus('User created.', 'success'); + } catch (e) { + showStatus(e.message || 'Create failed.', 'error'); + } finally { + createSubmit.disabled = false; + /* Ensure password field is cleared even on error. */ + document.getElementById('newPassword').value = ''; + } + }); + + async function init() { + try { + const session = await fetchSession(); + if (session.kind !== 'user' || session.role !== 'admin') { + window.location.href = '/login'; + return; + } + csrfToken = session.csrfToken || ''; + const data = await fetchUsers(); + userList = data.users || []; + document.getElementById('loadingText').textContent = ''; + renderTable(); + } catch (e) { + showStatus('Failed to load admin page. Please refresh.', 'error'); + } + } + + init(); + })(); + </script> +</body> +</html>