view mrjunejune/src/admin/users/index.html @ 265:056790c4fb0d

add role-aware Epi assistant prompts Add verified June knowledge, guest/member/admin Copilot profiles, profile-isolated session recovery, animated Epi greetings, and a single authoritative runtime config workflow for inference. Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Fri, 07 Aug 2026 10:50:30 -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, '&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>