/**
* Admin dashboard pages.
*
* GET /admin → dashboard: stats grid, tabbed view
* POST /admin/trust/grant → grant perms to a pubkey
* POST /admin/trust/revoke → revoke perms from a pubkey
*
* Tabs:
* - Trust → grant/revoke, searchable trust-map table
* - Services → sync + deploy + overlay status cards
* - Bootstrap→ bootstrap keys JSON for peer joining
* - Status → full daemon status (was /status, also linked from nav-admin)
*/
import { layout, escapeHtml, formatBytes, identicon } from '../helpers.js'
const ADMIN_STYLES = `
<style>
.stat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 1rem; }
.stat-card { background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 14px 16px; }
.stat-card-label { color: #8b949e; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; font-weight: 500; }
.stat-card-value { font-size: 1.8rem; font-weight: 600; color: #c9d1d9; line-height: 1; }
.stat-card-sub { color: #8b949e; font-size: 0.75rem; margin-top: 4px; }
.tab-nav { display: flex; gap: 4px; margin-bottom: 1rem; border-bottom: 1px solid #30363d; }
.tab-nav a { padding: 0.5rem 1rem; color: #8b949e; border-bottom: 2px solid transparent; text-decoration: none; font-size: 0.9rem; }
.tab-nav a:hover { color: #c9d1d9; }
.tab-nav a.active { color: #c9d1d9; border-bottom-color: #58a6ff; }
.trust-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.trust-table th { text-align: left; padding: 0.5rem 0.6rem; border-bottom: 1px solid #30363d; color: #8b949e; font-weight: 500; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.5px; }
.trust-table td { padding: 0.5rem 0.6rem; border-bottom: 1px solid #21262d; }
.trust-table tr:hover td { background: #1c2128; }
.trust-key { font-family: monospace; font-size: 0.75rem; }
.trust-key a { color: #58a6ff; }
.trust-actions { text-align: right; }
.search-filter { width: 100%; max-width: 300px; margin-bottom: 0.8rem; }
</style>`
export async function renderAdmin (svc, ctx, tab) {
const prefix = (ctx && ctx.prefix) || ''
// Server-side owner gate: anyone who renders this page must be owner.
const isOwner = await checkOwner(svc, ctx)
if (!isOwner && ctx && ctx.transport === 'http') {
const body = `<div class="card"><h2>Admin</h2><div class="empty">Owner access required. <a href="${prefix}/profile">Check your profile.</a></div></div>`
return layout('admin', '/admin', body, ctx)
}
const activeTab = ['trust', 'services', 'bootstrap', 'status'].includes(tab) ? tab : 'trust'
// Gather data. listUsers includes deactivated if we ask for them so the
// admin can see the full population and reactivate if needed.
const [status, users, keys] = await Promise.all([
svc.dispatch('status', {}, ctx).catch(() => ({})),
svc.identityRegistry.listUsers({ includeDeactivated: true }).catch(() => []),
svc.dispatch('dump-keys', {}, { transport: 'uds' }).catch(() => null)
])
const syncStatus = svc._sync ? svc._sync.status() : null
const deploy = svc._deploy || null
// ── Tab content ──
let tabContent
if (activeTab === 'services') tabContent = renderServicesTab(syncStatus, deploy, svc, prefix)
else if (activeTab === 'bootstrap') tabContent = renderBootstrapTab(keys, svc)
else if (activeTab === 'status') tabContent = renderStatusTab(status, svc, ctx, prefix)
else tabContent = renderUsersTab(users, ctx)
const body = `
${ADMIN_STYLES}
<h1>admin</h1>
<div class="stat-grid">
${statCard('Sessions', status.sessions ? status.sessions.active : 0, 'Active HTTP sessions')}
${statCard('Swarm', status.swarm ? (status.swarm.connections || 0) + '' : 'off', status.swarm ? 'peers connected' : 'not enabled')}
${statCard('Orgs', status.orgs || 0, 'groups + users')}
${statCard('Issues', status.issues ? (status.issues.open || 0) : 0, status.issues ? (status.issues.total || 0) + ' total' : '')}
${statCard('Repos', syncStatus ? syncStatus.repoCount : '—', syncStatus ? syncStatus.serving + ' serving' : 'no sync')}
${statCard('Users', users.length, users.filter(u => (u.globalPermissions || []).includes('admin')).length + ' admin')}
</div>
<div class="tab-nav">
<a href="${prefix}/admin?tab=trust" class="${activeTab === 'trust' ? 'active' : ''}">Users</a>
<a href="${prefix}/admin?tab=services" class="${activeTab === 'services' ? 'active' : ''}">Services</a>
<a href="${prefix}/admin?tab=bootstrap" class="${activeTab === 'bootstrap' ? 'active' : ''}">Bootstrap keys</a>
<a href="${prefix}/admin?tab=status" class="${activeTab === 'status' ? 'active' : ''}">Status</a>
</div>
${tabContent}
`
return layout('admin', '/admin', body, ctx)
}
function statCard (label, value, sub) {
return `
<div class="stat-card">
<h3 class="stat-card-label">${escapeHtml(label)}</h3>
<div class="stat-card-value">${escapeHtml(String(value))}</div>
${sub ? '<div class="stat-card-sub">' + escapeHtml(sub) + '</div>' : ''}
</div>`
}
function renderUsersTab (users, ctx) {
const prefix = (ctx && ctx.prefix) || ''
if (users.length === 0) {
return `
<div class="card">
<h2>Users (0)</h2>
<div class="empty">No users yet. The first HTTP login creates the admin via TOFU.</div>
</div>`
}
return `
<div class="card">
<h2>Users (${users.length})</h2>
<input type="text" class="search-filter" id="user-filter" placeholder="Filter by name, userId, or perms...">
<div class="table-scroll">
<table class="trust-table" id="user-table">
<thead>
<tr><th>User</th><th>Perms</th><th>Identities</th><th style="text-align:right">Actions</th></tr>
</thead>
<tbody>
${users.map(u => renderUserRow(u, ctx)).join('')}
</tbody>
</table>
</div>
<script>
(function(){
var f = document.getElementById('user-filter')
var rows = document.querySelectorAll('#user-table tbody tr')
if (!f) return
f.addEventListener('input', function(){
var q = f.value.toLowerCase()
rows.forEach(function(r){
r.style.display = (!q || r.dataset.search.indexOf(q) >= 0) ? '' : 'none'
})
})
})()
</script>
</div>
<div class="card">
<div class="meta">Users are created via browser TOFU (first-login becomes admin, subsequent users get read-only). Promote / demote / deactivate from the Actions column. Deactivated users are kept in the registry (never hard-deleted) and can be reactivated — see <a href="${prefix}/docs/identity-ops">identity-ops</a> for the full policy.</div>
</div>`
}
function renderUserRow (u, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const perms = u.globalPermissions || []
const identities = u.identities || []
const identityId = identities[0] || u.userId
const isMe = ctx && ctx.identity && identities.includes(ctx.identity.publicKey)
const displayName = (u.name && u.name !== 'Anonymous') ? u.name : u.userId.slice(0, 16) + '…'
const deactivated = !!u.deactivatedAt
const isAdmin = perms.includes('admin')
// The stored perm value is 'write' (the canonical name used everywhere
// else in this stack — auth/check.js, profile page, whoami response).
// The badge label is "Writer" (display label). Storing the display
// label as the value broke promotions silently — the badge looked
// right but requireWriter() failed because perms.includes('write')
// returned false on a user whose perms were ['writer'].
const isWriter = perms.includes('write')
const searchKey = (u.userId + ' ' + displayName + ' ' + perms.join(' ') + ' ' + identities.join(' ')).toLowerCase()
// Build the action buttons per user. Self-edits are blocked (an admin
// demoting themselves is a foot-gun; use UDS CLI if genuinely needed).
const actions = []
if (!isMe) {
if (deactivated) {
actions.push(`<form action="${prefix}/admin/user-reactivate" method="POST" style="display:inline;margin:0"><input type="hidden" name="userId" value="${escapeHtml(u.userId)}"><button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem">reactivate</button></form>`)
} else {
if (!isAdmin) {
actions.push(`<form action="${prefix}/admin/user-grant" method="POST" style="display:inline;margin:0" onsubmit="return confirm('Promote ${escapeHtml(displayName)} to admin?')"><input type="hidden" name="userId" value="${escapeHtml(u.userId)}"><input type="hidden" name="permissions" value="admin"><button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem" aria-label="Promote ${escapeHtml(displayName)} to admin">make admin</button></form>`)
}
if (!isWriter && !isAdmin) {
actions.push(`<form action="${prefix}/admin/user-grant" method="POST" style="display:inline;margin:0"><input type="hidden" name="userId" value="${escapeHtml(u.userId)}"><input type="hidden" name="permissions" value="write"><button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem">make writer</button></form>`)
}
if (isAdmin || isWriter) {
actions.push(`<form action="${prefix}/admin/user-grant" method="POST" style="display:inline;margin:0" onsubmit="return confirm('Demote ${escapeHtml(displayName)} to reader?')"><input type="hidden" name="userId" value="${escapeHtml(u.userId)}"><input type="hidden" name="permissions" value=""><button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem" aria-label="Demote ${escapeHtml(displayName)} to reader">demote</button></form>`)
}
actions.push(`<form action="${prefix}/admin/user-deactivate" method="POST" style="display:inline;margin:0" onsubmit="return confirm('Deactivate ${escapeHtml(displayName)}? This is reversible.')"><input type="hidden" name="userId" value="${escapeHtml(u.userId)}"><button class="btn-secondary" type="submit" style="font-size:0.68rem;padding:0.15rem 0.45rem" aria-label="Deactivate ${escapeHtml(displayName)}">deactivate</button></form>`)
}
}
return `
<tr data-search="${escapeHtml(searchKey)}" style="${deactivated ? 'opacity:0.55' : ''}">
<td class="trust-key">
<div style="display:flex;align-items:center;gap:0.5rem">
${identicon(identityId, 24)}
<a href="${prefix}/profile/${encodeURIComponent(identityId)}">${escapeHtml(displayName)}</a>
${isMe ? '<span class="badge badge-open">you</span>' : ''}
${deactivated ? '<span class="badge badge-closed">deactivated</span>' : ''}
</div>
</td>
<td>${perms.map(p => '<span class="badge badge-' + (p === 'admin' ? 'open' : 'group') + '">' + escapeHtml(p) + '</span>').join(' ') || '<span class="meta">reader</span>'}</td>
<td>${identities.length} linked</td>
<td class="trust-actions" style="white-space:nowrap">${actions.join(' ')}</td>
</tr>`
}
function renderServicesTab (syncStatus, deploy, svc, prefix) {
return `
${syncStatus ? `
<div class="card">
<h2>Sync service (git)</h2>
<div class="row"><div class="label">status</div><div><span class="badge badge-open">active</span></div></div>
<div class="row"><div class="label">repos</div><div>${syncStatus.repoCount}</div></div>
<div class="row"><div class="label">serving</div><div>${syncStatus.serving}</div></div>
<div class="row"><div class="label">remotes</div><div>${syncStatus.remotes || 0}</div></div>
<div class="row"><div class="label">server key</div><div class="meta mono" style="font-size:0.7rem">${escapeHtml(syncStatus.serverKey || '(none)')}</div></div>
</div>` : '<div class="card"><h2>Sync service</h2><div class="empty">Inactive (no swarm).</div></div>'}
${deploy ? `
<div class="card">
<h2>Deploy service</h2>
<div class="row"><div class="label">status</div><div><span class="badge badge-open">active</span></div></div>
<div class="row"><div class="label">files</div><div>${(deploy.manifest && deploy.manifest.files) ? deploy.manifest.files.length : 0}</div></div>
<div class="row"><div class="label">total size</div><div>${deploy.manifest ? formatBytes(deploy.manifest.totalSize || 0) : '?'}</div></div>
<div class="row"><div class="label">peers</div><div>${deploy.connections || 0}</div></div>
<div class="row"><div class="label">topic</div><div class="meta mono" style="font-size:0.7rem">${escapeHtml(deploy.topicHex || '(none)')}</div></div>
</div>` : '<div class="card"><h2>Deploy service</h2><div class="empty">Inactive (no swarm).</div></div>'}`
}
function renderBootstrapTab (keys, svc) {
if (!keys) {
return '<div class="card"><h2>Bootstrap keys</h2><div class="empty">Unavailable (requires UDS access).</div></div>'
}
// Each key gets its own row for skimming; full JSON is a collapsed
// `<details>` for copying into a peer's config.
const rows = []
if (keys.orgs && keys.orgs.bootstrapKey) rows.push({ label: 'orgs.bootstrapKey', value: keys.orgs.bootstrapKey })
if (keys.issues && keys.issues.bootstrapKey) rows.push({ label: 'issues.bootstrapKey', value: keys.issues.bootstrapKey })
if (keys.identity && keys.identity.bootstrapKey) rows.push({ label: 'identity.bootstrapKey', value: keys.identity.bootstrapKey })
if (keys.repoEventLog && keys.repoEventLog.coreKey) rows.push({ label: 'repoEventLog.coreKey', value: keys.repoEventLog.coreKey })
return `
<div class="card">
<h2>Bootstrap keys</h2>
<div class="meta" style="margin-bottom:0.6rem">Share these with peers who want to join this deployment.</div>
${rows.map(r => `
<div class="row">
<div class="label">${escapeHtml(r.label)}</div>
<div class="mono" style="font-size:0.7rem;word-break:break-all;text-align:right">${escapeHtml(r.value)}</div>
</div>`).join('')}
<details style="margin-top:0.8rem">
<summary>Full JSON (for pasting into opts.mounts)</summary>
<pre style="font-size:0.7rem;line-height:1.5;margin-top:0.5rem">${escapeHtml(JSON.stringify(keys, null, 2))}</pre>
</details>
</div>`
}
function renderStatusTab (status, svc, ctx, prefix) {
return `
<div class="card">
<h2>Daemon</h2>
<div class="row"><div class="label">name</div><div>${escapeHtml(status.name || 'pear-git-v3')}</div></div>
<div class="row"><div class="label">identity</div><div class="meta mono" style="font-size:0.65rem;word-break:break-all">${escapeHtml(status.identity || svc.identity.publicKeyHex)}</div></div>
<div class="row"><div class="label">mounts</div><div>${escapeHtml((status.mounts || []).join(', '))}</div></div>
<div class="row"><div class="label">handlers</div><div>${(status.handlers || []).length}</div></div>
</div>
<div class="card">
<h2>Full status →</h2>
<div class="meta">For the scannable status view (identity, service, data, bootstrap keys), see <a href="${prefix}/status">/status</a>.</div>
</div>`
}
/**
* Owner check. UDS traffic is always owner-equivalent; HTTP must have a
* matching trust entry. Separated from the renderer so the check fails
* cleanly and can be reused.
*/
async function checkOwner (svc, ctx) {
if (!ctx) return false
if (ctx.transport === 'uds') return true
if (!ctx.identity || !ctx.identity.publicKey) return false
const user = await svc.identityRegistry.getUserByIdentity(ctx.identity.publicKey).catch(() => null)
return !!(user && Array.isArray(user.globalPermissions) && user.globalPermissions.includes('admin'))
}