OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / identity-ui.js

1196 lines · javascript ref:
/**
 * Identity UI - Auto-generate browser identity on first visit
 *
 * Simplified approach:
 * - Generate keypair on first visit
 * - Store in localStorage
 * - Always inject X-Wirecore-PublicKey header
 * - Signatures optional (future enhancement)
 */

/**
 * Render identity UI JavaScript
 * This is injected into every page
 */
export function renderIdentityScript(pathPrefix = '') {
  return `
<script>
(function() {
  'use strict';

  const STORAGE_KEY = 'pear-git-keypair';

  // Server-injected path prefix (e.g., '/git' or '')
  window.__PATH_PREFIX__ = '${pathPrefix}';
  window.getBasePath = function() { return window.__PATH_PREFIX__; };

  const basePath = window.getBasePath();
  console.log('[Identity] Base path:', basePath || '(root)');

  // Load ed25519 signing library with sha512 configuration
  let ed25519 = null;
  let _ed25519Resolve = null;
  const ed25519Ready = new Promise(resolve => { _ed25519Resolve = resolve; });
  (async () => {
    try {
      const [ed25519Module, hashesModule] = await Promise.all([
        import(basePath + '/vendor/noble-ed25519.js'),
        import(basePath + '/vendor/noble-sha512.js')
      ]);

      // Configure sha512 for ed25519 v2.x
      ed25519Module.etc.sha512Sync = (...m) => hashesModule.sha512(ed25519Module.etc.concatBytes(...m));
      ed25519Module.etc.sha512Async = async (...m) => hashesModule.sha512(ed25519Module.etc.concatBytes(...m));

      ed25519 = ed25519Module;
      console.log('[Identity] ✓ ed25519 loaded and configured');
      _ed25519Resolve(true);
    } catch (err) {
      console.warn('[Identity] Failed to load ed25519:', err.message);
      console.warn('[Identity] Signatures disabled - using legacy auth');
      _ed25519Resolve(false);
    }
  })();

  // Wait for ed25519 with timeout (returns true if loaded, false if timeout/failed)
  async function waitForEd25519(timeoutMs) {
    if (ed25519) return true;
    const timeout = new Promise(resolve => setTimeout(() => resolve(false), timeoutMs));
    return Promise.race([ed25519Ready, timeout]);
  }

  // CRITICAL: Set up interception IMMEDIATELY (before any async operations)
  let publicKey = null;
  let secretKey = null;
  let currentChallenge = null;
  let challengeExpiry = 0;

  const existingKeypair = localStorage.getItem(STORAGE_KEY);
  if (existingKeypair) {
    publicKey = existingKeypair.substring(0, 44);
    secretKey = existingKeypair.substring(44, 132);
    console.log('[Identity] Loaded key from storage:', publicKey.substring(0, 8) + '...');
  }

  // Signing function
  async function signChallenge(challenge, secretKeyB64) {
    if (!ed25519) {
      throw new Error('ed25519 not loaded');
    }

    const challengeBytes = new TextEncoder().encode(challenge);
    const secretKeyBytes = base64ToBytes(secretKeyB64);

    if (secretKeyBytes.length !== 64) {
      throw new Error('Invalid secret key length: ' + secretKeyBytes.length);
    }

    // ed25519 private key is first 32 bytes
    const privateKey = secretKeyBytes.slice(0, 32);
    const signature = await ed25519.signAsync(challengeBytes, privateKey);

    if (signature.length !== 64) {
      throw new Error('Invalid signature length: ' + signature.length);
    }

    return bytesToBase64(signature);
  }

  function base64ToBytes(base64) {
    const binary = atob(base64);
    const bytes = new Uint8Array(binary.length);
    for (let i = 0; i < binary.length; i++) {
      bytes[i] = binary.charCodeAt(i);
    }
    return bytes;
  }

  function bytesToBase64(bytes) {
    const binary = String.fromCharCode(...bytes);
    return btoa(binary);
  }

  // Get or refresh challenge
  async function ensureChallenge() {
    const now = Date.now();

    // Return cached challenge if still valid (expires 1 min before server)
    if (currentChallenge && now < challengeExpiry) {
      return currentChallenge;
    }

    // Get new challenge
    try {
      const response = await originalFetch.call(window, basePath + '/api/auth/challenge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ context: 'web-identity' })
      });

      if (!response.ok) {
        throw new Error('Failed to get challenge: ' + response.status);
      }

      const data = await response.json();
      currentChallenge = data;
      // Expire 1 minute before server (5min - 1min = 4min)
      challengeExpiry = now + 240000;

      console.log('[Identity] ✓ Challenge obtained:', data.challengeId.substring(0, 8) + '...');
      return currentChallenge;
    } catch (err) {
      console.error('[Identity] Failed to get challenge:', err);
      return null;
    }
  }

  // Intercept fetch with retry on challenge failure
  const originalFetch = window.fetch;
  window.fetch = async function(url, options = {}, _retryCount = 0) {
    options = options || {};
    options.headers = options.headers || {};

    // Skip internal requests
    if (url.includes('/api/identity/generate') || url.includes('/api/identity/create') || url.includes('/api/identity/username-available') || url.includes('/api/auth/challenge')) {
      return originalFetch.call(this, url, options);
    }

    // Add authentication headers
    if (publicKey) {
      options.headers['X-Wirecore-PublicKey'] = publicKey;

      // Wait for ed25519 to load (up to 3s) before sending authenticated requests
      if (!ed25519 && secretKey) {
        await waitForEd25519(3000);
      }

      // Add signature if ed25519 is loaded
      if (ed25519 && secretKey) {
        try {
          const challenge = await ensureChallenge();
          if (challenge) {
            const signature = await signChallenge(challenge.challenge, secretKey);
            options.headers['X-Wirecore-Signature'] = signature;
            options.headers['X-Wirecore-Challenge'] = challenge.challengeId;
            console.log('[Identity] ✓ Fetch with signature:', url);
          } else {
            console.log('[Identity] ✓ Fetch (no challenge):', url);
          }
        } catch (err) {
          console.warn('[Identity] Signing failed, using legacy auth:', err.message);
          console.log('[Identity] ✓ Fetch (legacy):', url);
        }
      } else {
        console.log('[Identity] ✓ Fetch (legacy):', url);
      }
    }

    const response = await originalFetch.call(this, url, options);

    // If 401 and we haven't retried yet, wait for ed25519, get fresh challenge and re-sign
    if (response.status === 401 && _retryCount < 2) {
      // On first retry, wait for ed25519 if not loaded yet
      if (!ed25519 && _retryCount === 0) {
        console.warn('[Identity] Got 401, waiting for ed25519 before retry...');
        await waitForEd25519(5000);
      }
      console.warn('[Identity] Retrying with fresh challenge (attempt ' + (_retryCount + 1) + ')...');
      currentChallenge = null;
      challengeExpiry = 0;
      return window.fetch(url, { ...options, headers: { ...options.headers } }, _retryCount + 1);
    }

    return response;
  };

  // Intercept XMLHttpRequest (CRITICAL - some code uses XHR!)
  const originalXHROpen = XMLHttpRequest.prototype.open;
  const originalXHRSend = XMLHttpRequest.prototype.send;

  XMLHttpRequest.prototype.open = function(method, url, ...rest) {
    this._xhr_url = url;
    this._xhr_method = method;
    return originalXHROpen.call(this, method, url, ...rest);
  };

  XMLHttpRequest.prototype.send = async function(...args) {
    // Skip internal requests
    if (this._xhr_url && !this._xhr_url.includes('/api/identity/') && !this._xhr_url.includes('/api/auth/challenge')) {
      if (publicKey) {
        try {
          this.setRequestHeader('X-Wirecore-PublicKey', publicKey);

          // Wait for ed25519 to load (up to 3s) before sending authenticated requests
          if (!ed25519 && secretKey) {
            await waitForEd25519(3000);
          }

          // Add signature if ed25519 is loaded
          if (ed25519 && secretKey) {
            const challenge = await ensureChallenge();
            if (challenge) {
              const signature = await signChallenge(challenge.challenge, secretKey);
              this.setRequestHeader('X-Wirecore-Signature', signature);
              this.setRequestHeader('X-Wirecore-Challenge', challenge.challengeId);
              console.log('[Identity] ✓ XHR with signature:', this._xhr_method, this._xhr_url);
            }
          } else {
            console.log('[Identity] ✓ XHR (legacy):', this._xhr_method, this._xhr_url);
          }
        } catch (err) {
          console.warn('[Identity] XHR signing failed:', err.message);
        }
      }
    }

    return originalXHRSend.apply(this, args);
  };

  console.log('[Identity] Interceptors installed', publicKey ? '(key: ' + publicKey.substring(0, 8) + '...)' : '(no key yet)');

  /**
   * Get or generate identity
   */
  async function ensureIdentity() {
    let keypair = localStorage.getItem(STORAGE_KEY);

    if (!keypair) {
      console.log('[Identity] Generating new identity...');

      try {
        const response = await fetch(basePath + '/api/identity/generate', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' }
        });

        if (!response.ok) {
          throw new Error('Failed to generate identity: ' + response.status);
        }

        const data = await response.json();
        keypair = data.keypair;

        // Store in localStorage
        localStorage.setItem(STORAGE_KEY, keypair);

        console.log('[Identity] ✓ Identity generated:', data.publicKey.substring(0, 8) + '...');

        // Show welcome modal
        showWelcomeModal(data);
      } catch (err) {
        console.error('[Identity] Failed to generate identity:', err);
        throw err;
      }
    } else {
      console.log('[Identity] ✓ Loaded existing identity');
    }

    return keypair;
  }

  /**
   * Setup header injection (update the keys for the already-installed interceptor)
   */
  function setupHeaderInjection(keypair) {
    publicKey = keypair.substring(0, 44);
    secretKey = keypair.substring(44, 132);
    console.log('[Identity] ✓ Keys updated for interceptors:', publicKey.substring(0, 8) + '...');
    console.log('[Identity] Signatures:', ed25519 ? 'enabled' : 'disabled (legacy mode)');
  }

  /**
   * Show welcome modal for new users
   */
  function showWelcomeModal(data) {
    const modal = document.createElement('div');
    modal.id = 'identity-welcome-modal';
    modal.innerHTML = \`
      <style>
        #identity-welcome-modal {
          position: fixed;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background: rgba(0, 0, 0, 0.8);
          display: flex;
          align-items: center;
          justify-content: center;
          z-index: 10000;
        }
        #identity-welcome-modal .modal-content {
          background: #161b22;
          border: 1px solid #30363d;
          border-radius: 6px;
          padding: 30px;
          max-width: 600px;
          color: #c9d1d9;
        }
        #identity-welcome-modal h2 {
          margin: 0 0 20px 0;
          color: #58a6ff;
        }
        #identity-welcome-modal p {
          line-height: 1.6;
          margin: 10px 0;
        }
        #identity-welcome-modal .keypair-box {
          background: #0d1117;
          padding: 15px;
          border-radius: 4px;
          font-family: monospace;
          font-size: 11px;
          word-break: break-all;
          margin: 20px 0;
          border: 1px solid #30363d;
        }
        #identity-welcome-modal .warning {
          background: #3d1f00;
          border: 1px solid #6e3500;
          padding: 15px;
          border-radius: 4px;
          margin: 20px 0;
        }
        #identity-welcome-modal .warning strong {
          color: #f85149;
        }
        #identity-welcome-modal .buttons {
          display: flex;
          gap: 10px;
          margin-top: 20px;
        }
        #identity-welcome-modal button {
          background: #238636;
          color: white;
          border: none;
          padding: 10px 20px;
          border-radius: 6px;
          cursor: pointer;
          font-size: 14px;
        }
        #identity-welcome-modal button:hover {
          background: #2ea043;
        }
        #identity-welcome-modal .secondary {
          background: #21262d;
        }
        #identity-welcome-modal .secondary:hover {
          background: #30363d;
        }
      </style>
      <div class="modal-content">
        <h2>🎉 Welcome to Pear Git!</h2>
        <p>
          Your identity has been created. You now have <strong>read-only</strong> access to all repositories.
        </p>
        <p>
          <strong>Your Public Key:</strong>
        </p>
        <div class="keypair-box">\${data.publicKey}</div>

        <div class="warning">
          <strong>⚠️ Important:</strong> Save your keypair to restore your identity later.
          If you lose this, you'll need to create a new identity.
        </div>

        <details>
          <summary style="cursor: pointer; padding: 10px 0;">Show Full Keypair (132 characters)</summary>
          <div class="keypair-box">\${data.keypair}</div>
          <button onclick="copyKeypair('\${data.keypair}')" class="secondary">
            📋 Copy Keypair
          </button>
        </details>

        <div style="margin-top: 20px; padding: 15px; background: #0d1117; border: 1px solid #30363d; border-radius: 4px;">
          <p style="font-size: 14px; color: #8b949e; margin-bottom: 12px;">
            To get <strong style="color: #c9d1d9;">admin access</strong>, run this on the server:
          </p>
          <div style="background: #161b22; padding: 10px; border-radius: 4px; font-family: monospace; font-size: 12px; word-break: break-all; color: #79c0ff;">
            pgh admin promote \${data.publicKey}
          </div>
        </div>

        <div class="buttons">
          <button onclick="window.location.href=window.getBasePath()+'/profile'">
            View Profile
          </button>
          <button class="secondary" onclick="document.getElementById('identity-welcome-modal').remove(); showImportDialog()">
            Import Existing Identity
          </button>
          <button class="secondary" onclick="document.getElementById('identity-welcome-modal').remove()">
            Got it!
          </button>
        </div>
      </div>
    \`;

    document.body.appendChild(modal);
  }

  /**
   * Copy keypair to clipboard
   */
  window.copyKeypair = function(keypair) {
    if (navigator.clipboard) {
      navigator.clipboard.writeText(keypair).then(() => {
        alert('Keypair copied to clipboard!');
      }).catch(err => {
        console.error('Failed to copy:', err);
        prompt('Copy this keypair manually:', keypair);
      });
    } else {
      prompt('Copy this keypair manually:', keypair);
    }
  };

  /**
   * Import keypair - allows restoring identity on a different device
   */
  window.importKeypair = function(keypairInput) {
    if (!keypairInput) {
      keypairInput = prompt('Paste your 132-character ANProto keypair:');
    }

    if (!keypairInput) return false;

    keypairInput = keypairInput.trim();

    if (keypairInput.length !== 132) {
      alert('Invalid keypair. Must be exactly 132 characters (44 public + 88 secret).');
      return false;
    }

    // Validate it looks like base64
    if (!/^[A-Za-z0-9+/=]+$/.test(keypairInput)) {
      alert('Invalid keypair format. Must be base64 encoded.');
      return false;
    }

    // Store in localStorage
    localStorage.setItem(STORAGE_KEY, keypairInput);

    // Update current session
    publicKey = keypairInput.substring(0, 44);
    secretKey = keypairInput.substring(44, 132);

    console.log('[Identity] ✓ Keypair imported:', publicKey.substring(0, 8) + '...');
    alert('Keypair imported! Refreshing page...');

    // Reload to apply the new identity
    window.location.reload();
    return true;
  };

  /**
   * Show import dialog (can be called from console or UI)
   */
  window.showImportDialog = function() {
    const existing = localStorage.getItem(STORAGE_KEY);
    const existingPubKey = existing ? existing.substring(0, 16) + '...' : 'none';

    const modal = document.createElement('div');
    modal.id = 'identity-import-modal';
    modal.innerHTML = \`
      <style>
        #identity-import-modal {
          position: fixed;
          top: 0;
          left: 0;
          right: 0;
          bottom: 0;
          background: rgba(0, 0, 0, 0.8);
          display: flex;
          align-items: center;
          justify-content: center;
          z-index: 10000;
        }
        #identity-import-modal .modal-content {
          background: #161b22;
          border: 1px solid #30363d;
          border-radius: 6px;
          padding: 30px;
          max-width: 500px;
          width: 90%;
          color: #c9d1d9;
        }
        #identity-import-modal h2 {
          margin: 0 0 15px 0;
          color: #58a6ff;
        }
        #identity-import-modal textarea {
          width: 100%;
          height: 80px;
          background: #0d1117;
          border: 1px solid #30363d;
          border-radius: 6px;
          color: #c9d1d9;
          padding: 12px;
          font-family: monospace;
          font-size: 12px;
          margin: 15px 0;
        }
        #identity-import-modal .buttons {
          display: flex;
          gap: 10px;
          margin-top: 15px;
        }
        #identity-import-modal button {
          flex: 1;
          padding: 12px;
          border-radius: 6px;
          border: none;
          cursor: pointer;
          font-size: 14px;
        }
        #identity-import-modal .primary {
          background: #238636;
          color: white;
        }
        #identity-import-modal .secondary {
          background: #21262d;
          color: #c9d1d9;
          border: 1px solid #30363d;
        }
        #identity-import-modal .warning {
          background: #f8514933;
          border: 1px solid #f85149;
          padding: 10px;
          border-radius: 6px;
          font-size: 13px;
          margin-top: 10px;
        }
        #identity-import-modal .current {
          font-size: 12px;
          color: #8b949e;
          margin-bottom: 10px;
        }
      </style>
      <div class="modal-content">
        <h2>🔑 Import Keypair</h2>
        <p>Paste your 132-character ANProto keypair to restore your identity on this device.</p>
        <div class="current">Current identity: \${existingPubKey}</div>
        <textarea id="import-keypair-input" placeholder="Paste your 132-character keypair here..."></textarea>
        <div class="warning">
          <strong>⚠️ Warning:</strong> This will replace your current identity on this device.
        </div>
        <div class="buttons">
          <button class="secondary" onclick="document.getElementById('identity-import-modal').remove()">Cancel</button>
          <button class="primary" onclick="doImport()">Import</button>
        </div>
      </div>
    \`;

    window.doImport = function() {
      const input = document.getElementById('import-keypair-input').value;
      if (window.importKeypair(input)) {
        document.getElementById('identity-import-modal').remove();
      }
    };

    document.body.appendChild(modal);
    document.getElementById('import-keypair-input').focus();
  };

  /**
   * Expose signing function for other scripts (e.g., merge)
   */
  window.signChallenge = async function(challenge, secretKeyB64) {
    // Wait for ed25519 to load
    let attempts = 0;
    while (!ed25519 && attempts < 50) {
      await new Promise(r => setTimeout(r, 100));
      attempts++;
    }

    if (!ed25519) {
      throw new Error('ed25519 signing library not loaded');
    }

    const challengeBytes = new TextEncoder().encode(challenge);
    const secretKeyBytes = base64ToBytes(secretKeyB64);

    if (secretKeyBytes.length !== 64) {
      throw new Error('Invalid secret key length: ' + secretKeyBytes.length);
    }

    const privateKey = secretKeyBytes.slice(0, 32);
    const signature = await ed25519.signAsync(challengeBytes, privateKey);

    return bytesToBase64(signature);
  };

  // ========================================
  // Attestation Storage & Recovery
  // ========================================

  const ATTESTATION_KEY = 'pear-git-attestation';

  /**
   * Fetch and store a permission attestation from the server.
   * Called after successful login/promote to cache the signed proof.
   */
  window.fetchAttestation = async function() {
    try {
      const response = await fetch(basePath + '/api/user/attestation');
      if (!response.ok) {
        console.warn('[Identity] Could not fetch attestation:', response.status);
        return null;
      }
      const data = await response.json();
      if (data.attestation) {
        localStorage.setItem(ATTESTATION_KEY, JSON.stringify(data.attestation));
        console.log('[Identity] Attestation saved to localStorage');
        return data.attestation;
      }
    } catch (err) {
      console.warn('[Identity] Attestation fetch failed:', err.message);
    }
    return null;
  };

  /**
   * Attempt to restore permissions from a stored attestation.
   * Used when server returns 401/403 but we have a valid attestation.
   */
  window.restoreFromAttestation = async function() {
    const stored = localStorage.getItem(ATTESTATION_KEY);
    if (!stored) {
      console.log('[Identity] No stored attestation for restore');
      return false;
    }

    const keypairStr = localStorage.getItem(STORAGE_KEY);
    if (!keypairStr || !ed25519) {
      console.warn('[Identity] Cannot restore: missing keypair or ed25519');
      return false;
    }

    try {
      const attestation = JSON.parse(stored);
      const pubKey = keypairStr.substring(0, 44);

      // Get a fresh challenge for ownership proof
      const challengeResp = await originalFetch.call(window, basePath + '/api/auth/challenge', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ context: 'attestation-restore' })
      });
      if (!challengeResp.ok) throw new Error('Failed to get challenge');
      const challengeData = await challengeResp.json();

      // Sign the challenge
      const sig = await signChallenge(challengeData.challenge, keypairStr.substring(44, 132));

      // Submit restore request
      const restoreResp = await originalFetch.call(window, basePath + '/api/auth/restore', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({
          attestation,
          challengeId: challengeData.challengeId,
          publicKey: pubKey,
          signature: sig
        })
      });

      if (!restoreResp.ok) {
        const err = await restoreResp.json();
        throw new Error(err.error || 'Restore failed');
      }

      const result = await restoreResp.json();
      console.log('[Identity] Permissions restored!', result.permissions);

      // Store fresh attestation
      if (result.attestation) {
        localStorage.setItem(ATTESTATION_KEY, JSON.stringify(result.attestation));
      }

      return true;
    } catch (err) {
      console.error('[Identity] Restore from attestation failed:', err.message);
      return false;
    }
  };

  /**
   * Export identity (keypair + attestation) as a downloadable JSON backup.
   */
  window.exportIdentity = function() {
    const keypair = localStorage.getItem(STORAGE_KEY);
    const attestation = localStorage.getItem(ATTESTATION_KEY);

    if (!keypair) {
      alert('No identity to export');
      return;
    }

    const backup = {
      version: 1,
      exportedAt: new Date().toISOString(),
      keypair,
      publicKey: keypair.substring(0, 44),
      attestation: attestation ? JSON.parse(attestation) : null
    };

    const blob = new Blob([JSON.stringify(backup, null, 2)], { type: 'application/json' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'pear-git-identity-' + keypair.substring(0, 8) + '.json';
    document.body.appendChild(a);
    a.click();
    document.body.removeChild(a);
    URL.revokeObjectURL(url);

    console.log('[Identity] Identity exported');
  };

  /**
   * Import identity from a backup file.
   */
  window.importIdentityFile = function(fileInput) {
    const file = fileInput.files[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = function(e) {
      try {
        const backup = JSON.parse(e.target.result);
        if (!backup.keypair || backup.keypair.length !== 132) {
          alert('Invalid backup file: missing or invalid keypair');
          return;
        }

        localStorage.setItem(STORAGE_KEY, backup.keypair);
        if (backup.attestation) {
          localStorage.setItem(ATTESTATION_KEY, JSON.stringify(backup.attestation));
        }

        alert('Identity imported! Refreshing...');
        window.location.reload();
      } catch (err) {
        alert('Failed to import: ' + err.message);
      }
    };
    reader.readAsText(file);
  };

  /**
   * Sign out — clear identity and reload
   */
  window.signOut = function() {
    localStorage.removeItem(STORAGE_KEY);
    localStorage.removeItem(ATTESTATION_KEY);
    publicKey = null;
    secretKey = null;
    window.location.reload();
  };

  /**
   * Show the Sign In / Create Identity modal
   */
  window.showSignInModal = function() {
    const modal = document.createElement('div');
    modal.id = 'identity-signin-modal';
    modal.innerHTML = \`
      <style>
        #identity-signin-modal {
          position: fixed; top: 0; left: 0; right: 0; bottom: 0;
          background: rgba(0, 0, 0, 0.8);
          display: flex; align-items: center; justify-content: center;
          z-index: 10000;
        }
        #identity-signin-modal .modal-content {
          background: #161b22; border: 1px solid #30363d; border-radius: 6px;
          padding: 30px; max-width: 480px; width: 90%; color: #c9d1d9;
        }
        #identity-signin-modal h2 { margin: 0 0 20px 0; color: #58a6ff; }
        #identity-signin-modal .tabs { display: flex; gap: 0; margin-bottom: 20px; }
        #identity-signin-modal .tab {
          flex: 1; padding: 10px; text-align: center; cursor: pointer;
          border-bottom: 2px solid #30363d; color: #8b949e; font-size: 14px;
        }
        #identity-signin-modal .tab.active { border-bottom-color: #58a6ff; color: #c9d1d9; }
        #identity-signin-modal .tab-content { display: none; }
        #identity-signin-modal .tab-content.active { display: block; }
        #identity-signin-modal label { display: block; font-size: 13px; color: #8b949e; margin-bottom: 6px; margin-top: 15px; }
        #identity-signin-modal input, #identity-signin-modal textarea {
          width: 100%; background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
          color: #c9d1d9; padding: 10px; font-size: 14px; box-sizing: border-box;
        }
        #identity-signin-modal textarea { height: 80px; font-family: monospace; font-size: 12px; margin-top: 8px; }
        #identity-signin-modal .username-status { font-size: 12px; margin-top: 4px; min-height: 18px; }
        #identity-signin-modal .available { color: #3fb950; }
        #identity-signin-modal .taken { color: #f85149; }
        #identity-signin-modal .buttons { display: flex; gap: 10px; margin-top: 20px; }
        #identity-signin-modal button {
          flex: 1; padding: 12px; border-radius: 6px; border: none; cursor: pointer; font-size: 14px;
        }
        #identity-signin-modal .primary { background: #238636; color: white; }
        #identity-signin-modal .primary:disabled { background: #21262d; color: #484f58; cursor: not-allowed; }
        #identity-signin-modal .secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
        #identity-signin-modal .backup-info {
          background: #0d1117; border: 1px solid #30363d; border-radius: 6px;
          padding: 15px; margin-top: 15px; font-family: monospace; font-size: 11px; word-break: break-all;
        }
        #identity-signin-modal .success-msg { color: #3fb950; font-weight: 500; }
      </style>
      <div class="modal-content">
        <h2>Sign In</h2>
        <div class="tabs">
          <div class="tab active" data-tab="create" onclick="switchSignInTab('create')">Create Identity</div>
          <div class="tab" data-tab="import" onclick="switchSignInTab('import')">Import Keypair</div>
        </div>

        <div id="tab-create" class="tab-content active">
          <label for="signin-username">Username</label>
          <input type="text" id="signin-username" placeholder="pick-a-username" maxlength="30"
                 oninput="checkUsername(this.value)" autocomplete="off" />
          <div id="username-status" class="username-status"></div>

          <label for="signin-displayname">Display Name (optional)</label>
          <input type="text" id="signin-displayname" placeholder="Your Name" maxlength="60" />

          <div id="create-result" style="display:none;">
            <p class="success-msg">Identity created!</p>
            <label>Your keypair (save this!):</label>
            <div class="backup-info" id="create-keypair"></div>
            <button class="secondary" style="margin-top:10px; flex:none; width:auto; padding:8px 16px;"
                    onclick="copyKeypair(document.getElementById('create-keypair').textContent)">Copy Keypair</button>
          </div>

          <div class="buttons" id="create-buttons">
            <button class="secondary" onclick="document.getElementById('identity-signin-modal').remove()">Cancel</button>
            <button class="primary" id="create-btn" onclick="doCreateIdentity()" disabled>Create</button>
          </div>
        </div>

        <div id="tab-import" class="tab-content">
          <label>Paste your 132-character keypair</label>
          <textarea id="import-keypair-field" placeholder="Paste keypair here..."></textarea>
          <div class="buttons">
            <button class="secondary" onclick="document.getElementById('identity-signin-modal').remove()">Cancel</button>
            <button class="primary" onclick="doImportFromModal()">Import</button>
          </div>
        </div>
      </div>
    \`;

    document.body.appendChild(modal);
    document.getElementById('signin-username').focus();
  };

  let _usernameCheckTimer = null;
  let _usernameAvailable = false;

  window.switchSignInTab = function(tab) {
    document.querySelectorAll('#identity-signin-modal .tab').forEach(t => t.classList.toggle('active', t.dataset.tab === tab));
    document.querySelectorAll('#identity-signin-modal .tab-content').forEach(c => c.classList.toggle('active', c.id === 'tab-' + tab));
  };

  window.checkUsername = function(value) {
    const status = document.getElementById('username-status');
    const btn = document.getElementById('create-btn');
    _usernameAvailable = false;
    if (btn) btn.disabled = true;

    if (_usernameCheckTimer) clearTimeout(_usernameCheckTimer);

    const normalized = value.toLowerCase().trim();
    if (normalized.length < 3) {
      status.textContent = normalized.length > 0 ? 'Must be at least 3 characters' : '';
      status.className = 'username-status taken';
      return;
    }
    if (!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(normalized) && normalized.length >= 3) {
      status.textContent = 'Letters, numbers, and hyphens only';
      status.className = 'username-status taken';
      return;
    }

    status.textContent = 'Checking...';
    status.className = 'username-status';

    _usernameCheckTimer = setTimeout(async () => {
      try {
        const resp = await originalFetch.call(window, basePath + '/api/identity/username-available?username=' + encodeURIComponent(normalized));
        const data = await resp.json();
        if (data.available) {
          status.textContent = '\\u2713 Available';
          status.className = 'username-status available';
          _usernameAvailable = true;
          if (btn) btn.disabled = false;
        } else {
          status.textContent = 'Username taken';
          status.className = 'username-status taken';
        }
      } catch {
        status.textContent = 'Check failed';
        status.className = 'username-status taken';
      }
    }, 400);
  };

  window.doCreateIdentity = async function() {
    if (!_usernameAvailable) return;

    const username = document.getElementById('signin-username').value.toLowerCase().trim();
    const displayName = document.getElementById('signin-displayname').value.trim() || username;
    const btn = document.getElementById('create-btn');
    btn.disabled = true;
    btn.textContent = 'Creating...';

    try {
      const resp = await originalFetch.call(window, basePath + '/api/identity/create', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ username, displayName })
      });
      const data = await resp.json();

      if (!resp.ok) {
        alert(data.error || 'Failed to create identity');
        btn.disabled = false;
        btn.textContent = 'Create';
        return;
      }

      // Store keypair
      localStorage.setItem(STORAGE_KEY, data.keypair);
      setupHeaderInjection(data.keypair);

      // Show success with backup info
      document.getElementById('create-result').style.display = 'block';
      document.getElementById('create-keypair').textContent = data.keypair;
      document.getElementById('create-buttons').innerHTML =
        '<button class="primary" onclick="document.getElementById(\\'identity-signin-modal\\').remove(); window.location.reload();">Done</button>';

      // Fetch attestation
      setTimeout(() => window.fetchAttestation(), 1000);
    } catch (err) {
      alert('Error: ' + err.message);
      btn.disabled = false;
      btn.textContent = 'Create';
    }
  };

  window.doImportFromModal = function() {
    const input = document.getElementById('import-keypair-field').value;
    if (window.importKeypair(input)) {
      const modal = document.getElementById('identity-signin-modal');
      if (modal) modal.remove();
    }
  };

  /**
   * Render the identity badge / sign-in button in the page
   */
  function renderIdentityUI() {
    const container = document.createElement('div');
    container.id = 'identity-badge';
    container.style.cssText = 'position:fixed; top:0; right:12px; height:56px; display:flex; align-items:center; z-index:9999; font-family:system-ui; font-size:13px;';

    if (publicKey) {
      // Logged in — show user badge
      const abbrev = publicKey.substring(0, 8);
      container.innerHTML =
        '<div style="display:flex; align-items:center; gap:8px; background:#161b22; border:1px solid #30363d; border-radius:6px; padding:6px 12px;">' +
          '<span style="color:#3fb950; font-size:10px;">\\u25CF</span>' +
          '<a href="' + basePath + '/profile" style="color:#c9d1d9; text-decoration:none;">' + abbrev + '...</a>' +
          '<button onclick="signOut()" style="background:none; border:none; color:#8b949e; cursor:pointer; font-size:11px; padding:2px 4px;" title="Sign out">\\u2715</button>' +
        '</div>';
    } else {
      // Anonymous — show sign-in button
      container.innerHTML =
        '<button onclick="showSignInModal()" style="background:#238636; color:white; border:none; padding:6px 14px; border-radius:6px; cursor:pointer; font-size:13px; font-weight:500;">Sign In</button>';
    }

    document.body.appendChild(container);
  }

  /**
   * Initialize identity system — anonymous by default
   */
  window.identityReady = (async function() {
    try {
      const existing = localStorage.getItem(STORAGE_KEY);
      if (existing) {
        // Existing user — restore session seamlessly
        setupHeaderInjection(existing);
        // Wait for ed25519 before fetching attestation
        ed25519Ready.then(loaded => {
          if (loaded) {
            setTimeout(() => window.fetchAttestation(), 500);
          }
        });
        console.log('[Identity] Restored existing identity');
      } else {
        console.log('[Identity] Anonymous browsing (no identity)');
      }

      // Render sign-in button or user badge
      if (document.readyState === 'loading') {
        document.addEventListener('DOMContentLoaded', renderIdentityUI);
      } else {
        renderIdentityUI();
      }

      return !!existing;
    } catch (err) {
      console.error('[Identity] Init error:', err);
      return false;
    }
  })();

  console.log('[Identity] System initialized');
})();
</script>
  `;
}

/**
 * Render identity management UI (for settings page)
 */
export function renderIdentityManager() {
  return `
<div class="identity-manager">
  <style>
    .identity-manager {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 6px;
      padding: 20px;
      margin: 20px 0;
    }
    .identity-manager h3 {
      margin: 0 0 15px 0;
      color: #58a6ff;
    }
    .identity-list {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    .identity-item {
      background: #0d1117;
      padding: 15px;
      border-radius: 4px;
      border: 1px solid #30363d;
    }
    .identity-method {
      font-weight: bold;
      color: #58a6ff;
      margin-bottom: 5px;
    }
    .identity-id {
      font-family: monospace;
      font-size: 11px;
      color: #8b949e;
    }
    .add-identity-btn {
      background: #238636;
      color: white;
      border: none;
      padding: 10px 20px;
      border-radius: 6px;
      cursor: pointer;
      margin-top: 15px;
    }
    .add-identity-btn:hover {
      background: #2ea043;
    }
  </style>

  <h3>Your Identities</h3>
  <div id="identity-list" class="identity-list">
    <div style="color: #8b949e;">Loading...</div>
  </div>

  <button class="add-identity-btn" onclick="linkSSHKey()">
    🔑 Link SSH Key
  </button>
</div>

<script>
function getBasePath() {
  return window.__PATH_PREFIX__ || '';
}

async function loadIdentities() {
  try {
    const basePath = getBasePath();
    const response = await fetch(basePath + '/api/user/identities');

    if (!response.ok) {
      document.getElementById('identity-list').innerHTML =
        '<div style="color: #f85149;">Not authenticated</div>';
      return;
    }

    const data = await response.json();
    const list = document.getElementById('identity-list');

    if (data.identities.length === 0) {
      list.innerHTML = '<div style="color: #8b949e;">No identities linked</div>';
      return;
    }

    list.innerHTML = data.identities.map(id => \`
      <div class="identity-item">
        <div class="identity-method">\${getMethodName(id.method)}</div>
        <div class="identity-id">\${id.identityId}</div>
        <div style="font-size: 12px; color: #8b949e; margin-top: 5px;">
          Created: \${new Date(id.createdAt).toLocaleString()}
        </div>
      </div>
    \`).join('');
  } catch (err) {
    console.error('Failed to load identities:', err);
    document.getElementById('identity-list').innerHTML =
      '<div style="color: #f85149;">Failed to load identities</div>';
  }
}

function getMethodName(method) {
  const names = {
    'web-identity': '🌐 Browser Identity',
    'bearer-token': '🔑 API Token',
    'ssh-key': '🔐 SSH Key',
    'keet-identity': '💳 Keet Identity'
  };
  return names[method] || method;
}

async function linkSSHKey() {
  const sshKey = prompt('Paste your SSH public key (ssh-rsa ...):');
  if (!sshKey) return;

  try {
    const basePath = getBasePath();
    const response = await fetch(basePath + '/api/user/link-ssh-key', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ sshPublicKey: sshKey })
    });

    if (!response.ok) {
      const error = await response.json();
      alert('Failed to link SSH key: ' + error.error);
      return;
    }

    alert('✓ SSH key linked successfully!');
    loadIdentities();
  } catch (err) {
    console.error('Failed to link SSH key:', err);
    alert('Failed to link SSH key: ' + err.message);
  }
}

// Load on page load
loadIdentities();
</script>
`;
}