OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / static / pgv3-bootstrap.js

218 lines · javascript ref:
/**
 * pgv3 page bootstrap.
 *
 * Runs on every SSR page load. Hydrates the "you" badge and admin nav
 * link from the current envelope-signed identity.
 *
 * **Phase 2a migration:** identity is established by calling whoami via
 * a per-call signed envelope (signed-rpc.js). The browser keypair
 * survives in localStorage, so the user never sees a "logged out"
 * transition between sessions. Previous implementation used a
 * challenge + verify + session cookie dance; that path is retained in
 * browser-identity.js as a fallback for daemons without /api/rpc but is
 * no longer the primary flow.
 *
 * Sign-in is still explicit: the anon "Sign In" button in the nav calls
 * window.pgv3SignIn() on click. The only reason this exists (vs auto-
 * signing on every page load) is that the FIRST sign-in also registers
 * the user server-side via TOFU, and some visitors may want to read
 * public content without getting their keypair auto-registered.
 *
 * On a fresh identity (first-time sign-in for this browser's keypair),
 * the welcome modal opens asking the user to set a display name +
 * optional profile picture.
 *
 * Exposes the result on `window.__pgv3`:
 *   {
 *     publicKeyHex: 'hex' | null
 *     me:           { publicKey, userId, trust, ... } | null
 *   }
 */

import { signRpc, getPublicKeyHex, getKeypair, clearIdentity } from './signed-rpc.js'

const __prefix = (document.body && document.body.dataset.prefix) || ''
window.__pgv3 = { publicKeyHex: null, me: null, prefix: __prefix }

// ── Hydration: populate badge + admin link from envelope-signed whoami ──
async function hydrateIdentity () {
  // Has the user chosen to sign in on this browser? The signal is whether
  // a keypair exists in localStorage. No keypair = anonymous visitor; do
  // nothing, leave the Sign In button visible.
  let hasKeypair = false
  try { hasKeypair = typeof localStorage !== 'undefined' && !!localStorage.getItem('pgv3-keypair') } catch {}
  if (!hasKeypair) return

  // Envelope path: call whoami through /api/rpc with a signed envelope.
  // The server TOFU-registers the user if new, returns profile info.
  let rpcResult = null
  try {
    rpcResult = await signRpc('whoami', {})
  } catch (err) {
    // /api/rpc rejected the envelope (stale keypair? deprecated daemon?
    // network?). Leave sign-in UI visible.
    return
  }

  const me = rpcResult && rpcResult.result
  if (!me || !me.publicKey) return

  window.__pgv3.me = me
  window.__pgv3.publicKeyHex = me.publicKey

  // Admin link visibility
  if (me.trust === 'owner' || me.trust === 'local') {
    const el = document.getElementById('admin-nav')
    if (el) el.style.display = ''
  }

  // Hydrate the badge with display name + pfp from get-profile
  if (me.userId) {
    try {
      const profileResp = await signRpc('get-profile', { userId: me.userId })
      const user = profileResp && profileResp.result && profileResp.result.user
      if (user) {
        const label = (user.name && user.name !== 'Anonymous')
          ? user.name
          : ('you: ' + me.publicKey.slice(0, 8) + '…')
        const labelSlot = document.getElementById('you-label-slot')
        if (labelSlot) labelSlot.textContent = label
        if (user.profilePicture) {
          const avatarSlot = document.getElementById('you-avatar-slot')
          if (avatarSlot) {
            avatarSlot.innerHTML = '<img src="' + __prefix + '/files/raw?path=' +
              encodeURIComponent('/attachments/' + user.profilePicture) +
              '" style="width:20px;height:20px;border-radius:50%;vertical-align:middle;margin-right:4px;object-fit:cover" alt="">'
          }
        }
        const badge = document.getElementById('you-badge')
        if (badge) badge.classList.remove('shimmer')
      }
    } catch {}
    // Ensure shimmer is removed even if profile fetch fails
    const badgeFallback = document.getElementById('you-badge')
    if (badgeFallback) badgeFallback.classList.remove('shimmer')
  }
}

// ── Explicit sign-in handler wired to the "Sign In" button ──
window.pgv3SignIn = async function pgv3SignIn () {
  const btn = document.getElementById('signin-btn')
  if (btn) {
    btn.disabled = true
    btn.textContent = 'Signing in…'
  }

  try {
    // Generate/load keypair + call whoami. Server TOFU-registers on
    // first call. The `identity.newUser` flag in the response tells us
    // whether this is a fresh registration.
    await getKeypair()
    const rpcResult = await signRpc('whoami', {})

    const isNewUser = rpcResult && rpcResult.identity && rpcResult.identity.newUser

    if (isNewUser) {
      showWelcomeModal()
    } else {
      try {
        sessionStorage.setItem('pgv3-flash-deferred', 'success:Signed in')
      } catch {}
      window.location.reload()
    }
  } catch (err) {
    // eslint-disable-next-line no-console
    console.error('[pgv3] sign-in failed:', err)
    if (btn) {
      btn.disabled = false
      btn.textContent = 'Sign In'
      btn.title = (err && err.message) || 'sign-in failed'
    }
  }
}

// ── Welcome modal (shown after fresh sign-in) ──
function showWelcomeModal () {
  const modal = document.getElementById('welcome-modal')
  if (!modal) {
    window.location.reload()
    return
  }
  modal.classList.add('visible')

  const form = document.getElementById('welcome-form')
  const skipBtn = document.getElementById('welcome-skip')
  const errEl = document.getElementById('welcome-error')

  const finish = () => { window.location.reload() }

  if (skipBtn) skipBtn.addEventListener('click', finish, { once: true })

  if (form) {
    form.addEventListener('submit', async (ev) => {
      ev.preventDefault()
      if (errEl) errEl.textContent = ''
      const submitBtn = form.querySelector('button[type="submit"]')
      if (submitBtn) {
        submitBtn.disabled = true
        submitBtn.textContent = 'Saving…'
      }

      try {
        const nameInput = form.querySelector('input[name="name"]')
        const fileInput = form.querySelector('input[name="picture"]')
        const name = nameInput ? nameInput.value.trim() : ''
        let profilePicture = null

        if (fileInput && fileInput.files && fileInput.files[0]) {
          const file = fileInput.files[0]
          // File upload goes through the raw /api/upload route (not the
          // envelope RPC). That route was hardened to require an authenticated
          // session over the network (router.js: non-uds requests without
          // ctx.identity are rejected 401), so we MUST send the session cookie
          // that the whoami sign-in above just minted — credentials:'same-origin'
          // (matches pgv3-upload.js + profile.js). 'omit' here 401s the upload
          // even for a freshly signed-in owner (the initial-TOFU avatar bug).
          // TODO: migrate to the envelope RPC once lib/handlers/files.js does.
          const upload = await fetch(__prefix + '/api/upload', {
            method: 'POST',
            headers: { 'Content-Type': file.type || 'application/octet-stream' },
            credentials: 'same-origin',
            body: file
          })
          if (!upload.ok) throw new Error('upload failed: ' + upload.status)
          const body = await upload.json()
          profilePicture = body.hash
        }

        const updates = {}
        if (name) updates.name = name
        if (profilePicture) updates.profilePicture = profilePicture

        if (updates.name || updates.profilePicture) {
          await signRpc('update-profile', updates)
        }

        finish()
      } catch (err) {
        if (errEl) errEl.textContent = (err && err.message) || 'something went wrong'
        if (submitBtn) {
          submitBtn.disabled = false
          submitBtn.textContent = 'Save'
        }
      }
    })
  }
}

// Expose a "sign out" helper for UI buttons that want it.
window.pgv3SignOut = function pgv3SignOut () {
  clearIdentity()
  window.__pgv3.me = null
  window.__pgv3.publicKeyHex = null
  window.location.reload()
}

// Run hydration on page load
hydrateIdentity()