/**
* Orgs pages: list, overview, repos, members, settings, create/edit forms.
*
* GET /orgs → renderOrgsIndex (list + create)
* GET /orgs/:name → renderOrgShow (Overview tab)
* GET /orgs/:name/repos → renderOrgRepos
* GET /orgs/:name/members → renderOrgMembers
* GET /orgs/:name/settings → renderOrgSettings (admin only)
*
* /orgs/:name/issues handled by pages/issues.js — that page renders
* the org-tabs chrome itself when scope is { org }
*
* POST /orgs/create → svc.dispatch('create-org', ...)
* POST /orgs/:name/member → svc.dispatch('add-org-member', ...)
* POST /orgs/:name/resource → svc.dispatch('add-org-resource', ...)
* POST /orgs/:name/member-remove → svc.dispatch('remove-org-member', ...)
* POST /orgs/:name/resource-remove → svc.dispatch('remove-org-resource', ...)
* POST /orgs/:name/settings → svc.dispatch('update-org', ...)
*/
import { layout, escapeHtml, identicon, markdownBody } from '../helpers.js'
import { isAdmin } from '../../auth/check.js'
import { renderOrgTabs, ORG_TAB_STYLES } from '../components/org-tabs.js'
// ── Index ───────────────────────────────────────────────────────────
export async function renderOrgsIndex (svc, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const all = await svc.modules.orgs.list()
const groups = all.filter(o => o.type === 'group')
// User-type orgs are a dormant concept in v3 — nothing currently
// creates one. Show the section ONLY when some already exist (don't
// silently hide legacy data); otherwise skip it entirely so we don't
// render "0 users / Only owners can create orgs" noise.
const users = all.filter(o => o.type === 'user')
const canCreate = await isAdmin(ctx, svc.identityRegistry)
const gridStyles = `
<style>
.org-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(350px, 1fr)); gap: 15px; margin-bottom: 30px; }
.org-card { display: block; background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 20px; transition: border-color 0.2s; text-decoration: none; color: inherit; }
.org-card:hover { border-color: #58a6ff; text-decoration: none; }
.org-card-name { font-size: 18px; font-weight: 600; color: #58a6ff; margin-bottom: 5px; }
.org-card-desc { color: #8b949e; font-size: 14px; margin-bottom: 10px; min-height: 1.4em; }
.org-card-meta { display: flex; gap: 15px; font-size: 13px; color: #8b949e; }
.org-section-title { font-size: 0.75rem; text-transform: uppercase; color: #8b949e; letter-spacing: 0.5px; margin: 1.5rem 0 0.75rem; }
</style>`
const renderCard = (o) => `
<a class="org-card" href="${prefix}/orgs/${escapeHtml(o.name)}">
<div class="org-card-name">${escapeHtml(o.name)} <span class="badge badge-${o.type}">${o.type}</span></div>
<div class="org-card-desc">${escapeHtml(o.displayName || o.description || '')}</div>
<div class="org-card-meta">
${o.type === 'group' ? `<span>${o.members.length} member${o.members.length === 1 ? '' : 's'}</span>` : ''}
<span>${o.resources.length} resource${o.resources.length === 1 ? '' : 's'}</span>
</div>
</a>`
const body = `
${gridStyles}
<h1>orgs</h1>
<div class="org-section-title">${groups.length} group${groups.length === 1 ? '' : 's'}</div>
${groups.length === 0
? `<div class="card"><div class="empty">No groups yet.${canCreate ? ' Use the <a href="#create-org">Create org</a> form below to add one.' : ' Only owners can create orgs — ask an admin.'}</div></div>`
: `<div class="org-grid">${groups.map(renderCard).join('')}</div>`}
${users.length > 0 ? `
<div class="org-section-title">${users.length} user${users.length === 1 ? '' : 's'}</div>
<div class="org-grid">${users.map(renderCard).join('')}</div>` : ''}
${canCreate ? `
<div class="card" id="create-org">
<h2>Create org</h2>
<form action="${prefix}/orgs/create" method="POST">
<div class="field"><label for="new-org-name">Name</label><input id="new-org-name" name="name" placeholder="my-team" pattern="[a-z0-9][a-z0-9-]*[a-z0-9]" title="lowercase letters, numbers, and hyphens — must start and end with a letter or number" required><div class="meta" style="margin-top:0.25rem">lowercase letters, numbers, and hyphens — must start and end with a letter or number</div></div>
<div class="field"><label for="new-org-type">Type</label>
<select id="new-org-type" name="type">
<option value="group">group</option>
<option value="user">user</option>
</select>
</div>
<div class="field"><label for="new-org-display">Display name</label><input id="new-org-display" name="displayName"></div>
<div class="field"><label for="new-org-description">Description</label><textarea id="new-org-description" name="description"></textarea></div>
<button type="submit">Create</button>
</form>
</div>
<div class="card" id="join-org">
<h2>Join an org via invite</h2>
<div class="meta" style="margin-bottom:0.5rem">Paste a P2P invite code. This daemon pairs over the swarm to mount the org (and, for contributor+, becomes a writer). No URL, no HTTP.</div>
<form action="${prefix}/orgs/join" method="POST">
<div class="field"><label for="join-org-invite">Invite code</label><textarea id="join-org-invite" name="invitation" rows="3" placeholder="roompair1:…" required></textarea></div>
<button type="submit">Join</button>
</form>
</div>` : ''}
`
return layout('orgs', '/orgs', body, ctx)
}
/**
* Render the result of minting an org invite — shows the P2P invite code to
* copy + share. The recipient runs `pgh org join <code>` or pastes it into
* the Join box; they pair over the swarm (no URL, no HTTP).
*/
export function renderOrgInviteResult (orgName, invitation, role, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const body = `
<h2><a href="${prefix}/orgs/${escapeHtml(orgName)}">← ${escapeHtml(orgName)}</a> / invite</h2>
<div class="card">
<h2 style="margin-top:0">Invite created — role: ${escapeHtml(role)}</h2>
<div class="meta" style="margin-bottom:0.5rem">Share this code. The recipient runs <code>pgh org join <code></code> or pastes it into the Join box on the orgs page — they pair over the swarm and (for contributor+) become a writer.</div>
<textarea readonly rows="4" style="width:100%;font-family:monospace;font-size:0.8rem" onclick="this.select()">${escapeHtml(invitation)}</textarea>
</div>`
return layout('invite', '/orgs', body, ctx)
}
// ── Helpers shared across tab pages ──────────────────────────────────
const DETAIL_STYLES = `
<style>
.detail-header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.detail-title { font-size: 1.5rem; color: #58a6ff; font-weight: 600; }
.detail-sub { color: #8b949e; font-size: 0.9rem; margin-top: 2px; }
.member-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 10px; margin-top: 0.5rem; }
.member-card { display: flex; align-items: center; gap: 0.6rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.6rem 0.8rem; }
.member-name { font-family: monospace; font-size: 0.8rem; color: #c9d1d9; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.resource-list { display: flex; flex-direction: column; gap: 8px; }
.resource-card { display: flex; align-items: center; gap: 1rem; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 0.8rem 1rem; }
.resource-card:hover { border-color: #58a6ff; }
.resource-name { flex: 1; min-width: 0; }
.resource-name a { font-size: 1rem; font-weight: 500; color: #58a6ff; text-decoration: none; }
.resource-name a:hover { text-decoration: underline; }
.resource-urls { display: flex; flex-direction: column; gap: 3px; align-items: flex-end; }
.resource-urls code { font-size: 0.68rem; color: #8b949e; background: #161b22; padding: 2px 6px; border-radius: 3px; cursor: pointer; user-select: all; white-space: nowrap; }
.resource-urls code:hover { color: #c9d1d9; background: #21262d; }
</style>`
/**
* Small breadcrumb shown at the top of every per-org page. Mirrors the
* repo equivalent in repo-browser.js: `← orgs / <name>`.
*/
function renderOrgBreadcrumb (org, prefix) {
return `
<div class="card">
<h2><a href="${prefix}/orgs">← orgs</a> / ${escapeHtml(org.name)}</h2>
</div>`
}
/**
* Full detail header (identicon + displayName + badges) — shown on the
* Overview tab only. Other tabs use the breadcrumb alone to keep the
* page focused on its content.
*/
function renderOrgDetailHeader (org, prefix) {
return `
<div class="card">
<div class="detail-header">
${identicon('org:' + org.name, 56)}
<div>
<div class="detail-title">${escapeHtml(org.displayName || org.name)}</div>
<div class="detail-sub">
<a href="${prefix}/orgs">${escapeHtml(org.name)}</a>
<span class="badge badge-${org.type}" style="margin-left:0.4rem">${org.type}</span>
<span class="meta" style="margin-left:0.6rem">visibility: ${escapeHtml(org.visibility || 'public')}</span>
</div>
</div>
</div>
${org.description ? '<div style="color:#8b949e">' + escapeHtml(org.description) + '</div>' : ''}
<div class="meta" style="margin-top:0.5rem;font-size:0.75rem">created ${new Date(org.createdAt).toLocaleString()}</div>
</div>`
}
/**
* About card (markdown). Renders client-side via marked v14 with the
* same try/catch + plain-text fallback used by the repo README card.
* Returns '' when org.about is empty so we don't show an empty card.
*/
function renderAboutCard (org) {
if (!org.about) return ''
// No link/image base: an org About has no implied file tree, so
// relative hrefs pass through verbatim. Pass linkBase when org tabs
// grow internal targets like "[members](members)".
return `
<div class="card">
<h2>About</h2>
${markdownBody(org.about, { id: 'org-about-content' })}
</div>`
}
/**
* Compute counts for the tab badges. issueRegistry call is guarded —
* a missing tracker just means 0 issues rather than aborting the page.
*/
async function getOrgCounts (svc, org) {
let issues = 0
try {
const tracker = await svc.issueRegistry.getForOrg(org.name)
const counts = await tracker.counts()
issues = counts.total || 0
} catch { issues = 0 }
return {
issues,
repos: org.resources.length,
members: org.members.length
}
}
// ── Tab pages ────────────────────────────────────────────────────────
/**
* Overview tab — /orgs/:name. Detail header + about markdown +
* activity summary. The mutation cards (members, resources, settings)
* each get their own tab now.
*/
export async function renderOrgShow (svc, name, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const org = await svc.modules.orgs.get(name)
if (!org) return null
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const counts = await getOrgCounts(svc, org)
const body = `
${DETAIL_STYLES}
${ORG_TAB_STYLES}
${renderOrgBreadcrumb(org, prefix)}
${renderOrgTabs(org.name, 'overview', counts, prefix, canAdmin)}
${renderOrgDetailHeader(org, prefix)}
${renderAboutCard(org)}
<div class="card">
<h2>Activity</h2>
<div class="row">
<div class="label">issues</div>
<div><a href="${prefix}/orgs/${escapeHtml(org.name)}/issues">${counts.issues} issue${counts.issues === 1 ? '' : 's'}</a></div>
</div>
<div class="row">
<div class="label">repos</div>
<div><a href="${prefix}/orgs/${escapeHtml(org.name)}/repos">${counts.repos} repo${counts.repos === 1 ? '' : 's'}</a></div>
</div>
${org.type === 'group' ? `
<div class="row">
<div class="label">members</div>
<div><a href="${prefix}/orgs/${escapeHtml(org.name)}/members">${counts.members} member${counts.members === 1 ? '' : 's'}</a></div>
</div>` : ''}
</div>
`
return layout(`org · ${org.name}`, '/orgs', body, ctx)
}
/**
* Repos tab — /orgs/:name/repos. List of resources (clone URLs +
* remove buttons for admins) plus an add-resource form.
*/
export async function renderOrgRepos (svc, name, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const org = await svc.modules.orgs.get(name)
if (!org) return null
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const counts = await getOrgCounts(svc, org)
const serverKey = svc._sync && svc._sync.serverKey
const body = `
${DETAIL_STYLES}
${ORG_TAB_STYLES}
${renderOrgBreadcrumb(org, prefix)}
${renderOrgTabs(org.name, 'repos', counts, prefix, canAdmin)}
<div class="card">
<h2>${org.resources.length} repo${org.resources.length === 1 ? '' : 's'}</h2>
${org.resources.length === 0 ? '<div class="empty">(no repos yet — add one below)</div>' : `
<div class="resource-list">
${org.resources.map(r => renderResourceCardWithRemove(r, serverKey, org.name, prefix, canAdmin)).join('')}
</div>`}
${canAdmin ? `
<form action="${prefix}/orgs/${escapeHtml(org.name)}/resource" method="POST" style="margin-top:1rem">
<h2 style="margin-top:0">Add repo</h2>
<div class="field"><label for="add-resource-name">Repo name</label><input id="add-resource-name" name="resource" placeholder="my-repo" required></div>
<button type="submit">Add</button>
</form>` : ''}
</div>
`
return layout(`${org.name} · repos`, '/orgs', body, ctx)
}
/**
* Members tab — /orgs/:name/members. Members list (with remove for
* admins) and add-member form (admin-only). For type=user orgs this
* tab is empty by design — the user IS the org.
*/
export async function renderOrgMembers (svc, name, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const org = await svc.modules.orgs.get(name)
if (!org) return null
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const counts = await getOrgCounts(svc, org)
const body = `
${DETAIL_STYLES}
${ORG_TAB_STYLES}
${renderOrgBreadcrumb(org, prefix)}
${renderOrgTabs(org.name, 'members', counts, prefix, canAdmin)}
${org.type === 'group' ? `
<div class="card">
<h2>${org.members.length} member${org.members.length === 1 ? '' : 's'}</h2>
${org.members.length === 0 ? '<div class="empty">(no members yet)</div>' : `
<div class="member-list">
${org.members.map(m => `
<div class="member-card">
${identicon(m.userId, 32)}
<div style="flex:1;min-width:0">
<div class="member-name">${escapeHtml(m.userId.slice(0, 16))}${m.userId.length > 16 ? '…' : ''}</div>
<div class="meta">${escapeHtml(m.role)}</div>
</div>
${canAdmin ? `
<form action="${prefix}/orgs/${escapeHtml(org.name)}/member-remove" method="POST" style="margin:0" onsubmit="return confirm('Remove ${escapeHtml(m.userId.slice(0, 16))}… from ${escapeHtml(org.name)}?')">
<input type="hidden" name="userId" value="${escapeHtml(m.userId)}">
<button class="btn-secondary" type="submit" style="font-size:0.7rem;padding:0.2rem 0.5rem" aria-label="Remove member ${escapeHtml(m.userId.slice(0, 16))} from ${escapeHtml(org.name)}">remove</button>
</form>` : ''}
</div>`).join('')}
</div>`}
${canAdmin ? `
<form action="${prefix}/orgs/${escapeHtml(org.name)}/member" method="POST" style="margin-top:1rem">
<h2 style="margin-top:0">Add member</h2>
<div class="field pgv3-user-picker" style="position:relative">
<label for="add-member-search">Find user</label>
<input id="add-member-search" type="text" autocomplete="off"
placeholder="Search by name, username, or identity hex…"
data-pgv3-user-pick="add-member-userid">
<input type="hidden" id="add-member-userid" name="userId">
<div class="pgv3-user-picker-dropdown" hidden></div>
<div class="pgv3-user-picker-status" aria-live="polite"></div>
</div>
<div class="field"><label for="add-member-role">Role</label>
<select id="add-member-role" name="role">
<option value="member">member</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit">Add</button>
</form>
<form action="${prefix}/orgs/${escapeHtml(org.name)}/invite" method="POST" style="margin-top:1.5rem">
<h2 style="margin-top:0">Invite collaborator</h2>
<div class="meta" style="margin-bottom:0.5rem">Generates a P2P invite code. Share it — the holder pairs over the swarm and (for contributor+) becomes a writer. No URL, no HTTP.</div>
<div class="field"><label for="org-invite-role">Role</label>
<select id="org-invite-role" name="role">
<option value="reader">reader (read-only mirror)</option>
<option value="contributor">contributor (can write)</option>
<option value="maintainer">maintainer</option>
<option value="admin">admin</option>
</select>
</div>
<button type="submit">Generate invite</button>
</form>
<style>
.pgv3-user-picker-dropdown { position:absolute; top:100%; left:0; right:0; background:#161b22; border:1px solid #30363d; border-top:none; border-radius:0 0 6px 6px; max-height:280px; overflow-y:auto; z-index:50; }
.pgv3-user-picker-row { padding:0.5rem 0.7rem; cursor:pointer; border-bottom:1px solid #21262d; outline:none; }
.pgv3-user-picker-row:last-child { border-bottom:none; }
.pgv3-user-picker-row:hover, .pgv3-user-picker-row:focus { background:#21262d; }
.pgv3-user-picker-row-label { color:#c9d1d9; font-size:0.9rem; }
.pgv3-user-picker-row-sub { color:#8b949e; font-size:0.75rem; font-family:'SF Mono',Consolas,monospace; margin-top:0.15rem; }
.pgv3-user-picker-empty { padding:0.7rem; color:#8b949e; font-style:italic; }
.pgv3-user-picker-status { color:#8b949e; font-size:0.75rem; margin-top:0.25rem; min-height:1em; }
input[data-pgv3-user-pick].resolved { color:#3fb950; }
</style>
<script src="${prefix}/static/pgv3-user-typeahead.js" defer></script>` : ''}
</div>
` : '<div class="card"><div class="empty">User-type orgs have no members — the user IS the org.</div></div>'}
`
return layout(`${org.name} · members`, '/orgs', body, ctx)
}
/**
* Settings tab — /orgs/:name/settings. Admin-only. Edit displayName,
* description, about (markdown), visibility. POST goes to the existing
* /orgs/:name/settings handler in router.js.
*/
export async function renderOrgSettings (svc, name, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const org = await svc.modules.orgs.get(name)
if (!org) return null
const canAdmin = await isAdmin(ctx, svc.identityRegistry)
const counts = await getOrgCounts(svc, org)
if (!canAdmin) {
// Render a stub page with the tab nav (so the operator can navigate
// away cleanly) plus a permission-denied note. The route itself
// shouldn't have surfaced this page to non-admins, but the stub is
// a polite landing for someone who got here via a direct URL.
const body = `
${DETAIL_STYLES}
${ORG_TAB_STYLES}
${renderOrgBreadcrumb(org, prefix)}
${renderOrgTabs(org.name, 'overview', counts, prefix, false)}
<div class="card"><div class="empty">Only org admins can edit settings.</div></div>
`
return layout(`${org.name} · settings`, '/orgs', body, ctx)
}
const body = `
${DETAIL_STYLES}
${ORG_TAB_STYLES}
${renderOrgBreadcrumb(org, prefix)}
${renderOrgTabs(org.name, 'settings', counts, prefix, true)}
<div class="card">
<h2>Settings</h2>
<form action="${prefix}/orgs/${escapeHtml(org.name)}/settings" method="POST">
<div class="field"><label for="org-settings-display">Display name</label><input id="org-settings-display" name="displayName" value="${escapeHtml(org.displayName || '')}"></div>
<div class="field"><label for="org-settings-description">Description</label><textarea id="org-settings-description" name="description">${escapeHtml(org.description || '')}</textarea><div class="meta" style="margin-top:0.25rem">one-line summary, shown on the orgs index card and at the top of the Overview tab</div></div>
<div class="field"><label for="org-settings-about">About (markdown)</label><textarea id="org-settings-about" name="about" rows="10" style="font-family:'SF Mono','Fira Code',Consolas,Monaco,monospace;font-size:0.85rem">${escapeHtml(org.about || '')}</textarea><div class="meta" style="margin-top:0.25rem">long-form markdown, rendered as an "About" card on the Overview tab (the org's README equivalent)</div></div>
<div class="field"><label for="org-settings-visibility">Visibility</label>
<select id="org-settings-visibility" name="visibility">
<option value="public"${(org.visibility || 'public') === 'public' ? ' selected' : ''}>public</option>
<option value="private"${org.visibility === 'private' ? ' selected' : ''}>private</option>
</select>
</div>
<button type="submit">Save</button>
</form>
</div>
`
return layout(`${org.name} · settings`, '/orgs', body, ctx)
}
// ── Helpers ──────────────────────────────────────────────────────────
/**
* Render one resource (a repo name) with its clone URL and an optional
* remove button (admin-only). The non-removeable variant got dropped
* with the tab restructure since every place that lists resources is
* the Repos tab, where admins can remove and viewers can't.
*/
function renderResourceCardWithRemove (resource, serverKey, orgName, pfx, canAdmin) {
const name = typeof resource === 'string' ? resource : (resource.name || '?')
const p2p = serverKey ? 'punch://' + serverKey + '/' + name : null
return `
<div class="resource-card">
<div class="resource-name">
<a href="${pfx || ''}/repos/${encodeURIComponent(name)}">${escapeHtml(name)}</a>
</div>
<div class="resource-urls">
${p2p ? '<code title="Copy">' + escapeHtml(p2p) + '</code>' : ''}
</div>
${canAdmin ? `
<form action="${pfx || ''}/orgs/${escapeHtml(orgName)}/resource-remove" method="POST" style="margin:0" onsubmit="return confirm('Remove ${escapeHtml(name)} from ${escapeHtml(orgName)}?')">
<input type="hidden" name="resource" value="${escapeHtml(name)}">
<button class="btn-secondary" type="submit" style="font-size:0.7rem;padding:0.2rem 0.5rem" aria-label="Remove resource ${escapeHtml(name)} from ${escapeHtml(orgName)}">remove</button>
</form>` : ''}
</div>`
}
export function renderOrgError (name, err, ctx) {
const prefix = (ctx && ctx.prefix) || ''
const body = `
<div class="card">
<h2>error</h2>
<pre>${escapeHtml(err.message || String(err))}</pre>
<div style="margin-top:0.6rem"><a href="${prefix}/orgs">← back to orgs</a></div>
</div>
`
return layout(`error · ${name || 'orgs'}`, '/orgs', body, ctx)
}