view mrjunejune/src/login/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" data-zen-theme="cyberpunk">
<head>
  {{/parts/base_head.html}}
  <title>Sign in — MrJuneJune</title>
  <style>
    .login-page main {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 60vh;
      padding: var(--zenbu-sys-padding-lg) var(--zenbu-sys-padding-md);
    }

    .login-card {
      width: 100%;
      max-width: 360px;
    }

    .login-card h2 {
      margin: 0 0 var(--zenbu-sys-padding-md);
      font-size: 1.4rem;
      font-weight: 700;
      text-align: center;
      letter-spacing: 0.08em;
    }

    .login-form {
      display: flex;
      flex-direction: column;
      gap: var(--zenbu-sys-padding-sm);
    }

    .login-actions {
      display: flex;
      justify-content: flex-end;
      gap: var(--zenbu-sys-space-control);
      margin-top: var(--zenbu-sys-padding-xs, 0.5rem);
    }

    #loginError {
      display: none;
      padding: 0.6rem 0.75rem;
      border-left: var(--zenbu-sys-stroke-width-emphasis, 2px) solid var(--zenbu-sys-color-danger-foreground);
      background: color-mix(in srgb, var(--zenbu-sys-color-danger-foreground) 10%, transparent);
      color: var(--zenbu-sys-color-danger-foreground);
      font-size: 0.875rem;
    }

    #loginError[aria-hidden="false"] {
      display: block;
    }
  </style>
</head>
<body class="login-page">
  {{/parts/header.html}}

  <main>
    <div class="login-card">
      <zen-heading size="xl">
        <h2>Sign in</h2>
      </zen-heading>

      <div id="loginError" role="alert" aria-live="assertive" aria-hidden="true"></div>

      <form class="login-form" id="loginForm" novalidate>
        <zen-field appearance="plain" size="md">
          <label for="username">Username</label>
          <input
            id="username"
            name="username"
            type="text"
            autocomplete="username"
            autocapitalize="none"
            spellcheck="false"
            required
            maxlength="32"
          >
        </zen-field>

        <zen-field appearance="plain" size="md">
          <label for="password">Password</label>
          <input
            id="password"
            name="password"
            type="password"
            autocomplete="current-password"
            required
            minlength="12"
            maxlength="1024"
          >
        </zen-field>

        <div class="login-actions">
          <zen-button appearance="plain" size="md">
            <button type="submit" id="loginSubmit">Sign in</button>
          </zen-button>
        </div>
      </form>
    </div>
  </main>

  <script>
    (function () {
      'use strict';

      const form     = document.getElementById('loginForm');
      const errorEl  = document.getElementById('loginError');
      const submitBtn = document.getElementById('loginSubmit');

      function showError(message) {
        errorEl.textContent = message;
        errorEl.setAttribute('aria-hidden', 'false');
      }

      function hideError() {
        errorEl.textContent = '';
        errorEl.setAttribute('aria-hidden', 'true');
      }

      async function fetchSession() {
        const resp = await fetch('/api/auth/session', {
          credentials: 'same-origin',
        });
        if (!resp.ok) throw new Error('session unavailable');
        return resp.json();
      }

      form.addEventListener('submit', async function (evt) {
        evt.preventDefault();
        hideError();

        const username = form.username.value.trim();
        const password = form.password.value;

        if (!username || !password) {
          showError('Please enter your username and password.');
          return;
        }

        submitBtn.disabled = true;

        try {
          const session = await fetchSession();
          const csrfToken = session.csrfToken;

          const resp = await fetch('/api/auth/login', {
            method: 'POST',
            headers: {
              'Content-Type': 'application/json',
              'Origin': window.location.origin,
            },
            credentials: 'same-origin',
            body: JSON.stringify({ username, password, csrfToken }),
          });

          form.password.value = '';

          let data = await resp.json().catch(function () { return null; });

          if (!resp.ok) {
            showError(resp.status === 429
              ? 'Too many attempts. Please try again later.'
              : 'Invalid username or password.');
            return;
          }

          if (!data) {
            data = await fetchSession();
            if (data.kind !== 'user') {
              window.location.reload();
              return;
            }
          }

          if (data.mustChangePassword) {
            window.location.href = '/account/password';
            return;
          }

          function safeNext(n) {
            if (!n || typeof n !== 'string') return null;
            if (!n.startsWith('/') || n.startsWith('//') || n.startsWith('/\\')) return null;
            if (n.includes('\\')) return null;
            try {
              const url = new URL(n, window.location.origin);
              if (url.origin !== window.location.origin) return null;
              return url.pathname + url.search + url.hash;
            } catch (_) {
              return null;
            }
          }
          const next = safeNext(new URLSearchParams(window.location.search).get('next'));
          window.location.href = next || '/jrpg';
        } catch (_) {
          form.password.value = '';
          showError('Sign-in failed. Please try again.');
        } finally {
          submitBtn.disabled = false;
        }
      });
    })();
  </script>
</body>
</html>