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, '&amp;')
+          .replace(/</g, '&lt;')
+          .replace(/>/g, '&gt;')
+          .replace(/"/g, '&quot;')
+          .replace(/'/g, '&#39;');
+      }
+
+      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>