OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git-v3 / lib / web / pages / profile.js

890 lines · javascript ref:
/**
 * User profile pages.
 *
 *   GET /profile         → current user (status + perms + linked SSH + keypair backup + edit form)
 *   GET /profile/:pubkey → another user's public profile
 *   GET /users           → list all users
 *
 * Backed by IdentityRegistry (F.6). Profile edit (name + picture) goes
 * through the `update-profile` RPC handler; picture upload uses the
 * existing `/api/upload` → hyper-overlay flow.
 */

import { layout, escapeHtml, identicon } from '../helpers.js'
import { listLinkedSSHKeys } from '../../auth/ssh-identity.js'
import { isAdmin as viewerIsAdmin } from '../../auth/check.js'
import { listUserPushes } from '../../services/push-index.js'
import { listUserIssues } from '../../services/issue-index.js'

// Relative "time ago" for activity timestamps (ms epoch).
function ago (ms) {
  if (!ms) return ''
  const s = Math.floor((Date.now() - ms) / 1000)
  if (s < 60) return 'just now'
  const m = Math.floor(s / 60); if (m < 60) return m + 'm ago'
  const h = Math.floor(m / 60); if (h < 24) return h + 'h ago'
  const d = Math.floor(h / 24); if (d < 30) return d + 'd ago'
  const mo = Math.floor(d / 30); if (mo < 12) return mo + 'mo ago'
  return Math.floor(mo / 12) + 'y ago'
}

const PROFILE_STYLES = `
<style>
  .user-status { background: #161b22; border: 2px solid #30363d; border-radius: 6px; padding: 20px; margin-bottom: 20px; }
  .user-status.owner { border-color: #238636; }
  .user-status.writer { border-color: #58a6ff; }
  .user-status-header { display: flex; align-items: center; gap: 15px; margin-bottom: 10px; }
  .user-status-body { color: #8b949e; font-size: 0.85rem; line-height: 1.6; }
  .user-status-name { font-size: 1.15rem; color: #c9d1d9; font-weight: 600; font-family: monospace; }
  .user-status-displayname { font-size: 1.25rem; color: #c9d1d9; font-weight: 600; }
  .user-status-id { color: #8b949e; font-size: 0.75rem; font-family: monospace; word-break: break-all; margin-top: 4px; }
  .status-badge { padding: 6px 12px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; }
  .status-badge.owner { background: #238636; color: white; }
  .status-badge.writer { background: #58a6ff; color: white; }
  .status-badge.read { background: #30363d; color: #c9d1d9; }

  .profile-avatar { width: 72px; height: 72px; border-radius: 50%; object-fit: cover; border: 2px solid #30363d; background: #0d1117; }

  .perm-table { width: 100%; border-collapse: collapse; }
  .perm-table th { text-align: left; padding: 0.6rem 0.6rem; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 600; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.5px; }
  .perm-table td { padding: 0.6rem 0.6rem; border-bottom: 1px solid #21262d; }
  .perm-table tr:last-child td { border-bottom: none; }
  .perm-badge { display: inline-block; padding: 2px 8px; margin-right: 4px; border-radius: 12px; font-size: 0.7rem; font-weight: 500; }
  .perm-badge.admin { background: #238636; color: white; }
  .perm-badge.write { background: #1f6feb; color: white; }
  .perm-badge.read { background: #6e7681; color: white; }

  .ssh-list { display: flex; flex-direction: column; gap: 8px; }
  .ssh-item { display: flex; align-items: center; gap: 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 10px 14px; }
  .ssh-fp { font-family: monospace; font-size: 0.78rem; color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .ssh-comment { color: #8b949e; font-size: 0.75rem; }

  .keypair-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 0.8rem; }
  .keypair-warning { background: #f8514922; border: 1px solid #f85149; border-radius: 6px; padding: 12px; font-size: 0.8rem; color: #ffc1c1; }
  .keypair-warning strong { color: #f85149; }
  .edit-form-row { display: flex; flex-direction: column; gap: 4px; margin-bottom: 12px; }
  .edit-form-row label { color: #8b949e; font-size: 0.75rem; }
  .avatar-preview-wrap { margin-top: 8px; min-height: 72px; }
</style>`

function timeAgo (ms) {
  if (!ms) return 'unknown'
  const s = Math.floor((Date.now() - ms) / 1000)
  if (s < 60) return 'just now'
  const m = Math.floor(s / 60)
  if (m < 60) return m + 'm ago'
  const h = Math.floor(m / 60)
  if (h < 24) return h + 'h ago'
  const d = Math.floor(h / 24)
  if (d < 30) return d + 'd ago'
  const mo = Math.floor(d / 30)
  if (mo < 12) return mo + 'mo ago'
  return Math.floor(mo / 12) + 'y ago'
}

/**
 * Summarise a User-Agent into a short browser+OS label. Best-effort — we
 * only care enough to help users disambiguate "Firefox on Linux" vs
 * "Safari on iOS". Full UA string is still in the underlying record.
 */
function uaSummary (ua) {
  if (!ua) return null
  const browser =
    /Firefox\//i.test(ua) ? 'Firefox' :
    /Edg\//i.test(ua) ? 'Edge' :
    /Chrome\//i.test(ua) ? 'Chrome' :
    /Safari\//i.test(ua) && !/Chrome/i.test(ua) ? 'Safari' :
    /curl/i.test(ua) ? 'curl' :
    /git\//i.test(ua) ? 'git' :
    null
  const os =
    /Android/i.test(ua) ? 'Android' :
    /iPhone|iPad|iOS/i.test(ua) ? 'iOS' :
    /Mac OS X/i.test(ua) ? 'macOS' :
    /Linux/i.test(ua) ? 'Linux' :
    /Windows/i.test(ua) ? 'Windows' :
    null
  if (browser && os) return browser + ' on ' + os
  if (browser) return browser
  if (os) return os
  return null
}

/**
 * Render one linked-identity row on /profile. Shows origin (Host header
 * captured on creation), a UA summary, and a last-used timestamp.
 * Marks the current session's identity with a "this device" badge.
 */
function renderIdentityRow (i, myPubkey) {
  const isMe = myPubkey && i.identityId === myPubkey
  const ua = uaSummary(i.firstSeenUserAgent)
  const createdAt = i.firstSeenAt || i.createdAt
  const method = i.method || 'web-identity'
  const methodBadge =
    method === 'ssh-key' ? '<span class="badge badge-user" style="margin-left:0.4rem">ssh</span>' :
    method === 'web-identity' ? '<span class="badge badge-group" style="margin-left:0.4rem">browser</span>' :
    '<span class="badge badge-user" style="margin-left:0.4rem">' + escapeHtml(method) + '</span>'

  // Origin label: explicit Host for browser identities; SSH keys came in
  // from the daemon's filesystem auto-import, not an HTTP request, so
  // they get a different label.
  let originLabel
  if (i.firstSeenOrigin) {
    originLabel = escapeHtml(i.firstSeenOrigin)
  } else if (method === 'ssh-key') {
    originLabel = '<span class="meta">imported from ~/.ssh/</span>'
  } else {
    originLabel = '<span class="meta">unknown origin</span>'
  }

  // Show the identity ID (publicKey hex) prominently — that's the
  // user-visible handle for "which keypair is this".
  const idShort = (i.identityId || '').slice(0, 16)
  const idFull = i.identityId || ''

  return `
  <div class="ssh-item"${isMe ? ' style="border-color:#3fb950"' : ''}>
    ${identicon(i.identityId || 'unknown', 32)}
    <div style="flex:1;min-width:0">
      <div class="ssh-fp" title="${escapeHtml(idFull)}">
        ${escapeHtml(idShort)}<span class="meta">…</span>${methodBadge}${isMe ? '<span class="badge badge-open" style="margin-left:0.4rem">this device</span>' : ''}
      </div>
      <div class="ssh-comment">
        ${originLabel}${ua ? ' · ' + escapeHtml(ua) : ''} · created ${timeAgo(createdAt)}${i.lastSeenAt ? ' · last used ' + timeAgo(i.lastSeenAt) : ''}
      </div>
    </div>
  </div>`
}

function pictureSrc (hash, pfx) {
  if (!hash) return null
  return (pfx || '') + '/files/raw?path=' + encodeURIComponent('/attachments/' + hash)
}

export async function renderProfile (svc, ctx) {
  const prefix = (ctx && ctx.prefix) || ''
  const identity = ctx && ctx.identity
  const isAuthed = identity && identity.publicKey
  const daemonId = svc.identity

  // Registry-backed lookup: session publicKey → User + linked identities
  let user = null
  let identities = []
  let linkedSSH = []
  if (isAuthed) {
    try {
      user = await svc.identityRegistry.getUserByIdentity(identity.publicKey)
    } catch {}
    if (user) {
      try {
        const wrapped = await svc.identityRegistry.getUserWithIdentities(user.userId)
        identities = (wrapped && wrapped.identities) || []
      } catch {}
      try {
        linkedSSH = await listLinkedSSHKeys(svc, user.userId)
      } catch {}
    }
  }

  const perms = (user && user.globalPermissions) || []
  const isAdmin = perms.includes('admin')
  const isWriter = perms.includes('write')
  let statusClass = 'read'
  let badgeHtml = '<span class="status-badge read">Read only</span>'
  if (isAdmin) {
    statusClass = 'owner'
    badgeHtml = '<span class="status-badge owner">Owner</span>'
  } else if (isWriter) {
    statusClass = 'writer'
    badgeHtml = '<span class="status-badge writer">Writer</span>'
  }

  const displayName = (user && user.name && user.name !== 'Anonymous') ? user.name : null
  const avatarUrl = user && user.profilePicture ? pictureSrc(user.profilePicture, prefix) : null
  const uid = user && user.userId
  const pushes = uid ? listUserPushes(uid) : []
  const issuesPosted = uid ? listUserIssues(uid, 10) : []

  const body = `
${PROFILE_STYLES}
<h1>profile</h1>

${isAuthed && user ? `
<div class="user-status ${statusClass}">
  <div class="user-status-header">
    ${avatarUrl
      ? `<img class="profile-avatar" src="${escapeHtml(avatarUrl)}" alt="${escapeHtml(displayName || identity.publicKey.slice(0, 8))}'s avatar">`
      : identicon(identity.publicKey, 72)}
    <div style="flex:1;min-width:0">
      ${displayName ? `<div class="user-status-displayname">${escapeHtml(displayName)}</div>` : ''}
      <div class="user-status-name">${escapeHtml(identity.publicKey.slice(0, 16))}…</div>
      <div class="user-status-id">${escapeHtml(identity.publicKey)}</div>
    </div>
    ${badgeHtml}
  </div>
  <div class="user-status-body">
    Your identity lives in this browser. ${isAdmin ? 'You have full admin access.' : isWriter ? 'You can create issues, orgs, and modify content.' : 'You can browse public content. Ask an owner for writer access.'}
  </div>
  <div style="margin-top:1rem">
    <a href="${prefix}/profile/${escapeHtml(identity.publicKey)}" class="btn-secondary" style="display:inline-block;margin-right:0.5rem;text-decoration:none">View public profile →</a>
    <form action="${prefix}/api/auth/logout" method="POST" style="display:inline">
      <button class="btn-secondary" type="submit">Log out</button>
    </form>
  </div>
</div>

<div class="card">
  <h2>Repos pushed (${pushes.length})</h2>
  ${pushes.length === 0 ? '<div class="empty">No pushes recorded yet.</div>' : `
  <div class="ssh-list">
    ${pushes.slice(0, 30).map(p => `
    <div class="ssh-item">
      <div style="flex:1;min-width:0">
        <div class="repo-name"><a href="${prefix}/repos/${encodeURIComponent(p.repo)}">${escapeHtml(p.repo)}</a></div>
        <div class="ssh-comment">${p.count} push${p.count === 1 ? '' : 'es'} · last ${escapeHtml(ago(p.lastPush))}</div>
      </div>
    </div>`).join('')}
  </div>`}
</div>

<div class="card">
  <h2>Recent issues (${issuesPosted.length})</h2>
  ${issuesPosted.length === 0 ? '<div class="empty">No issues posted yet.</div>' : `
  <div class="ssh-list">
    ${issuesPosted.map(it => {
      const base = it.scope === 'org' ? `${prefix}/orgs/${encodeURIComponent(it.name)}/issues/${it.id}` : `${prefix}/repos/${encodeURIComponent(it.name)}/issues/${it.id}`
      return `
    <div class="ssh-item">
      <div style="flex:1;min-width:0">
        <div class="repo-name"><a href="${base}">${escapeHtml(it.title || 'issue #' + it.id)}</a></div>
        <div class="ssh-comment">${escapeHtml(it.scope)} ${escapeHtml(it.name)} · #${it.id} · <span class="badge ${it.status === 'closed' ? 'badge-closed' : 'badge-open'}">${escapeHtml(it.status)}</span> · ${escapeHtml(ago(it.createdAt))}</div>
      </div>
    </div>`
    }).join('')}
  </div>`}
</div>

<div class="card">
  <h2>Edit profile</h2>
  <form id="pgv3-profile-form" onsubmit="return pgv3SaveProfile(event)">
    <div class="edit-form-row">
      <label for="pgv3-name">Display name</label>
      <input id="pgv3-name" type="text" maxlength="50" value="${escapeHtml(user.name && user.name !== 'Anonymous' ? user.name : '')}" placeholder="Your name">
    </div>
    <div class="edit-form-row">
      <label for="pgv3-picture">Profile picture</label>
      <input id="pgv3-picture" type="file" accept="image/png,image/jpeg,image/webp,image/gif">
      <div class="avatar-preview-wrap" id="pgv3-preview">
        ${avatarUrl ? `<img class="profile-avatar" src="${escapeHtml(avatarUrl)}" alt="current">` : ''}
      </div>
    </div>
    <input type="hidden" id="pgv3-picture-hash" value="${escapeHtml(user.profilePicture || '')}">
    <button type="submit">Save</button>
    <span id="pgv3-save-status" class="meta" style="margin-left:0.8rem"></span>
  </form>

  <script>
  (function(){
    var fileInput = document.getElementById('pgv3-picture')
    var preview = document.getElementById('pgv3-preview')
    var hashField = document.getElementById('pgv3-picture-hash')
    var status = document.getElementById('pgv3-save-status')

    async function uploadPicture (file) {
      status.textContent = 'uploading…'
      var res = await fetch('${prefix}/api/upload', {
        method: 'POST',
        headers: { 'Content-Type': 'application/octet-stream' },
        body: file,
        credentials: 'include'
      })
      if (!res.ok) throw new Error('upload failed: ' + res.status)
      var j = await res.json()
      return j.hash
    }

    fileInput && fileInput.addEventListener('change', async function(){
      var file = fileInput.files && fileInput.files[0]
      if (!file) return
      if (file.size > 2 * 1024 * 1024) {
        status.textContent = 'image too large (max 2 MB)'
        fileInput.value = ''
        return
      }
      try {
        var hash = await uploadPicture(file)
        hashField.value = hash
        var url = URL.createObjectURL(file)
        preview.innerHTML = '<img class="profile-avatar" src="' + url + '" alt="preview">'
        status.textContent = 'uploaded ✓'
      } catch (err) {
        status.textContent = 'error: ' + err.message
      }
    })

    window.pgv3SaveProfile = async function(ev) {
      ev.preventDefault()
      status.textContent = 'saving…'
      var form = ev.target
      // Opt this form into the pgv3-forms.js AJAX contract so submit
      // buttons re-enable on error (and stay locked on success until
      // the reload fires).
      form.dataset.pgv3Ajax = '1'
      var name = document.getElementById('pgv3-name').value.trim()
      var pictureHash = hashField.value || null
      try {
        var res = await fetch('${prefix}/profile/update', {
          method: 'POST',
          headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
          body: new URLSearchParams({ name: name, profilePicture: pictureHash || '' }).toString(),
          credentials: 'include'
        })
        if (!res.ok) throw new Error('save failed: ' + res.status)
        status.textContent = 'saved ✓ — reloading'
        // Don't unlock — the imminent reload replaces the whole page.
        setTimeout(function(){ window.location.reload() }, 400)
      } catch (err) {
        status.textContent = 'error: ' + err.message
        // Re-enable the submit button so the user can retry without
        // a page reload.
        form.dispatchEvent(new CustomEvent('pgv3:done'))
      }
      return false
    }
  })()
  </script>
</div>

<div class="card">
  <h2>Linked identities (${identities.length})</h2>
  <div class="meta" style="margin-bottom:0.6rem">
    Each identity is a keypair linked to this account. Different browsers / origins / devices get different identities; all of them share the same User record (profile, perms, org memberships).
  </div>
  ${identities.length === 0 ? '<div class="empty">No identities linked — this is unexpected; try signing out and back in.</div>' : `
  <div class="ssh-list">
    ${identities.map(i => renderIdentityRow(i, identity && identity.publicKey)).join('')}
  </div>`}
</div>

<div class="card">
  <h2>Link another device</h2>
  <div class="meta" style="margin-bottom:0.6rem">
    On a new browser or different origin (e.g. accessing the daemon directly at <code>tabertron.local</code> instead of through a reverse proxy), generate a token here, then paste it into the next device's "Link to existing account" form. Both browsers will then share this account.
  </div>
  <div class="hstack" style="gap:0.6rem">
    <button type="button" id="gen-link-token-btn" onclick="window.pgv3GenerateLinkToken && window.pgv3GenerateLinkToken()">Generate link token</button>
    <span id="gen-link-token-status" class="meta"></span>
  </div>
  <div id="gen-link-token-display" style="display:none;margin-top:0.8rem">
    <code id="gen-link-token-text" style="font-size:0.9rem;padding:0.5rem 0.7rem;display:inline-block;user-select:all"></code>
    <button type="button" class="btn-secondary" style="margin-left:0.4rem" onclick="window.pgv3CopyLinkToken && window.pgv3CopyLinkToken()">copy</button>
    <div class="meta" style="margin-top:0.4rem">Expires in <span id="gen-link-token-expires">5:00</span>. Single-use. Do not share publicly — anyone with this token can attach a new identity to your account.</div>
  </div>
</div>

<div class="card">
  <h2>Link this device to an existing account</h2>
  <div class="meta" style="margin-bottom:0.6rem">
    Already have an account on another browser? Generate a token there, then paste it here. This browser's identity will be added to that account, and the throwaway account on this browser will be deactivated.
  </div>
  <form onsubmit="return window.pgv3ConsumeLinkToken && window.pgv3ConsumeLinkToken(event)" class="hstack" style="gap:0.5rem">
    <input id="consume-link-token-input" placeholder="paste link token" style="flex:1;min-width:0;font-family:monospace;font-size:0.85rem">
    <button type="submit">Link</button>
  </form>
  <div id="consume-link-token-status" class="meta" style="margin-top:0.4rem"></div>
</div>

<script>
(function(){
  var _pfx = document.body.dataset.prefix || ''
  // Generate-token flow
  window.pgv3GenerateLinkToken = async function () {
    const btn = document.getElementById('gen-link-token-btn')
    const status = document.getElementById('gen-link-token-status')
    const display = document.getElementById('gen-link-token-display')
    const text = document.getElementById('gen-link-token-text')
    const expiresEl = document.getElementById('gen-link-token-expires')
    if (!btn || !status || !display || !text || !expiresEl) return
    btn.disabled = true
    status.textContent = 'generating…'
    try {
      const res = await fetch(_pfx + '/rpc/generate-link-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: '{}'
      })
      if (!res.ok) throw new Error('HTTP ' + res.status)
      const payload = await res.json()
      const token = (payload && payload.result && payload.result.token) || null
      const expiresAt = (payload && payload.result && payload.result.expiresAt) || (Date.now() + 5 * 60 * 1000)
      if (!token) throw new Error('no token returned')
      text.textContent = token
      display.style.display = ''
      status.textContent = ''
      // Tick down the expiry display
      const tick = () => {
        const remaining = Math.max(0, expiresAt - Date.now())
        if (remaining <= 0) {
          expiresEl.textContent = 'expired'
          btn.disabled = false
          return
        }
        const m = Math.floor(remaining / 60000)
        const s = Math.floor((remaining % 60000) / 1000)
        expiresEl.textContent = m + ':' + String(s).padStart(2, '0')
        setTimeout(tick, 1000)
      }
      tick()
    } catch (err) {
      status.textContent = 'failed: ' + (err.message || err)
      btn.disabled = false
    }
  }

  window.pgv3CopyLinkToken = function () {
    const text = document.getElementById('gen-link-token-text')
    if (!text) return
    const sel = window.getSelection()
    const range = document.createRange()
    range.selectNodeContents(text)
    sel.removeAllRanges()
    sel.addRange(range)
    try { document.execCommand('copy') } catch {}
    if (navigator.clipboard) { try { navigator.clipboard.writeText(text.textContent) } catch {} }
  }

  // Consume-token flow
  window.pgv3ConsumeLinkToken = async function (ev) {
    if (ev) ev.preventDefault()
    const input = document.getElementById('consume-link-token-input')
    const status = document.getElementById('consume-link-token-status')
    if (!input || !status) return false
    const token = input.value.trim()
    if (!token) { status.textContent = 'paste a token first'; return false }
    status.textContent = 'linking…'
    try {
      const res = await fetch(_pfx + '/rpc/consume-link-token', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify({ token })
      })
      if (!res.ok) {
        const body = await res.text().catch(() => '')
        throw new Error(body || ('HTTP ' + res.status))
      }
      const payload = await res.json()
      if (payload && payload.error) throw new Error(payload.error)
      status.textContent = 'linked ✓ — reloading'
      setTimeout(() => window.location.reload(), 600)
    } catch (err) {
      status.textContent = 'failed: ' + (err.message || err)
    }
    return false
  }
})()
</script>

<div class="card">
  <h2>Linked SSH keys (${linkedSSH.length})</h2>
  <div class="meta" style="margin-bottom:0.6rem">SSH keys from <code>~/.ssh/</code> auto-imported with your permissions. Let you <code>git push</code> over punch:// without re-entering credentials.</div>
  ${linkedSSH.length === 0 ? '<div class="empty">No SSH keys linked.</div>' : `
  <div class="ssh-list">
    ${linkedSSH.map(k => `
    <div class="ssh-item">
      ${identicon((k.fingerprint || '').replace('SHA256:', ''), 32)}
      <div style="flex:1;min-width:0">
        <div class="ssh-fp">${escapeHtml(k.fingerprint || '(no fingerprint)')}<span class="badge badge-group" style="margin-left:0.4rem">${escapeHtml(k.type || '?')}</span></div>
        <div class="ssh-comment">${k.comment ? escapeHtml(k.comment) + ' · ' : ''}${k.path ? escapeHtml(k.path) : ''}</div>
      </div>
    </div>`).join('')}
  </div>`}
  <form id="add-ssh-form" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:flex-start;margin-top:0.9rem">
    <textarea id="add-ssh-input" placeholder="ssh-ed25519 AAAA… you@host" rows="2" style="flex:1;min-width:260px;font-family:monospace;font-size:0.78rem"></textarea>
    <button type="submit" class="btn">Add SSH key</button>
  </form>
  <div class="meta" style="margin-top:0.4rem">Paste a public key (e.g. <code>~/.ssh/id_ed25519.pub</code>). It links to your account; you then <code>git push</code> over SSH with the matching private key.</div>
  <div id="add-ssh-status" class="meta" style="margin-top:0.3rem"></div>
</div>

<div class="card">
  <h2>Access tokens</h2>
  <div class="meta" style="margin-bottom:0.6rem">Personal access tokens let you <code>git push</code> / clone over <strong>HTTPS</strong> — use the token as your password (any username). A token inherits your permissions; a <code>read</code> token can clone but never push.</div>

  <form id="pat-form" style="display:flex;gap:0.5rem;flex-wrap:wrap;align-items:center;margin-bottom:0.8rem">
    <input id="pat-name" type="text" placeholder="name (e.g. laptop)" maxlength="64" style="flex:1;min-width:140px" />
    <select id="pat-scope">
      <option value="write">write</option>
      <option value="read">read-only</option>
    </select>
    <select id="pat-ttl">
      <option value="90">90 days</option>
      <option value="30">30 days</option>
      <option value="365">1 year</option>
      <option value="0">no expiry</option>
    </select>
    <button type="submit">Generate token</button>
  </form>

  <div id="pat-new" style="display:none;margin-bottom:0.8rem">
    <div class="meta" style="margin-bottom:0.3rem;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px;color:#d29922">⚠ Copy now — shown only once</div>
    <div style="display:flex;gap:0.5rem;align-items:center">
      <code id="pat-secret" class="mono" style="flex:1;word-break:break-all;padding:0.5rem 0.7rem;background:#0d1117;border:1px solid #30363d;border-radius:4px"></code>
      <button type="button" class="btn-secondary" onclick="pgv3CopyToken()">copy</button>
    </div>
  </div>

  <div id="pat-status" class="meta" style="font-size:0.8rem;margin-bottom:0.4rem"></div>
  <div id="pat-list"></div>

  <script>
  (function(){
    var _pfx = document.body.dataset.prefix || ''
    var listEl = document.getElementById('pat-list')
    var statusEl = document.getElementById('pat-status')

    function esc(s){
      return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){
        return { '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]
      })
    }
    function fmtDate(ms){
      if (!ms) return 'no expiry'
      try { return new Date(ms).toISOString().slice(0,10) } catch (e) { return '' }
    }
    function rpc(name, body){
      return fetch(_pfx + '/rpc/' + name, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        credentials: 'include',
        body: JSON.stringify(body || {})
      }).then(function(res){
        return res.json().catch(function(){ return {} }).then(function(p){
          if (!res.ok || (p && p.error)) throw new Error((p && p.error) || ('HTTP ' + res.status))
          return (p && p.result) || {}
        })
      })
    }
    function render(tokens){
      tokens = tokens || []
      if (tokens.length === 0){ listEl.innerHTML = '<div class="empty">No active tokens.</div>'; return }
      var rows = tokens.map(function(t){
        return '<div class="ssh-item" style="justify-content:space-between">' +
          '<div style="flex:1;min-width:0">' +
            '<div class="ssh-fp">' + esc(t.name) + ' <span class="badge badge-group" style="margin-left:0.3rem">' + esc(t.scope) + '</span></div>' +
            '<div class="ssh-comment">expires ' + esc(fmtDate(t.expiresAt)) + ' · created ' + esc(fmtDate(t.createdAt)) + '</div>' +
          '</div>' +
          '<button type="button" class="btn-secondary" data-revoke="' + esc(t.identityId) + '">revoke</button>' +
        '</div>'
      }).join('')
      listEl.innerHTML = '<div class="ssh-list">' + rows + '</div>'
    }
    function load(){
      rpc('list-access-tokens', {}).then(function(r){ render(r.tokens) })
        .catch(function(err){ statusEl.textContent = 'could not load tokens: ' + (err.message || err) })
    }
    document.getElementById('pat-form').addEventListener('submit', function(ev){
      ev.preventDefault()
      statusEl.textContent = 'generating…'
      var name = (document.getElementById('pat-name').value || '').trim() || 'token'
      var scope = document.getElementById('pat-scope').value
      var ttl = parseInt(document.getElementById('pat-ttl').value, 10)
      rpc('create-access-token', { name: name, scope: scope, ttlDays: ttl }).then(function(r){
        statusEl.textContent = ''
        document.getElementById('pat-secret').textContent = r.secret
        document.getElementById('pat-new').style.display = ''
        document.getElementById('pat-name').value = ''
        load()
      }).catch(function(err){ statusEl.textContent = 'failed: ' + (err.message || err) })
    })
    listEl.addEventListener('click', function(ev){
      var btn = ev.target.closest('[data-revoke]')
      if (!btn) return
      var id = btn.getAttribute('data-revoke')
      if (!confirm('Revoke this token? Any client using it will stop working.')) return
      statusEl.textContent = 'revoking…'
      rpc('revoke-access-token', { identityId: id }).then(function(){ statusEl.textContent = ''; load() })
        .catch(function(err){ statusEl.textContent = 'failed: ' + (err.message || err) })
    })
    window.pgv3CopyToken = function(){
      var el = document.getElementById('pat-secret')
      if (!el) return
      if (navigator.clipboard) { try { navigator.clipboard.writeText(el.textContent) } catch (e) {} }
    }
    // Add SSH key (classic-git-auth Channel B) — links to your session user.
    var addSshForm = document.getElementById('add-ssh-form')
    if (addSshForm) {
      var addSshStatus = document.getElementById('add-ssh-status')
      addSshForm.addEventListener('submit', function(ev){
        ev.preventDefault()
        var input = document.getElementById('add-ssh-input')
        var pub = (input.value || '').trim()
        if (!pub) { addSshStatus.textContent = 'paste a public key first'; return }
        addSshStatus.textContent = 'adding…'
        rpc('add-ssh-key', { publicKey: pub }).then(function(r){
          addSshStatus.textContent = 'added ' + (r.fingerprint || '') + ' — reloading…'
          input.value = ''
          setTimeout(function(){ location.reload() }, 700)
        }).catch(function(err){ addSshStatus.textContent = 'failed: ' + (err.message || err) })
      })
    }
    load()
  })()
  </script>
</div>

<div class="card">
  <h2>Your permissions</h2>
  ${perms.length === 0 ? '<div class="empty">No permissions set.</div>' : `
  <div>${perms.map(p => '<span class="perm-badge ' + p + '">' + escapeHtml(p) + '</span>').join('')}</div>`}
  ${user.resources && user.resources.length > 0 ? `
  <h2 style="margin-top:1rem">Per-resource</h2>
  <div class="table-scroll">
    <table class="perm-table">
      <thead><tr><th>Resource</th><th>Permissions</th></tr></thead>
      <tbody>
        ${user.resources.map(r => `
        <tr>
          <td><code>${escapeHtml(r.name)}</code></td>
          <td>${(r.permissions || []).map(p => '<span class="perm-badge ' + p + '">' + escapeHtml(p) + '</span>').join('')}</td>
        </tr>`).join('')}
      </tbody>
    </table>
  </div>` : ''}
  <div class="meta" style="margin-top:0.8rem;font-size:0.8rem">Need more access? An owner can grant permissions at <a href="${prefix}/admin">/admin</a>.</div>
</div>

<div class="card">
  <h2>Backup your identity</h2>
  <div class="meta" style="margin-bottom:0.8rem">Your keypair lives in this browser's localStorage. Save it to restore access on another device.</div>
  <div class="keypair-actions">
    <button class="btn-secondary" type="button" onclick="pgv3ShowKeypair()">Show keypair</button>
    <button class="btn-secondary" type="button" onclick="pgv3CopyPubkey()">Copy public key</button>
    <button type="button" onclick="pgv3CopyKeypair()">Copy full keypair</button>
    <button class="btn-secondary" type="button" onclick="pgv3DownloadKeypair()">Download</button>
  </div>
  <div id="pgv3-keypair-display" style="display:none;margin-bottom:0.8rem">
    <div style="margin-bottom:0.5rem">
      <div class="meta" style="margin-bottom:0.3rem;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px">Public key (44 base64)</div>
      <div id="pgv3-pubkey-text" class="mono" style="font-size:0.75rem;word-break:break-all;padding:0.5rem 0.7rem;background:#0d1117;border:1px solid #30363d;border-radius:4px"></div>
    </div>
    <div>
      <div class="meta" style="margin-bottom:0.3rem;font-size:0.7rem;text-transform:uppercase;letter-spacing:0.5px">Full keypair (132 base64) — KEEP SECRET</div>
      <div id="pgv3-keypair-text" class="mono" style="font-size:0.7rem;word-break:break-all;padding:0.5rem 0.7rem;background:#0d1117;border:1px solid #30363d;border-radius:4px"></div>
    </div>
  </div>
  <div class="keypair-warning">
    <strong>⚠ Security:</strong> Your full keypair is your private key. Never share it. Only share the public key when asking an owner for access.
  </div>

  <script>
    (function(){
      function getKp() {
        try { return localStorage.getItem('pgv3-keypair') } catch { return null }
      }
      window.pgv3ShowKeypair = function(){
        var kp = getKp()
        if (!kp) { alert('No keypair in browser storage.'); return }
        var disp = document.getElementById('pgv3-keypair-display')
        if (disp.style.display === 'none') {
          document.getElementById('pgv3-pubkey-text').textContent = kp.substring(0, 44)
          document.getElementById('pgv3-keypair-text').textContent = kp
          disp.style.display = ''
        } else {
          disp.style.display = 'none'
        }
      }
      window.pgv3CopyPubkey = function(){
        var kp = getKp()
        if (!kp) { alert('No keypair in browser storage.'); return }
        var pk = kp.substring(0, 44)
        if (navigator.clipboard) navigator.clipboard.writeText(pk).then(function(){ alert('Public key copied (' + pk.length + ' chars)') })
        else prompt('Copy manually:', pk)
      }
      window.pgv3CopyKeypair = function(){
        var kp = getKp()
        if (!kp) { alert('No keypair in browser storage.'); return }
        if (navigator.clipboard) navigator.clipboard.writeText(kp).then(function(){ alert('Full keypair copied (' + kp.length + ' chars)') })
        else prompt('Copy manually:', kp)
      }
      window.pgv3DownloadKeypair = function(){
        var kp = getKp()
        if (!kp) { alert('No keypair in browser storage.'); return }
        var blob = new Blob([kp], { type: 'text/plain' })
        var url = URL.createObjectURL(blob)
        var a = document.createElement('a')
        a.href = url
        a.download = 'pear-git-v3-keypair-' + Date.now() + '.txt'
        a.click()
        URL.revokeObjectURL(url)
      }
    })()
  </script>
</div>

<div class="card">
  <h2>Daemon identity</h2>
  <div class="user-status-header" style="margin-bottom:0">
    ${identicon(daemonId.publicKeyHex, 48)}
    <div style="flex:1;min-width:0">
      <div class="user-status-name" style="font-size:0.9rem">${escapeHtml(daemonId.publicKeyHex.slice(0, 16))}…</div>
      <div class="user-status-id">${escapeHtml(daemonId.publicKeyHex)}</div>
      <div class="meta" style="margin-top:0.3rem">This is the daemon's own pubkey, not yours. Created ${daemonId.createdAt ? new Date(daemonId.createdAt).toLocaleString() : 'unknown'}.</div>
    </div>
  </div>
</div>
` : `
<div class="card">
  <div class="empty">Not authenticated yet. Reload the page to trigger the browser-side auth flow.</div>
</div>
`}
`
  return layout('profile', '/profile', body, ctx)
}

export async function renderUserProfile (svc, ctx, pubkey) {
  const prefix = (ctx && ctx.prefix) || ''
  let user = null
  let userId = null
  let identities = []
  try {
    const idRec = await svc.identityRegistry.getIdentity(pubkey)
    if (idRec && idRec.userId) {
      userId = idRec.userId
      const wrapped = await svc.identityRegistry.getUserWithIdentities(idRec.userId)
      user = wrapped && wrapped.user
      identities = (wrapped && wrapped.identities) || []
    }
  } catch {}

  const isMe = ctx && ctx.identity && ctx.identity.publicKey === pubkey
  // Private data (linked devices / SSH keys / identity methods) is shown only
  // to the profile owner or an admin. Anonymous / other visitors see a public
  // profile: name, avatar, handle, and public activity. (GitHub: public
  // profile vs. account settings.)
  const canSeePrivate = isMe || await viewerIsAdmin(ctx, svc.identityRegistry).catch(() => false)
  const pushes = userId ? listUserPushes(userId) : []
  const issuesPosted = userId ? listUserIssues(userId, 10) : []
  const perms = (user && user.globalPermissions) || []
  const isAdmin = perms.includes('admin')
  const isWriter = perms.includes('write')

  let statusClass = 'read'
  let badgeHtml = '<span class="status-badge read">Unregistered</span>'
  if (isAdmin) { statusClass = 'owner'; badgeHtml = '<span class="status-badge owner">Owner</span>' }
  else if (isWriter) { statusClass = 'writer'; badgeHtml = '<span class="status-badge writer">Writer</span>' }
  else if (user) { badgeHtml = '<span class="status-badge read">Registered</span>' }

  const displayName = (user && user.name && user.name !== 'Anonymous') ? user.name : null
  const avatarUrl = user && user.profilePicture ? pictureSrc(user.profilePicture, prefix) : null

  const body = `
${PROFILE_STYLES}
<h1><a href="${prefix}/users">users</a> / ${escapeHtml(pubkey.slice(0, 12))}…</h1>

<div class="user-status ${statusClass}">
  <div class="user-status-header">
    ${avatarUrl
      ? `<img class="profile-avatar" src="${escapeHtml(avatarUrl)}" alt="${escapeHtml(displayName || pubkey.slice(0, 8))}'s avatar">`
      : identicon(pubkey, 72)}
    <div style="flex:1;min-width:0">
      ${displayName ? `<div class="user-status-displayname">${escapeHtml(displayName)}${isMe ? ' <span class="badge badge-open">you</span>' : ''}</div>` : ''}
      <div class="user-status-name">${escapeHtml(pubkey.slice(0, 16))}…${!displayName && isMe ? ' <span class="badge badge-open">you</span>' : ''}</div>
      <div class="user-status-id">${escapeHtml(pubkey)}</div>
    </div>
    ${badgeHtml}
  </div>
</div>

<div class="card">
  <h2>Repos pushed (${pushes.length})</h2>
  ${pushes.length === 0 ? '<div class="empty">No pushes recorded yet.</div>' : `
  <div class="ssh-list">
    ${pushes.slice(0, 30).map(p => `
    <div class="ssh-item">
      <div style="flex:1;min-width:0">
        <div class="repo-name"><a href="${prefix}/repos/${encodeURIComponent(p.repo)}">${escapeHtml(p.repo)}</a></div>
        <div class="ssh-comment">${p.count} push${p.count === 1 ? '' : 'es'} · last ${escapeHtml(ago(p.lastPush))}</div>
      </div>
    </div>`).join('')}
  </div>`}
</div>

<div class="card">
  <h2>Recent issues (${issuesPosted.length})</h2>
  ${issuesPosted.length === 0 ? '<div class="empty">No issues posted yet.</div>' : `
  <div class="ssh-list">
    ${issuesPosted.map(it => {
      const base = it.scope === 'org' ? `${prefix}/orgs/${encodeURIComponent(it.name)}/issues/${it.id}` : `${prefix}/repos/${encodeURIComponent(it.name)}/issues/${it.id}`
      return `
    <div class="ssh-item">
      <div style="flex:1;min-width:0">
        <div class="repo-name"><a href="${base}">${escapeHtml(it.title || 'issue #' + it.id)}</a></div>
        <div class="ssh-comment">${escapeHtml(it.scope)} ${escapeHtml(it.name)} · #${it.id} · <span class="badge ${it.status === 'closed' ? 'badge-closed' : 'badge-open'}">${escapeHtml(it.status)}</span> · ${escapeHtml(ago(it.createdAt))}</div>
      </div>
    </div>`
    }).join('')}
  </div>`}
</div>

${canSeePrivate ? `
<div class="card">
  <h2>Identities (${identities.length})</h2>
  <div class="meta" style="margin-bottom:0.5rem">Private — visible only to you${isMe ? '' : ' (admin)'}.</div>
  ${identities.length === 0 ? '<div class="empty">(none)</div>' : identities.map(i => `
  <div class="ssh-item" style="margin-bottom:6px">
    <div style="flex:1;min-width:0">
      <div><span class="badge badge-group">${escapeHtml(i.method || '?')}</span></div>
      <div class="ssh-fp">${escapeHtml(i.identityId.slice(0, 40))}${i.identityId.length > 40 ? '…' : ''}</div>
    </div>
  </div>`).join('')}
</div>` : ''}
`
  return layout('user ' + pubkey.slice(0, 12), '/profile', body, ctx)
}

export async function renderUsers (svc, ctx) {
  const prefix = (ctx && ctx.prefix) || ''
  let users = []
  try {
    users = await svc.identityRegistry.listUsers()
  } catch {}

  const myPubkey = ctx && ctx.identity ? ctx.identity.publicKey : null

  const body = `
${PROFILE_STYLES}
<h1>users</h1>

<div class="card">
  <h2>${users.length} user${users.length === 1 ? '' : 's'}</h2>
  ${users.length === 0 ? '<div class="empty">(no users — first login becomes admin via TOFU)</div>' : users.map(u => {
    const perms = u.globalPermissions || []
    const primaryIdentity = (u.identities && u.identities[0]) || u.userId
    const pubkeyish = typeof primaryIdentity === 'string' ? primaryIdentity : u.userId
    const isMe = myPubkey && u.identities && u.identities.includes(myPubkey)
    const displayName = (u.name && u.name !== 'Anonymous') ? u.name : null
    const avatarUrl = u.profilePicture ? pictureSrc(u.profilePicture, prefix) : null
    return `
  <div class="item">
    <div style="display:flex;align-items:center;gap:0.8rem">
      ${avatarUrl
        ? `<img class="profile-avatar" src="${escapeHtml(avatarUrl)}" alt="${escapeHtml(displayName || pubkeyish.slice(0, 8))}'s avatar" style="width:40px;height:40px">`
        : identicon(pubkeyish, 40)}
      <div style="flex:1;min-width:0">
        <div>
          ${displayName ? `<a href="${prefix}/profile/${encodeURIComponent(pubkeyish)}" style="font-weight:600">${escapeHtml(displayName)}</a>` : `<a href="${prefix}/profile/${encodeURIComponent(pubkeyish)}" class="mono" style="font-size:0.85rem">${escapeHtml(pubkeyish.slice(0, 16))}…</a>`}
          ${isMe ? '<span class="badge badge-open" style="margin-left:0.4rem">you</span>' : ''}
        </div>
        <div class="meta" style="font-size:0.65rem;word-break:break-all;font-family:monospace">${escapeHtml(pubkeyish)}</div>
        <div style="margin-top:0.3rem">${perms.map(p => '<span class="perm-badge ' + p + '">' + escapeHtml(p) + '</span>').join('')}</div>
      </div>
    </div>
  </div>`
  }).join('')}
</div>
`
  return layout('users', '/profile', body, ctx)
}