OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / account-merge-ui.js

555 lines · javascript ref:
/**
 * Account Merge UI — server-rendered HTML pages for the account merge flow.
 *
 * Extracted from routes/auth.js to separate rendering from routing.
 */

function renderMergePage(mergeRequest, pathPrefix = '') {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Merge Accounts - Pear Git</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }
    .container {
      max-width: 500px;
      width: 100%;
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 12px;
      padding: 40px;
    }
    h1 { font-size: 24px; margin-bottom: 10px; }
    .subtitle { color: #8b949e; margin-bottom: 30px; }
    .accounts {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 20px;
      margin: 30px 0;
    }
    .account {
      text-align: center;
      padding: 20px;
      background: #0d1117;
      border-radius: 8px;
      flex: 1;
    }
    .account-label { font-size: 12px; color: #8b949e; margin-bottom: 8px; }
    .pubkey {
      font-family: monospace;
      font-size: 12px;
      color: #58a6ff;
      word-break: break-all;
    }
    .arrow { font-size: 24px; color: #238636; }
    .status {
      display: flex;
      gap: 10px;
      margin: 20px 0;
    }
    .status-item {
      flex: 1;
      padding: 12px;
      border-radius: 6px;
      text-align: center;
      font-size: 14px;
    }
    .status-item.pending { background: #21262d; color: #8b949e; }
    .status-item.signed { background: #238636; color: white; }
    .warning {
      background: #f8514933;
      border: 1px solid #f85149;
      border-radius: 6px;
      padding: 16px;
      margin: 20px 0;
      font-size: 14px;
    }
    .warning strong { color: #f85149; }
    .keypair-input {
      margin: 20px 0;
    }
    .keypair-input label {
      display: block;
      margin-bottom: 8px;
      font-size: 14px;
    }
    .keypair-input textarea {
      width: 100%;
      height: 80px;
      background: #0d1117;
      border: 1px solid #30363d;
      border-radius: 6px;
      color: #c9d1d9;
      padding: 12px;
      font-family: monospace;
      font-size: 12px;
      resize: none;
    }
    .auto-detected {
      background: #23863633;
      border: 1px solid #238636;
      border-radius: 6px;
      padding: 16px;
      margin: 20px 0;
      text-align: center;
    }
    .auto-detected .icon { font-size: 32px; margin-bottom: 10px; }
    .auto-detected .label { color: #238636; font-weight: 600; margin-bottom: 5px; }
    .auto-detected .pubkey { font-family: monospace; font-size: 12px; color: #8b949e; }
    .manual-toggle {
      text-align: center;
      margin-top: 10px;
    }
    .manual-toggle a {
      color: #58a6ff;
      cursor: pointer;
      font-size: 12px;
      text-decoration: underline;
    }
    .hidden { display: none !important; }
    .button {
      width: 100%;
      padding: 14px;
      background: #238636;
      border: none;
      border-radius: 6px;
      color: white;
      font-size: 16px;
      font-weight: 600;
      cursor: pointer;
      margin-top: 10px;
    }
    .button:hover { background: #2ea043; }
    .button:disabled { background: #21262d; color: #8b949e; cursor: not-allowed; }
    .button.secondary {
      background: #21262d;
      border: 1px solid #30363d;
    }
    .button.secondary:hover { background: #30363d; }
    .expires {
      text-align: center;
      font-size: 12px;
      color: #8b949e;
      margin-top: 20px;
    }
    .result {
      margin-top: 20px;
      padding: 16px;
      border-radius: 6px;
      display: none;
    }
    .result.success { background: #23863633; border: 1px solid #238636; }
    .result.error { background: #f8514933; border: 1px solid #f85149; }
  </style>
</head>
<body>
  <div class="container">
    <h1>Merge Accounts</h1>
    <p class="subtitle">Link two ANProto identities into one account</p>

    <div class="accounts">
      <div class="account">
        <div class="account-label">Primary Account</div>
        <div class="pubkey">${mergeRequest.primaryPublicKey.substring(0, 16)}...</div>
      </div>
      <div class="arrow">→</div>
      <div class="account">
        <div class="account-label">Merging Into Primary</div>
        <div class="pubkey">${mergeRequest.secondaryPublicKey.substring(0, 16)}...</div>
      </div>
    </div>

    <div class="status">
      <div class="status-item ${mergeRequest.primarySig ? 'signed' : 'pending'}">
        ${mergeRequest.primarySig ? '✓ Primary Signed' : '○ Primary Pending'}
      </div>
      <div class="status-item ${mergeRequest.secondarySig ? 'signed' : 'pending'}">
        ${mergeRequest.secondarySig ? '✓ Secondary Signed' : '○ Secondary Pending'}
      </div>
    </div>

    <div class="warning">
      <strong>Warning:</strong> After merging, all issues, comments, and permissions from the secondary account will be transferred to the primary account. This action cannot be undone.
    </div>

    <!-- Auto-detected keypair section (shown when keypair found in localStorage) -->
    <div id="auto-detected-section" class="auto-detected hidden">
      <div class="icon">🔑</div>
      <div class="label">Keypair Auto-Detected</div>
      <div class="pubkey" id="detected-pubkey"></div>
      <div id="detected-role" style="margin-top: 8px; font-size: 14px;"></div>
    </div>

    <button id="auto-sign-btn" class="button hidden" onclick="autoSignMerge()">One-Click Confirm Merge</button>

    <div id="manual-toggle" class="manual-toggle hidden">
      <a onclick="showManualInput()">Use different keypair manually</a>
    </div>

    <!-- Manual keypair input (hidden when auto-detected) -->
    <div id="manual-section" class="keypair-input">
      <label>Enter your ANProto keypair to sign:</label>
      <textarea id="keypair" placeholder="Paste your 132-character keypair here..."></textarea>
    </div>

    <button id="manual-sign-btn" class="button" onclick="signMerge()">Sign & Confirm Merge</button>

    <div id="result" class="result"></div>

    <div class="expires">
      Expires: ${new Date(mergeRequest.expiresAt).toLocaleString()}
    </div>
  </div>

  <script type="module">
    import * as ed25519 from 'https://esm.sh/@noble/ed25519@2.0.0';
    import { sha512 } from 'https://esm.sh/@noble/hashes@1.3.0/sha512';

    // Configure ed25519 to use sha512
    ed25519.etc.sha512Sync = (...m) => sha512(ed25519.etc.concatBytes(...m));
    ed25519.etc.sha512Async = async (...m) => sha512(ed25519.etc.concatBytes(...m));

    // Make sign function available globally
    window.ed25519Sign = async function(message, secretKey) {
      const msgBytes = new TextEncoder().encode(message);
      const signature = await ed25519.signAsync(msgBytes, secretKey);
      return signature;
    };

    window.ed25519Ready = true;
    console.log('[Merge] ed25519 configured and ready');
  </script>

  <script>
    const mergeId = '${mergeRequest.mergeId}';
    const challenge = '${mergeRequest.challenge}';
    const basePath = '${pathPrefix}';
    const primaryPublicKey = '${mergeRequest.primaryPublicKey}';
    const secondaryPublicKey = '${mergeRequest.secondaryPublicKey}';
    const primarySigned = ${mergeRequest.primarySig ? 'true' : 'false'};
    const secondarySigned = ${mergeRequest.secondarySig ? 'true' : 'false'};

    let detectedKeypair = null;
    let detectedRole = null;

    function base64ToBytes(base64) {
      const binString = atob(base64);
      return Uint8Array.from(binString, (m) => m.codePointAt(0));
    }

    function bytesToBase64(bytes) {
      const binString = Array.from(bytes, (x) => String.fromCodePoint(x)).join('');
      return btoa(binString);
    }

    // Check localStorage for keypair on page load
    function checkForStoredKeypair() {
      const stored = localStorage.getItem('pear-git-keypair');
      if (!stored || stored.length !== 132) return null;

      const publicKeyB64 = stored.substring(0, 44);

      // Check if this matches primary or secondary
      if (publicKeyB64 === primaryPublicKey) {
        return { keypair: stored, role: 'primary', publicKey: publicKeyB64, needsSignature: !primarySigned };
      } else if (publicKeyB64 === secondaryPublicKey) {
        return { keypair: stored, role: 'secondary', publicKey: publicKeyB64, needsSignature: !secondarySigned };
      }

      return null;
    }

    // Initialize page
    function initPage() {
      const detected = checkForStoredKeypair();

      if (detected) {
        detectedKeypair = detected.keypair;
        detectedRole = detected.role;

        // Show auto-detected section
        document.getElementById('auto-detected-section').classList.remove('hidden');
        document.getElementById('detected-pubkey').textContent = detected.publicKey.substring(0, 16) + '...';

        if (detected.needsSignature) {
          document.getElementById('detected-role').innerHTML =
            'You are the <strong>' + detected.role + '</strong> account. Click below to sign.';
          document.getElementById('auto-sign-btn').classList.remove('hidden');
        } else {
          document.getElementById('detected-role').innerHTML =
            'You are the <strong>' + detected.role + '</strong> account. Already signed!';
          document.getElementById('detected-role').style.color = '#238636';
        }

        document.getElementById('manual-toggle').classList.remove('hidden');

        // Hide manual input
        document.getElementById('manual-section').classList.add('hidden');
        document.getElementById('manual-sign-btn').classList.add('hidden');
      }

      console.log('[Merge] Page initialized, detected:', detected);
    }

    function showManualInput() {
      document.getElementById('auto-detected-section').classList.add('hidden');
      document.getElementById('auto-sign-btn').classList.add('hidden');
      document.getElementById('manual-toggle').classList.add('hidden');
      document.getElementById('manual-section').classList.remove('hidden');
      document.getElementById('manual-sign-btn').classList.remove('hidden');
    }

    async function signWithKeypair(keypairInput) {
      const resultDiv = document.getElementById('result');

      if (keypairInput.length !== 132) {
        resultDiv.className = 'result error';
        resultDiv.style.display = 'block';
        resultDiv.textContent = 'Invalid keypair. Must be 132 characters (44 public + 88 secret).';
        return;
      }

      try {
        // Extract public key (first 44 chars) and secret key (last 88 chars)
        const publicKeyB64 = keypairInput.substring(0, 44);
        const secretKeyB64 = keypairInput.substring(44);

        // Decode secret key and extract signing key (first 32 bytes)
        const secretKeyBytes = base64ToBytes(secretKeyB64);
        const signingKey = secretKeyBytes.slice(0, 32);

        // Sign the challenge
        const signatureBytes = await window.ed25519Sign(challenge, signingKey);
        const signatureB64 = bytesToBase64(signatureBytes);

        // Submit to server
        const response = await fetch(basePath + '/api/user/merge/' + mergeId + '/confirm', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            signature: signatureB64,
            publicKey: publicKeyB64
          })
        });

        const result = await response.json();

        if (response.ok) {
          resultDiv.className = 'result success';
          resultDiv.style.display = 'block';

          if (result.stats) {
            resultDiv.innerHTML = '<strong>Merge Complete!</strong><br>' +
              'Identities transferred: ' + result.stats.identitiesTransferred + '<br>' +
              'Issues migrated: ' + result.stats.issuesMigrated + '<br>' +
              'Comments migrated: ' + result.stats.commentsMigrated;
          } else {
            resultDiv.textContent = result.message;
          }

          // Reload after 2 seconds if complete
          if (result.stats) {
            setTimeout(() => location.reload(), 2000);
          }
        } else {
          resultDiv.className = 'result error';
          resultDiv.style.display = 'block';
          resultDiv.textContent = result.error || 'Failed to confirm merge';
        }
      } catch (err) {
        resultDiv.className = 'result error';
        resultDiv.style.display = 'block';
        resultDiv.textContent = 'Error: ' + err.message;
      }
    }

    async function autoSignMerge() {
      if (!detectedKeypair) {
        alert('No keypair detected');
        return;
      }

      // Disable button to prevent double-click
      const btn = document.getElementById('auto-sign-btn');
      btn.disabled = true;
      btn.textContent = 'Signing...';

      await signWithKeypair(detectedKeypair);

      btn.disabled = false;
      btn.textContent = 'One-Click Confirm Merge';
    }

    async function signMerge() {
      const keypairInput = document.getElementById('keypair').value.trim();
      await signWithKeypair(keypairInput);
    }

    // Initialize when ed25519 is ready
    function waitForEd25519() {
      if (window.ed25519Ready) {
        initPage();
      } else {
        setTimeout(waitForEd25519, 50);
      }
    }
    waitForEd25519();
  </script>
</body>
</html>
  `;
}

/**
 * Render merge error page
 */
function renderMergeError(message, pathPrefix = '') {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Merge Error - Pear Git</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }
    .container {
      max-width: 400px;
      text-align: center;
    }
    .icon { font-size: 64px; margin-bottom: 20px; }
    h1 { font-size: 24px; margin-bottom: 10px; color: #f85149; }
    p { color: #8b949e; margin-bottom: 30px; }
    a {
      display: inline-block;
      padding: 12px 24px;
      background: #21262d;
      border: 1px solid #30363d;
      border-radius: 6px;
      color: #c9d1d9;
      text-decoration: none;
    }
    a:hover { background: #30363d; }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">⚠️</div>
    <h1>Merge Error</h1>
    <p>${message}</p>
    <a href="${pathPrefix}/">Return Home</a>
  </div>
</body>
</html>
  `;
}

/**
 * Render merge complete page
 */
function renderMergeComplete(mergeRequest, pathPrefix = '') {
  return `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Merge Complete - Pear Git</title>
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body {
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: #0d1117;
      color: #c9d1d9;
      min-height: 100vh;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 20px;
    }
    .container {
      max-width: 400px;
      text-align: center;
    }
    .icon { font-size: 64px; margin-bottom: 20px; }
    h1 { font-size: 24px; margin-bottom: 10px; color: #238636; }
    p { color: #8b949e; margin-bottom: 20px; }
    .stats {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 8px;
      padding: 20px;
      margin: 20px 0;
      text-align: left;
    }
    .stat { margin: 8px 0; }
    .stat-label { color: #8b949e; }
    .stat-value { color: #58a6ff; font-weight: 600; }
    a {
      display: inline-block;
      padding: 12px 24px;
      background: #238636;
      border-radius: 6px;
      color: white;
      text-decoration: none;
      font-weight: 600;
    }
    a:hover { background: #2ea043; }
  </style>
</head>
<body>
  <div class="container">
    <div class="icon">✓</div>
    <h1>Merge Complete!</h1>
    <p>Your accounts have been successfully merged.</p>

    ${mergeRequest.stats ? `
    <div class="stats">
      <div class="stat">
        <span class="stat-label">Identities transferred:</span>
        <span class="stat-value">${mergeRequest.stats.identitiesTransferred}</span>
      </div>
      <div class="stat">
        <span class="stat-label">Issues migrated:</span>
        <span class="stat-value">${mergeRequest.stats.issuesMigrated}</span>
      </div>
      <div class="stat">
        <span class="stat-label">Comments migrated:</span>
        <span class="stat-value">${mergeRequest.stats.commentsMigrated}</span>
      </div>
    </div>
    ` : ''}

    <a href="${pathPrefix}/">Return Home</a>
  </div>
</body>
</html>
  `;
}

export { renderMergePage, renderMergeError, renderMergeComplete }