/**
* 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 { '&':'&','<':'<','>':'>','"':'"',"'":''' }[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)
}