view mrjunejune/src/admin/users/index.html @ 279:b3b547563ec7

Add Google connector service and agent wiki Implement the C/Seobeo Google Drive and Gmail connector with encrypted OAuth storage, Zenbu authentication, browser testing, AI tool discovery, chunked HTTP decoding, and Bazel coverage. Consolidate repository guidance into progressive wiki documentation and enforce arena-first allocation for new first-party C code. Co-authored-by: Copilot <[email protected]> Copilot-Session: 84c338fd-0939-4bb3-b7f3-1062eb213e5d
author MrJuneJune <me@mrjunejune.com>
date Mon, 17 Aug 2026 22:22:36 -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>