Mercurial
view mrjunejune/src/admin/users/index.html @ 272:41a49c29a28f
polish JRPG conversation experience
Integrate desktop conversations into the utility panel, simplify the mobile frame, add modal destinations and a reusable Zenbu composer lab, and preserve explicit conversation resume behavior.
Co-authored-by: Copilot <[email protected]>
| author | MrJuneJune <me@mrjunejune.com> |
|---|---|
| date | Fri, 07 Aug 2026 16:05:29 -0700 |
| parents | 04fee26ecce0 |
| children |
line wrap: on
line source
<!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>