OverviewFilesCommitsIssuesDocsReleasesPeers

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

469 lines · javascript ref:
/**
 * 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)}">&larr; ${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 &lt;code&gt;</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">&larr; 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)
}