OverviewFilesCommitsIssuesDocsReleasesPeers

pear-git / lib / namespace-ui.js

1735 lines · javascript ref:
/**
 * Namespace UI Renderers
 *
 * Server-side HTML rendering for group pages:
 * - renderGroupsPage(groups, pathPrefix) — List of all groups with admin controls
 * - renderGroupPage(group, repos, allRepos, pathPrefix) — Group detail with management
 */

import { escapeHtml } from './response.js'
import { renderPage } from './page-layout.js'

const PAGE_STYLES = `
  h1 { font-size: 28px; margin-bottom: 10px; }
  h2 { font-size: 22px; margin-bottom: 15px; color: #e6edf3; }
  .subtitle { color: #8b949e; margin-bottom: 30px; }

  .group-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(350px, 1fr));
    gap: 15px;
    margin-bottom: 30px;
  }
  .group-card {
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 20px;
    transition: border-color 0.2s;
  }
  .group-card:hover { border-color: #58a6ff; }
  .group-card-name {
    font-size: 18px;
    font-weight: 600;
    color: #58a6ff;
    margin-bottom: 5px;
  }
  .group-card-desc {
    color: #8b949e;
    font-size: 14px;
    margin-bottom: 10px;
  }
  .group-card-meta {
    display: flex;
    gap: 15px;
    font-size: 13px;
    color: #8b949e;
  }

  .repo-list {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .repo-card {
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 16px 20px;
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  .repo-card:hover { border-color: #30363d; }
  .repo-name {
    font-size: 16px;
    font-weight: 600;
    color: #58a6ff;
  }
  .repo-meta {
    display: flex;
    gap: 15px;
    font-size: 12px;
    color: #8b949e;
    margin-top: 4px;
  }

  .member-list {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
    margin-top: 15px;
  }
  .member-badge {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 4px 12px;
    border-radius: 12px;
    font-size: 13px;
    background: #21262d;
    border: 1px solid #30363d;
  }
  .member-badge.admin { border-color: #f0883e; }

  .empty-state {
    text-align: center;
    padding: 40px;
    color: #8b949e;
  }

  .badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 12px;
    font-size: 11px;
    font-weight: 600;
  }
  .badge-count {
    background: #21262d;
    color: #8b949e;
  }

  /* Form styles */
  .form-group { margin-bottom: 16px; }
  .form-group label {
    display: block;
    font-size: 14px;
    font-weight: 500;
    margin-bottom: 6px;
    color: #c9d1d9;
  }
  .form-input, .form-textarea, .form-select {
    width: 100%;
    padding: 10px 14px;
    background: #0d1117;
    border: 1px solid #30363d;
    border-radius: 6px;
    color: #c9d1d9;
    font-size: 14px;
    font-family: inherit;
  }
  .form-input:focus, .form-textarea:focus, .form-select:focus {
    outline: none;
    border-color: #58a6ff;
  }
  .form-textarea { resize: vertical; min-height: 60px; }

  /* Button styles */
  .btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 8px 16px;
    border: none;
    border-radius: 6px;
    font-size: 14px;
    font-weight: 500;
    cursor: pointer;
    font-family: inherit;
  }
  .btn-primary { background: #238636; color: white; }
  .btn-primary:hover { background: #2ea043; }
  .btn-secondary { background: #21262d; color: #c9d1d9; border: 1px solid #30363d; }
  .btn-secondary:hover { background: #30363d; }
  .btn-danger { background: #da3633; color: white; }
  .btn-danger:hover { background: #f85149; }
  .btn-sm { padding: 4px 10px; font-size: 12px; }
  .btn:disabled { opacity: 0.5; cursor: not-allowed; }

  /* Inline form panel */
  .admin-panel {
    display: none;
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 20px;
    margin-bottom: 20px;
  }
  .admin-panel.visible { display: block; }
  .admin-panel h3 { margin-bottom: 16px; font-size: 16px; }

  .admin-actions {
    display: none;
    gap: 10px;
    margin-bottom: 20px;
  }
  .admin-actions.visible { display: flex; }

  /* Remove button on cards */
  .remove-btn {
    background: none;
    border: none;
    color: #8b949e;
    cursor: pointer;
    font-size: 16px;
    padding: 4px 8px;
    border-radius: 4px;
  }
  .remove-btn:hover { background: #da363355; color: #f85149; }

  /* Editable header */
  .editable-header {
    display: flex;
    align-items: center;
    gap: 12px;
    margin-bottom: 10px;
  }
  .edit-icon {
    display: none;
    background: none;
    border: none;
    color: #8b949e;
    cursor: pointer;
    font-size: 14px;
    padding: 4px 8px;
  }
  .edit-icon:hover { color: #58a6ff; }

  /* Modal */
  .modal-overlay {
    display: none;
    position: fixed;
    top: 0; left: 0; right: 0; bottom: 0;
    background: rgba(0, 0, 0, 0.7);
    z-index: 1000;
    align-items: center;
    justify-content: center;
  }
  .modal-overlay.visible { display: flex; }
  .modal-box {
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 6px;
    padding: 24px;
    max-width: 500px;
    width: 90%;
  }
  .modal-box h3 { margin-bottom: 16px; }
  .modal-actions {
    display: flex;
    gap: 10px;
    justify-content: flex-end;
    margin-top: 20px;
  }

  /* Searchable dropdown */
  .dropdown-container { position: relative; }
  .dropdown-list {
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 0 0 6px 6px;
    max-height: 200px;
    overflow-y: auto;
    z-index: 10;
    display: none;
  }
  .dropdown-list.visible { display: block; }
  .dropdown-item {
    padding: 8px 14px;
    cursor: pointer;
    font-size: 14px;
  }
  .dropdown-item:hover { background: #21262d; }

  /* Status banner */
  .status-banner {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 10px 16px;
    border-radius: 6px;
    font-size: 13px;
    margin-bottom: 20px;
    border: 1px solid #30363d;
    background: #161b22;
  }
  .status-dot {
    width: 8px;
    height: 8px;
    border-radius: 50%;
    flex-shrink: 0;
  }
  .status-dot.shared { background: #238636; }
  .status-dot.local { background: #8b949e; }
  .source-badge {
    display: inline-block;
    padding: 1px 6px;
    border-radius: 8px;
    font-size: 10px;
    font-weight: 600;
    text-transform: uppercase;
    margin-left: 6px;
    vertical-align: middle;
  }
  .source-badge.shared { background: #238636; color: white; }
  .source-badge.local { background: #30363d; color: #8b949e; }

  /* Linked servers section */
  .server-card {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 16px;
    background: #0d1117;
    border: 1px solid #30363d;
    border-radius: 6px;
    font-size: 13px;
    margin-bottom: 6px;
  }
  .server-card .server-key {
    font-family: monospace;
    color: #58a6ff;
    font-size: 12px;
  }
  .server-card .server-self {
    color: #238636;
    font-size: 11px;
    font-weight: 600;
  }

  /* Remote server catalog sections */
  .remote-server-section {
    margin-top: 30px;
    padding-top: 20px;
    border-top: 1px solid #30363d;
  }
  .remote-server-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 15px;
    cursor: pointer;
  }
  .remote-server-header:hover .remote-server-name { color: #79c0ff; }
  .remote-server-name {
    font-size: 18px;
    font-weight: 600;
    color: #58a6ff;
  }
  .remote-server-badge {
    display: inline-block;
    padding: 2px 8px;
    border-radius: 12px;
    font-size: 11px;
    font-weight: 600;
  }
  .remote-server-badge.online { background: #238636; color: white; }
  .remote-server-badge.offline { background: #484f58; color: #8b949e; }
  .remote-server-badge.loading { background: #30363d; color: #8b949e; }
  .remote-group-card {
    background: #0d1117;
    border: 1px solid #21262d;
    border-radius: 6px;
    padding: 16px;
    margin-bottom: 8px;
  }
  .remote-group-name {
    font-size: 15px;
    font-weight: 600;
    color: #c9d1d9;
    margin-bottom: 4px;
  }
  .remote-group-repos {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-top: 8px;
  }
  .remote-repo-chip {
    display: inline-block;
    padding: 3px 10px;
    background: #161b22;
    border: 1px solid #30363d;
    border-radius: 12px;
    font-size: 12px;
    color: #58a6ff;
    text-decoration: none;
  }
  .remote-repo-chip:hover { border-color: #58a6ff; text-decoration: none; }
  .remote-collapse-icon {
    font-size: 12px;
    color: #8b949e;
    transition: transform 0.2s;
  }
  .remote-collapse-icon.collapsed { transform: rotate(-90deg); }

  /* Toast notification */
  .toast {
    position: fixed;
    bottom: 20px;
    right: 20px;
    background: #238636;
    color: white;
    padding: 12px 20px;
    border-radius: 6px;
    font-size: 14px;
    z-index: 2000;
    opacity: 0;
    transform: translateY(10px);
    transition: opacity 0.3s, transform 0.3s;
  }
  .toast.visible { opacity: 1; transform: translateY(0); }
  .toast.error { background: #da3633; }
`

/**
 * Render linked servers section for group detail page
 */
function renderLinkedServers (serverInfo, group) {
  if (!serverInfo || !serverInfo.writers || serverInfo.writers.length === 0) {
    return ''
  }

  const isShared = group.source === 'shared'
  if (!isShared) return ''

  // Deduplicate writers by serverKey — keep the one with highest role
  const roleRank = { owner: 3, admin: 2, writer: 1, mirror: 0 }
  const byKey = new Map()
  for (const w of serverInfo.writers) {
    const key = w.serverKey || w.peerId || 'unknown'
    if (w.removedAt) continue // skip removed writers
    const existing = byKey.get(key)
    if (!existing || (roleRank[w.role] || 0) > (roleRank[existing.role] || 0)) {
      byKey.set(key, w)
    }
  }
  const uniqueWriters = [...byKey.values()]

  const serverCards = uniqueWriters.map(w => {
    const keyHex = w.serverKey || w.peerId || 'unknown'
    const keyPrefix = keyHex.slice(0, 16)
    const isSelf = serverInfo.selfKey && keyHex === serverInfo.selfKey
    return `
      <div class="server-card">
        <span class="server-key">${escapeHtml(keyPrefix)}...</span>
        <span>(${escapeHtml(w.role || 'writer')})</span>
        ${isSelf ? '<span class="server-self">This server</span>' : ''}
      </div>`
  }).join('')

  return `
    <div style="margin-top: 30px;">
      <h2>Federation</h2>
      <p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">
        ${uniqueWriters.length} server${uniqueWriters.length !== 1 ? 's' : ''} sharing this group via P2P registry
      </p>
      ${serverCards}
    </div>`
}

/**
 * Render access grants section for group detail page
 */
function renderGrantsSection (grants, pathPrefix, groupName) {
  if (!grants || grants.length === 0) {
    return ''
  }

  const grantCards = grants.map(g => {
    const subjectShort = (g.subjectId || 'unknown').slice(0, 16)
    const permColor = g.permission === 'owner' ? '#f0883e' : g.permission === 'moderator' ? '#d29922' : '#238636'
    const typeIcon = g.subjectType === 'server' ? 'S' : 'U'
    return `
      <div class="server-card">
        <span style="background: ${permColor}; color: white; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;">${escapeHtml(typeIcon)}</span>
        <span class="server-key">${escapeHtml(subjectShort)}...</span>
        <span style="color: ${permColor};">${escapeHtml(g.permission)}</span>
        ${g.revokedAt ? '<span style="color: #da3633; font-size: 11px;">REVOKED</span>' : ''}
        <button class="remove-btn admin-ctrl" onclick="revokeGrant('${escapeHtml(g.grantId)}')" title="Revoke grant" style="display: none; margin-left: auto;">&times;</button>
      </div>`
  }).join('')

  return `
    <div style="margin-top: 30px;">
      <h2>Access Grants</h2>
      <p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;">
        ${grants.length} grant${grants.length !== 1 ? 's' : ''} — controls who can write/moderate/own
      </p>
      ${grantCards}
    </div>`
}

/**
 * Render the groups list page (/groups)
 */
export function renderGroupsPage (groups, pathPrefix = '', registryStatus = null, remotePeers = []) {
  const hasShared = registryStatus && registryStatus.active
  const statusBanner = hasShared
    ? `<div class="status-banner">
        <span class="status-dot shared"></span>
        <span>${registryStatus.writerCount} server${registryStatus.writerCount !== 1 ? 's' : ''} linked</span>
        <button class="btn btn-danger btn-sm admin-ctrl" onclick="unlinkRegistry()" style="display: none; margin-left: auto;">Unlink</button>
      </div>`
    : `<div class="status-banner">
        <span class="status-dot local"></span>
        <span>Local mode</span>
      </div>`

  const hasSharedGroups = groups.some(g => g.source === 'shared')
  const hasMixedSources = hasSharedGroups && groups.some(g => g.source === 'local')

  const federationBanner = hasShared && hasSharedGroups
    ? `<div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; margin-bottom: 16px; font-size: 13px; color: #8b949e;">
        <strong style="color: #58a6ff;">Linked servers share group visibility.</strong>
        All groups from linked servers appear here as read-only. To push code, you need an explicit write grant from the group owner.
      </div>`
    : ''

  const groupCards = groups.length > 0
    ? groups.map(g => {
      const sourceBadge = hasMixedSources && g.source
        ? `<span class="source-badge ${g.source}">${g.source}</span>`
        : ''
      return `
      <a href="${pathPrefix}/groups/${escapeHtml(g.name)}" style="text-decoration: none; color: inherit;">
        <div class="group-card">
          <div class="group-card-name">${escapeHtml(g.displayName || g.name)}${sourceBadge}</div>
          ${g.description ? `<div class="group-card-desc">${escapeHtml(g.description)}</div>` : ''}
          <div class="group-card-meta">
            <span>${g.repos.length} repo${g.repos.length !== 1 ? 's' : ''}</span>
            <span>${g.members.length} member${g.members.length !== 1 ? 's' : ''}</span>
          </div>
        </div>
      </a>
    `}).join('')
    : '<div class="empty-state" id="empty-state">No groups yet</div>'

  const content = `
    <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
      <h1>Groups</h1>
      <div style="display: flex; gap: 10px; align-items: center;">
        <button class="btn btn-secondary" onclick="showJoinModal()">Join Group</button>
        <div class="admin-actions" id="admin-actions">
          <button class="btn btn-primary" onclick="toggleCreateForm()">+ New Group</button>
        </div>
      </div>
    </div>
    <div class="subtitle">${groups.length} group${groups.length !== 1 ? 's' : ''}</div>

    ${statusBanner}
    ${federationBanner}

    <!-- Create Group Form -->
    <div class="admin-panel" id="create-form">
      <h3>Create New Group</h3>
      <div class="form-group">
        <label for="group-name">Name (lowercase, no spaces)</label>
        <input type="text" class="form-input" id="group-name" placeholder="my-project" pattern="[a-z0-9-]+" required>
      </div>
      <div class="form-group">
        <label for="group-display">Display Name</label>
        <input type="text" class="form-input" id="group-display" placeholder="My Project">
      </div>
      <div class="form-group">
        <label for="group-desc">Description</label>
        <textarea class="form-textarea" id="group-desc" placeholder="A short description..."></textarea>
      </div>
      <div style="display: flex; gap: 10px;">
        <button class="btn btn-primary" onclick="createGroup()" id="create-btn">Create Group</button>
        <button class="btn btn-secondary" onclick="toggleCreateForm()">Cancel</button>
      </div>
    </div>

    <div class="group-grid" id="group-grid">
      ${groupCards}
    </div>

    <div id="remote-servers"></div>
  </div>

  <!-- Join Group Modal -->
  <div class="modal-overlay" id="join-modal">
    <div class="modal-box" style="max-width: 600px;">
      <!-- Step 1: Enter Invite -->
      <div id="join-step-invite">
        <h3>Join Group</h3>
        <p style="color: #8b949e; font-size: 13px; margin-bottom: 16px;">Paste an invite code from another server to join their group.</p>
        <div class="form-group">
          <label for="join-invite-hex">Invite Code</label>
          <textarea class="form-textarea" id="join-invite-hex" style="font-family: monospace; min-height: 80px;" placeholder="Paste the invite hex from the other server..."></textarea>
        </div>
        <div class="modal-actions">
          <button class="btn btn-secondary" onclick="hideJoinModal()">Cancel</button>
          <button class="btn btn-primary" onclick="connectWithInvite()" id="join-connect-btn">Connect</button>
        </div>
      </div>

      <!-- Step 2: Preview & Select Repos -->
      <div id="join-step-preview" style="display: none;">
        <h3>Select Repos to Sync</h3>
        <div id="join-group-preview" style="background: #0d1117; border: 1px solid #30363d; border-radius: 6px; padding: 16px; margin-bottom: 16px;"></div>
        <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px;">
          <label style="font-size: 14px;">Repositories</label>
          <label style="font-size: 12px; color: #8b949e; cursor: pointer;">
            <input type="checkbox" id="join-select-all" checked onchange="toggleAllJoinRepos()"> Select All
          </label>
        </div>
        <div id="join-repo-list" style="max-height: 250px; overflow-y: auto;"></div>
        <div class="modal-actions">
          <button class="btn btn-secondary" onclick="joinGoBack()">Back</button>
          <button class="btn btn-primary" onclick="confirmJoin()" id="join-confirm-btn">Confirm Join</button>
        </div>
      </div>

      <!-- Step 3: Success -->
      <div id="join-step-success" style="display: none;">
        <div style="text-align: center; padding: 20px 0;">
          <h3 style="color: #238636; font-size: 20px; margin-bottom: 10px;">Joined!</h3>
          <p style="color: #8b949e; margin-bottom: 16px;" id="join-success-msg"></p>
          <button class="btn btn-primary" onclick="hideJoinModal(); window.location.reload();">View Groups</button>
        </div>
      </div>
    </div>
  </div>

  <div id="toast" class="toast"></div>
  `

  const scripts = `
    let isAdmin = false;
    let joinResult = null;
    const remotePeers = ${JSON.stringify(remotePeers)};

    // --- Join Group Modal ---
    function showJoinModal() {
      joinResult = null;
      document.getElementById('join-invite-hex').value = '';
      document.getElementById('join-step-invite').style.display = '';
      document.getElementById('join-step-preview').style.display = 'none';
      document.getElementById('join-step-success').style.display = 'none';
      document.getElementById('join-modal').classList.add('visible');
    }
    function hideJoinModal() {
      document.getElementById('join-modal').classList.remove('visible');
    }

    async function connectWithInvite() {
      var invite = document.getElementById('join-invite-hex').value.trim();
      if (!invite) { showToast('Paste an invite code first', true); return; }

      var btn = document.getElementById('join-connect-btn');
      btn.disabled = true;
      btn.textContent = 'Connecting...';

      try {
        var res = await window.fetch(getBasePath() + '/api/groups/join', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ invite: invite })
        });

        var data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Failed to connect');

        joinResult = data;

        var preview = document.getElementById('join-group-preview');
        if (data.group) {
          preview.innerHTML =
            '<h4 style="color: #58a6ff; margin-bottom: 6px;">' + escapeHtml(data.group.displayName || data.group.name || data.groupName) + '</h4>' +
            (data.group.description ? '<p style="color: #8b949e; font-size: 13px;">' + escapeHtml(data.group.description) + '</p>' : '') +
            '<p style="color: #8b949e; font-size: 13px; margin-top: 6px;">' + (data.group.repos ? data.group.repos.length : 0) + ' repos, ' +
            (data.group.members ? data.group.members.length : 0) + ' members</p>';

          var repoList = document.getElementById('join-repo-list');
          if (data.group.repos && data.group.repos.length > 0) {
            repoList.innerHTML = data.group.repos.map(function(name) {
              return '<div style="display: flex; align-items: center; gap: 10px; padding: 8px 12px; background: #0d1117; border: 1px solid #30363d; border-radius: 6px; margin-bottom: 6px;">' +
                '<input type="checkbox" class="join-repo-cb" value="' + escapeHtml(name) + '" checked style="accent-color: #238636;">' +
                '<label style="font-size: 14px;">' + escapeHtml(name) + '</label>' +
                '</div>';
            }).join('');
          } else {
            repoList.innerHTML = '<p style="color: #8b949e;">No repos in this group yet</p>';
          }
        } else {
          preview.innerHTML = '<h4 style="color: #58a6ff;">Registry Connected</h4><p style="color: #8b949e; font-size: 13px;">Group data will sync shortly.</p>';
          document.getElementById('join-repo-list').innerHTML = '<p style="color: #8b949e;">Syncing...</p>';
        }

        document.getElementById('join-step-invite').style.display = 'none';
        document.getElementById('join-step-preview').style.display = '';
      } catch (err) {
        showToast(err.message, true);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Connect';
      }
    }

    function toggleAllJoinRepos() {
      var checked = document.getElementById('join-select-all').checked;
      document.querySelectorAll('.join-repo-cb').forEach(function(cb) { cb.checked = checked; });
    }

    async function confirmJoin() {
      var selectedRepos = [];
      document.querySelectorAll('.join-repo-cb:checked').forEach(function(cb) {
        selectedRepos.push(cb.value);
      });

      var groupName = joinResult && joinResult.groupName;

      if (groupName && selectedRepos.length > 0) {
        var btn = document.getElementById('join-confirm-btn');
        btn.disabled = true;
        btn.textContent = 'Saving...';

        try {
          var res = await window.fetch(getBasePath() + '/api/groups/' + encodeURIComponent(groupName) + '/sync', {
            method: 'PATCH',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ repos: selectedRepos })
          });
          if (!res.ok) {
            var err = await res.json();
            throw new Error(err.error || 'Failed to save sync config');
          }
        } catch (err) {
          showToast(err.message, true);
          btn.disabled = false;
          btn.textContent = 'Confirm Join';
          return;
        }
      }

      document.getElementById('join-success-msg').textContent =
        groupName
          ? 'Joined group "' + groupName + '" — syncing ' + selectedRepos.length + ' repo(s)'
          : 'Connected to shared registry';

      document.getElementById('join-step-preview').style.display = 'none';
      document.getElementById('join-step-success').style.display = '';
    }

    function joinGoBack() {
      document.getElementById('join-step-preview').style.display = 'none';
      document.getElementById('join-step-invite').style.display = '';
    }

    function escapeHtml(text) {
      if (!text) return '';
      return String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
    }

    // Close modal on overlay click
    document.addEventListener('click', function(e) {
      if (e.target.classList.contains('modal-overlay')) {
        e.target.classList.remove('visible');
      }
    });

    async function loadUserIdentity() {
      if (window.identityReady) await window.identityReady;
      try {
        const response = await window.fetch(getBasePath() + '/api/user/identities');
        if (!response.ok) return;
        const data = await response.json();
        const hasAdmin = data.permissions.global.includes('admin') ||
                         data.permissions.repos.some(r => r.permissions.includes('admin'));
        if (hasAdmin) {
          isAdmin = true;
          document.getElementById('admin-actions').classList.add('visible');
          document.querySelectorAll('.admin-ctrl').forEach(function(el) { el.style.display = ''; });
        }
      } catch (err) {
        console.error('Failed to load identity:', err);
      }
    }
    loadUserIdentity();

    // --- Remote Server Catalogs ---
    async function loadRemoteServers() {
      try {
        if (!remotePeers || remotePeers.length === 0) return;

        var container = document.getElementById('remote-servers');

        for (var i = 0; i < remotePeers.length; i++) {
          var peer = remotePeers[i];
          var sectionId = 'remote-' + peer.serverKey.slice(0, 12);
          var peerName = peer.displayName || peer.serverKey.slice(0, 12) + '...';

          container.innerHTML += '<div class="remote-server-section" id="' + sectionId + '">' +
            '<div class="remote-server-header" onclick="toggleRemoteSection(\\'' + sectionId + '\\')">' +
              '<span class="remote-collapse-icon" id="' + sectionId + '-icon">&#9660;</span>' +
              '<span class="remote-server-name">' + escapeHtml(peerName) + '</span>' +
              '<span class="remote-server-badge loading" id="' + sectionId + '-badge">loading</span>' +
            '</div>' +
            '<div id="' + sectionId + '-content" style="color: #8b949e; font-size: 13px;">Loading catalog...</div>' +
          '</div>';

          fetchCatalog(peer.serverKey, peerName, sectionId);
        }
      } catch (err) {
        console.error('Failed to load remote servers:', err);
      }
    }

    async function fetchCatalog(key, name, sectionId) {
      try {
        var res = await window.fetch(getBasePath() + '/api/federation/peers/' + encodeURIComponent(key) + '/catalog');
        var data = await res.json();

        var badge = document.getElementById(sectionId + '-badge');
        var content = document.getElementById(sectionId + '-content');

        if (data.offline || data.error) {
          badge.className = 'remote-server-badge offline';
          badge.textContent = 'offline';
          content.innerHTML = '<div style="color: #8b949e; font-size: 13px; padding: 10px 0;">Server is offline or unreachable</div>';
          return;
        }

        badge.className = 'remote-server-badge online';
        badge.textContent = (data.repos ? data.repos.length : 0) + ' repos';

        // Update header with server's actual hostname
        if (data.serverName) {
          var header = document.querySelector('#' + sectionId + ' .remote-server-name');
          if (header) header.textContent = data.serverName;
        }

        var html = '';
        if (data.groups && data.groups.length > 0) {
          for (var i = 0; i < data.groups.length; i++) {
            var g = data.groups[i];
            html += '<div class="remote-group-card">' +
              '<div class="remote-group-name">' + escapeHtml(g.displayName || g.name) +
                '<span style="color: #8b949e; font-weight: 400; font-size: 12px; margin-left: 8px;">' + (g.repos ? g.repos.length : 0) + ' repos</span>' +
              '</div>' +
              (g.description ? '<div style="color: #8b949e; font-size: 12px;">' + escapeHtml(g.description) + '</div>' : '');

            if (g.repos && g.repos.length > 0) {
              html += '<div class="remote-group-repos">';
              for (var j = 0; j < g.repos.length; j++) {
                var repoName = g.repos[j];
                html += '<a class="remote-repo-chip" href="' + getBasePath() + '/' + encodeURIComponent(g.name) + '/' + encodeURIComponent(repoName) + '?remote=' + encodeURIComponent(key) + '">' + escapeHtml(repoName) + '</a>';
              }
              html += '</div>';
            }
            html += '</div>';
          }
        }

        // Show ungrouped repos
        var groupedRepos = new Set();
        if (data.groups) {
          for (var gi = 0; gi < data.groups.length; gi++) {
            var gr = data.groups[gi];
            if (gr.repos) {
              for (var ri = 0; ri < gr.repos.length; ri++) {
                groupedRepos.add(gr.repos[ri]);
              }
            }
          }
        }
        var ungrouped = (data.repos || []).filter(function(r) { return !groupedRepos.has(r.name) && r.visibility !== 'private'; });
        if (ungrouped.length > 0) {
          html += '<div class="remote-group-card">' +
            '<div class="remote-group-name">Other Repos' +
              '<span style="color: #8b949e; font-weight: 400; font-size: 12px; margin-left: 8px;">' + ungrouped.length + ' repos</span>' +
            '</div>' +
            '<div class="remote-group-repos">';
          for (var u = 0; u < ungrouped.length; u++) {
            html += '<a class="remote-repo-chip" href="' + getBasePath() + '/local/' + encodeURIComponent(ungrouped[u].name) + '?remote=' + encodeURIComponent(key) + '">' + escapeHtml(ungrouped[u].name) + '</a>';
          }
          html += '</div></div>';
        }

        content.innerHTML = html || '<div style="color: #8b949e; font-size: 13px; padding: 10px 0;">No groups or repos found</div>';
      } catch (err) {
        var badge = document.getElementById(sectionId + '-badge');
        var content = document.getElementById(sectionId + '-content');
        if (badge) { badge.className = 'remote-server-badge offline'; badge.textContent = 'offline'; }
        if (content) { content.innerHTML = '<div style="color: #8b949e; font-size: 13px; padding: 10px 0;">Failed to load: ' + escapeHtml(err.message) + '</div>'; }
      }
    }

    function toggleRemoteSection(sectionId) {
      var content = document.getElementById(sectionId + '-content');
      var icon = document.getElementById(sectionId + '-icon');
      if (content.style.display === 'none') {
        content.style.display = '';
        icon.classList.remove('collapsed');
      } else {
        content.style.display = 'none';
        icon.classList.add('collapsed');
      }
    }

    loadRemoteServers();

    function toggleCreateForm() {
      document.getElementById('create-form').classList.toggle('visible');
    }

    async function createGroup() {
      const name = document.getElementById('group-name').value.trim();
      const displayName = document.getElementById('group-display').value.trim();
      const description = document.getElementById('group-desc').value.trim();

      if (!name) { showToast('Name is required', true); return; }
      if (!/^[a-z0-9][a-z0-9-]*$/.test(name)) {
        showToast('Name must be lowercase letters, numbers, and hyphens', true);
        return;
      }

      const btn = document.getElementById('create-btn');
      btn.disabled = true;
      btn.textContent = 'Creating...';

      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ name, type: 'group', displayName: displayName || name, description })
        });

        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to create group');
        }

        showToast('Group created!');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
        btn.disabled = false;
        btn.textContent = 'Create Group';
      }
    }

    async function unlinkRegistry() {
      if (!confirm('Disconnect from the shared registry? Local groups will remain.')) return;
      try {
        const res = await window.fetch(getBasePath() + '/api/registry/unlink', { method: 'DELETE' });
        const data = await res.json();
        if (!res.ok) throw new Error(data.error || 'Failed to unlink');
        showToast('Unlinked from shared registry');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
      }
    }

    function showToast(msg, isError) {
      const toast = document.getElementById('toast');
      toast.textContent = msg;
      toast.className = 'toast visible' + (isError ? ' error' : '');
      setTimeout(() => { toast.className = 'toast'; }, 3000);
    }

    function getBasePath() { return window.__PATH_PREFIX__ || ''; }
  `

  return renderPage('Groups', content, { pathPrefix, activePage: 'groups', css: PAGE_STYLES, scripts })
}

/**
 * Render a single group detail page (/groups/:name)
 */
export function renderGroupPage (group, repos, allRepos = [], pathPrefix = '', serverInfo = null, grants = [], federationPeers = []) {
  // P2P-first: use federation peer server keys for punch:// URLs
  const peerKeys = federationPeers.map(p => p.serverKey).filter(Boolean)
  const primaryPeerKey = peerKeys[0] || null
  // Repos already in this group
  const groupRepoNames = new Set(group.repos || [])

  // Available repos for "add repo" dropdown (not already in a group namespace)
  const availableRepos = allRepos.filter(r => !groupRepoNames.has(r.name))

  const repoCards = repos.length > 0
    ? repos.map(r => {
      const isLocal = r.local !== false
      const syncBadge = !isLocal ? '<span class="badge" style="background: #30363d; color: #8b949e; font-size: 11px;">remote</span>' : ''
      const syncBtn = !isLocal ? `<button class="btn btn-primary btn-sm" onclick="syncRepo('${escapeHtml(r.name)}')" title="Clone this repo locally" style="font-size: 11px; padding: 2px 8px;">Sync</button>` : ''
      const requestBtn = !isLocal ? `<button class="btn btn-secondary btn-sm" onclick="requestRepoAccess('${escapeHtml(r.name)}')" title="Request write access" style="font-size: 11px; padding: 2px 8px;">Request Write</button>` : ''
      const punchUrl = (!isLocal && primaryPeerKey) ? `punch://${primaryPeerKey}/${r.name}` : null
      const repoLink = `<a href="${pathPrefix}/${escapeHtml(group.name)}/${escapeHtml(r.name)}" class="repo-name">${escapeHtml(r.displayName || r.name)}</a>`
      return `
      <div class="repo-card" id="repo-card-${escapeHtml(r.name)}">
        <div style="flex: 1; min-width: 0;">
          ${repoLink} ${syncBadge}
          ${punchUrl ? `<div style="margin-top: 4px; display: flex; align-items: center; gap: 6px;">
            <code style="font-size: 11px; color: #58a6ff; background: #0d1117; padding: 2px 8px; border-radius: 4px; border: 1px solid #30363d; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 400px;" title="${escapeHtml(punchUrl)}">${escapeHtml(punchUrl)}</code>
            <button class="btn btn-secondary btn-sm" onclick="copyText('${escapeHtml(punchUrl)}')" style="font-size: 10px; padding: 1px 6px; flex-shrink: 0;">Copy</button>
          </div>` : ''}
          <div class="repo-meta">
            ${isLocal ? `<span>${r.commits || r.commitCount || 0} commits</span><span>${r.branches || 0} branches</span>` : (punchUrl ? '<span>P2P — clone with git clone &lt;url&gt;</span>' : '<span>Remote — no peer key available</span>')}
            ${r.visibility && r.visibility !== 'public' ? `<span class="badge">${escapeHtml(r.visibility)}</span>` : ''}
          </div>
        </div>
        <div style="display: flex; gap: 6px; align-items: center; flex-shrink: 0;">
          ${syncBtn}
          ${requestBtn}
          <button class="btn btn-secondary btn-sm admin-ctrl" onclick="showAccessInviteModal('repo', '${escapeHtml(r.name)}')" title="Create access invite for this repo" style="display: none; font-size: 11px; padding: 2px 8px;">Invite</button>
          <button class="remove-btn admin-ctrl" onclick="removeRepo('${escapeHtml(r.name)}')" title="Remove from group" style="display: none;">&times;</button>
        </div>
      </div>`
    }).join('')
    : `<div class="empty-state" id="empty-repos">No repos in this group yet</div>`

  const memberBadges = group.members.length > 0
    ? group.members.map(m => `
        <span class="member-badge ${m.role === 'admin' ? 'admin' : ''}" id="member-${escapeHtml(m.userId.slice(0, 12))}">
          ${escapeHtml(m.userId.slice(0, 12))}... (${m.role})
          <button class="remove-btn admin-ctrl" onclick="removeMember('${escapeHtml(m.userId)}')" title="Remove member" style="display: none;">&times;</button>
        </span>
      `).join('')
    : '<span style="color: #8b949e;">No members</span>'

  const content2 = `
    <div class="editable-header">
      <h1 id="group-title">${escapeHtml(group.displayName || group.name)}</h1>
      <button class="edit-icon admin-ctrl" onclick="showEditModal()" title="Edit group" style="display: none;">&#9998;</button>
    </div>
    ${group.description ? `<div class="subtitle" id="group-description">${escapeHtml(group.description)}</div>` : '<div class="subtitle" id="group-description"></div>'}

    <div style="display: flex; gap: 15px; margin-bottom: 25px; font-size: 14px; color: #8b949e;">
      <span id="repo-count">${repos.length} repo${repos.length !== 1 ? 's' : ''}</span>
      <span>${group.members.length} member${group.members.length !== 1 ? 's' : ''}</span>
      <span>Type: ${group.type}</span>
      ${group.source === 'shared' ? '<span class="source-badge shared">shared</span>' : ''}
    </div>
    ${repos.some(r => r.local === false) && primaryPeerKey ? `<div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px; margin-bottom: 16px; font-size: 13px; color: #8b949e;">
        <strong style="color: #58a6ff;">P2P Federation</strong> — ${peerKeys.length} linked server${peerKeys.length !== 1 ? 's' : ''}.
        Remote repos are cloneable via <code style="color: #58a6ff;">punch://</code> URLs.
        <strong>Sync</strong> clones to this server. <strong>Request Write</strong> asks for push access.
      </div>` : ''}

    <!-- Admin Actions -->
    <div class="admin-actions" id="admin-actions">
      <button class="btn btn-primary btn-sm" onclick="showAddRepoDropdown()">+ Add Repo</button>
      <button class="btn btn-secondary btn-sm" onclick="showAddMemberModal()">+ Add Member</button>
      <button class="btn btn-secondary btn-sm" onclick="showAccessInviteModal('group')">Invite to Group</button>
      <button class="btn btn-secondary btn-sm" onclick="createServerInvite()">Invite Server</button>
      <button class="btn btn-danger btn-sm" onclick="confirmDeleteGroup()" id="delete-group-btn">Delete Group</button>
    </div>

    <!-- Request Access (shown to non-admins on federated groups) -->
    <div id="request-access-section" style="margin-bottom: 16px; display: none;">
      <div style="background: #161b22; border: 1px solid #30363d; border-radius: 6px; padding: 12px 16px;">
        <div style="display: flex; align-items: center; gap: 10px; flex-wrap: wrap;">
          <span style="color: #8b949e; font-size: 13px;">Want write access?</span>
          <select id="request-permission" style="background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 4px; padding: 4px 8px; font-size: 12px;">
            <option value="write">Write</option>
            <option value="moderator">Moderator</option>
          </select>
          <select id="request-as" style="background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 4px; padding: 4px 8px; font-size: 12px;">
            <option value="server">As this server</option>
            <option value="user">As my user</option>
          </select>
          <button class="btn btn-primary btn-sm" onclick="requestGroupAccess()">Request Access</button>
        </div>
        <input type="text" id="request-reason" placeholder="Optional: why do you need access?" style="margin-top: 8px; width: 100%; background: #0d1117; color: #c9d1d9; border: 1px solid #30363d; border-radius: 4px; padding: 6px 10px; font-size: 12px; box-sizing: border-box;">
      </div>
    </div>

    <!-- Pending Requests (admin view) -->
    <div class="admin-panel" id="pending-requests-panel" style="display: none;">
      <h3 style="color: #f0883e;">Pending Access Requests</h3>
      <div id="pending-requests-list"></div>
    </div>

    <!-- Access Invite Result -->
    <div class="admin-panel" id="invite-panel">
      <h3 id="invite-panel-title">Invite</h3>
      <p style="color: #8b949e; font-size: 13px; margin-bottom: 12px;" id="invite-panel-desc">Share this invite code. The recipient can use it to get access.</p>
      <div style="background: #0d1117; padding: 12px; border: 1px solid #30363d; border-radius: 6px; font-family: monospace; font-size: 12px; word-break: break-all;" id="invite-hex"></div>
      <div style="display: flex; gap: 10px; margin-top: 10px;">
        <button class="btn btn-secondary btn-sm" onclick="copyInviteHex()">Copy</button>
        <button class="btn btn-secondary btn-sm" onclick="hideInvitePanel()">Close</button>
      </div>
    </div>

    <!-- Add Repo Panel -->
    <div class="admin-panel" id="add-repo-panel">
      <h3>Add Repository to Group</h3>
      <div class="form-group dropdown-container">
        <label>Search repositories</label>
        <input type="text" class="form-input" id="repo-search" placeholder="Type to search..." oninput="filterRepos(this.value)" onfocus="showRepoDropdown()">
        <div class="dropdown-list" id="repo-dropdown">
          ${availableRepos.map(r => `
            <div class="dropdown-item" onclick="addRepo('${escapeHtml(r.name)}')">${escapeHtml(r.name)}</div>
          `).join('')}
        </div>
      </div>
      <button class="btn btn-secondary btn-sm" onclick="hideAddRepoPanel()">Cancel</button>
    </div>

    <h2>Repositories</h2>
    <div class="repo-list" style="margin-bottom: 30px;" id="repo-list">
      ${repoCards}
    </div>

    <div>
      <h2>Members</h2>
      <div class="member-list" id="member-list">
        ${memberBadges}
      </div>
    </div>

    ${renderLinkedServers(serverInfo, group)}
    ${renderGrantsSection(grants, pathPrefix, group.name)}
  </div>

  <!-- Edit Group Modal -->
  <div class="modal-overlay" id="edit-modal">
    <div class="modal-box">
      <h3>Edit Group</h3>
      <div class="form-group">
        <label for="edit-display">Display Name</label>
        <input type="text" class="form-input" id="edit-display" value="${escapeHtml(group.displayName || group.name)}">
      </div>
      <div class="form-group">
        <label for="edit-desc">Description</label>
        <textarea class="form-textarea" id="edit-desc">${escapeHtml(group.description || '')}</textarea>
      </div>
      <div class="modal-actions">
        <button class="btn btn-secondary" onclick="hideEditModal()">Cancel</button>
        <button class="btn btn-primary" onclick="saveGroupEdit()" id="save-edit-btn">Save</button>
      </div>
    </div>
  </div>

  <!-- Add Member Modal -->
  <div class="modal-overlay" id="member-modal">
    <div class="modal-box">
      <h3>Add Member</h3>
      <div class="form-group">
        <label for="member-id">User ID (public key)</label>
        <input type="text" class="form-input" id="member-id" placeholder="Paste user's public key...">
      </div>
      <div class="form-group">
        <label for="member-role">Role</label>
        <select class="form-select" id="member-role">
          <option value="member">Member</option>
          <option value="admin">Admin</option>
        </select>
      </div>
      <div class="modal-actions">
        <button class="btn btn-secondary" onclick="hideMemberModal()">Cancel</button>
        <button class="btn btn-primary" onclick="addMember()" id="add-member-btn">Add Member</button>
      </div>
    </div>
  </div>

  <!-- Confirm Delete Modal -->
  <div class="modal-overlay" id="delete-modal">
    <div class="modal-box">
      <h3>Delete Group</h3>
      <p style="margin-bottom: 16px; color: #8b949e;">Are you sure you want to delete <strong style="color: #c9d1d9;">${escapeHtml(group.name)}</strong>? This action cannot be undone.</p>
      <div class="modal-actions">
        <button class="btn btn-secondary" onclick="hideDeleteModal()">Cancel</button>
        <button class="btn btn-danger" onclick="deleteGroup()" id="confirm-delete-btn">Delete</button>
      </div>
    </div>
  </div>

  <!-- Access Invite Modal -->
  <div class="modal-overlay" id="access-invite-modal">
    <div class="modal-box">
      <h3 id="access-invite-title">Create Access Invite</h3>
      <div class="form-group">
        <label for="access-invite-perm">Permission</label>
        <select class="form-select" id="access-invite-perm">
          <option value="write">Write — push commits, create branches</option>
          <option value="moderator">Moderator — manage settings, moderate issues</option>
          <option value="owner">Owner — full control, grant permissions</option>
        </select>
      </div>
      <div class="form-group" id="access-invite-repo-field" style="display: none;">
        <label for="access-invite-repo">Repository</label>
        <select class="form-select" id="access-invite-repo">
          ${(group.repos || []).map(r => `<option value="${escapeHtml(r)}">${escapeHtml(r)}</option>`).join('')}
        </select>
      </div>
      <div class="form-group">
        <label for="access-invite-type">Invite For</label>
        <select class="form-select" id="access-invite-type">
          <option value="user">Individual user</option>
          <option value="server">Entire server (all users)</option>
        </select>
      </div>
      <div class="modal-actions">
        <button class="btn btn-secondary" onclick="hideAccessInviteModal()">Cancel</button>
        <button class="btn btn-primary" onclick="createAccessInvite()" id="create-access-invite-btn">Create Invite</button>
      </div>
    </div>
  </div>

  <div id="toast" class="toast"></div>
  `

  const scripts2 = `
    const groupName = ${JSON.stringify(group.name)};
    const groupRepos = ${JSON.stringify(group.repos || [])};
    const availableRepos = ${JSON.stringify(availableRepos.map(r => r.name))};
    const peerKeys = ${JSON.stringify(peerKeys)};
    const primaryPeerKey = ${JSON.stringify(primaryPeerKey)};
    let isAdmin = false;

    function copyText(text) {
      if (navigator.clipboard) {
        navigator.clipboard.writeText(text).then(function() { showToast('Copied: ' + text); });
      } else {
        showToast(text);
      }
    }

    function getBasePath() { return window.__PATH_PREFIX__ || ''; }

    async function loadUserIdentity() {
      if (window.identityReady) await window.identityReady;
      try {
        const response = await window.fetch(getBasePath() + '/api/user/identities');
        if (!response.ok) return;
        const data = await response.json();
        const hasAdmin = data.permissions.global.includes('admin') ||
                         data.permissions.repos.some(r => r.permissions.includes('admin'));
        if (hasAdmin) {
          isAdmin = true;
          document.getElementById('admin-actions').classList.add('visible');
          // Show all admin controls
          document.querySelectorAll('.admin-ctrl').forEach(el => { el.style.display = ''; });
        }
      } catch (err) {
        console.error('Failed to load identity:', err);
      }
    }
    loadUserIdentity();

    // --- Edit Group ---
    function showEditModal() { document.getElementById('edit-modal').classList.add('visible'); }
    function hideEditModal() { document.getElementById('edit-modal').classList.remove('visible'); }

    async function saveGroupEdit() {
      const displayName = document.getElementById('edit-display').value.trim();
      const description = document.getElementById('edit-desc').value.trim();
      const btn = document.getElementById('save-edit-btn');
      btn.disabled = true;
      btn.textContent = 'Saving...';

      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName), {
          method: 'PATCH',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ displayName, description })
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to update');
        }
        document.getElementById('group-title').textContent = displayName || groupName;
        document.getElementById('group-description').textContent = description;
        hideEditModal();
        showToast('Group updated');
      } catch (err) {
        showToast(err.message, true);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Save';
      }
    }

    // --- Add Repo ---
    function showAddRepoDropdown() {
      document.getElementById('add-repo-panel').classList.add('visible');
      document.getElementById('repo-search').focus();
    }
    function hideAddRepoPanel() {
      document.getElementById('add-repo-panel').classList.remove('visible');
      document.getElementById('repo-dropdown').classList.remove('visible');
    }
    function showRepoDropdown() {
      document.getElementById('repo-dropdown').classList.add('visible');
    }
    function filterRepos(query) {
      const dropdown = document.getElementById('repo-dropdown');
      const q = query.toLowerCase();
      const filtered = availableRepos.filter(name => name.toLowerCase().includes(q));
      dropdown.innerHTML = filtered.length > 0
        ? filtered.map(name => '<div class="dropdown-item" onclick="addRepo(\\'' + name + '\\')">' + name + '</div>').join('')
        : '<div class="dropdown-item" style="color: #8b949e;">No matches</div>';
      dropdown.classList.add('visible');
    }

    async function addRepo(repoName) {
      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName) + '/repos', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ repoName })
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to add repo');
        }
        showToast('Repo added!');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
      }
    }

    async function removeRepo(repoName) {
      if (!confirm('Remove ' + repoName + ' from this group?')) return;
      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName) + '/repos/' + encodeURIComponent(repoName), {
          method: 'DELETE'
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to remove repo');
        }
        const card = document.getElementById('repo-card-' + repoName);
        if (card) card.remove();
        showToast('Repo removed');
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // --- Members ---
    function showAddMemberModal() { document.getElementById('member-modal').classList.add('visible'); }
    function hideMemberModal() { document.getElementById('member-modal').classList.remove('visible'); }

    async function addMember() {
      const userId = document.getElementById('member-id').value.trim();
      const role = document.getElementById('member-role').value;
      if (!userId) { showToast('User ID is required', true); return; }

      const btn = document.getElementById('add-member-btn');
      btn.disabled = true;
      btn.textContent = 'Adding...';

      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName) + '/members', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ userId, role })
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to add member');
        }
        showToast('Member added!');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Add Member';
      }
    }

    async function removeMember(userId) {
      if (!confirm('Remove this member?')) return;
      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName) + '/members/' + encodeURIComponent(userId), {
          method: 'DELETE'
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to remove member');
        }
        showToast('Member removed');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // --- Delete Group ---
    function confirmDeleteGroup() { document.getElementById('delete-modal').classList.add('visible'); }
    function hideDeleteModal() { document.getElementById('delete-modal').classList.remove('visible'); }

    async function deleteGroup() {
      const btn = document.getElementById('confirm-delete-btn');
      btn.disabled = true;
      btn.textContent = 'Deleting...';

      try {
        const res = await window.fetch(getBasePath() + '/api/namespaces/' + encodeURIComponent(groupName), {
          method: 'DELETE'
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to delete group');
        }
        showToast('Group deleted');
        setTimeout(() => { window.location.href = getBasePath() + '/groups'; }, 500);
      } catch (err) {
        showToast(err.message, true);
        btn.disabled = false;
        btn.textContent = 'Delete';
      }
    }

    // --- Server Invite (uses access-invite layer) ---
    async function createServerInvite() {
      try {
        const res = await window.fetch(getBasePath() + '/api/groups/' + encodeURIComponent(groupName) + '/access-invite', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ permission: 'write', forServer: true })
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to create invite');
        }
        const data = await res.json();
        document.getElementById('invite-hex').textContent = data.invite;
        document.getElementById('invite-panel').classList.add('visible');
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // --- Revoke Grant ---
    async function revokeGrant(grantId) {
      if (!confirm('Revoke this access grant?')) return;
      try {
        const res = await window.fetch(getBasePath() + '/api/access/grants/' + encodeURIComponent(grantId), {
          method: 'DELETE'
        });
        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || 'Failed to revoke grant');
        }
        showToast('Grant revoked');
        setTimeout(() => window.location.reload(), 500);
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // --- Helper: safe JSON parse from response ---
    async function safeJsonError(res) {
      try {
        var text = await res.text();
        try { return JSON.parse(text); } catch { return { error: text || ('HTTP ' + res.status) }; }
      } catch { return { error: 'HTTP ' + res.status }; }
    }

    // --- Sync Remote Repo ---
    async function syncRepo(repoName) {
      if (!confirm('Clone "' + repoName + '" to this server?')) return;

      var card = document.getElementById('repo-card-' + repoName);
      if (card) card.style.opacity = '0.4';
      showToast('Syncing ' + repoName + '...');

      try {
        var res = await window.fetch(getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/sync', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({})
        });

        if (!res.ok) {
          var errData = await safeJsonError(res);
          throw new Error(errData.error || 'Sync failed');
        }

        showToast('Synced ' + repoName + ' successfully!');
        setTimeout(function() { window.location.reload(); }, 1000);
      } catch (err) {
        showToast(err.message, true);
        if (card) card.style.opacity = '0.85';
      }
    }

    // --- Access Invite (Group or Repo) ---
    var _accessInviteScope = 'group'; // 'group' or 'repo'

    function showAccessInviteModal(scope, repoName) {
      _accessInviteScope = scope;
      const title = document.getElementById('access-invite-title');
      const repoField = document.getElementById('access-invite-repo-field');

      if (scope === 'repo') {
        title.textContent = 'Create Repo Access Invite';
        repoField.style.display = '';
        if (repoName) document.getElementById('access-invite-repo').value = repoName;
      } else {
        title.textContent = 'Create Group Access Invite';
        repoField.style.display = 'none';
      }
      document.getElementById('access-invite-modal').classList.add('visible');
    }

    function hideAccessInviteModal() {
      document.getElementById('access-invite-modal').classList.remove('visible');
    }

    async function createAccessInvite() {
      var perm = document.getElementById('access-invite-perm').value;
      var forServer = document.getElementById('access-invite-type').value === 'server';
      var btn = document.getElementById('create-access-invite-btn');
      btn.disabled = true;
      btn.textContent = 'Creating...';

      try {
        var url, body;
        if (_accessInviteScope === 'repo') {
          var repoName = document.getElementById('access-invite-repo').value;
          url = getBasePath() + '/api/repos/' + encodeURIComponent(repoName) + '/access-invite';
          body = { permission: perm, forServer: forServer };
        } else {
          url = getBasePath() + '/api/groups/' + encodeURIComponent(groupName) + '/access-invite';
          body = { permission: perm, forServer: forServer };
        }

        var res = await window.fetch(url, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify(body)
        });

        if (!res.ok) {
          var errData = await res.json();
          throw new Error(errData.error || 'Failed to create invite');
        }

        var data = await res.json();
        hideAccessInviteModal();

        // Show the invite panel
        var scope = _accessInviteScope === 'repo'
          ? 'repo "' + document.getElementById('access-invite-repo').value + '"'
          : 'group "' + groupName + '"';
        var permLabel = perm === 'owner' ? 'Owner' : perm === 'moderator' ? 'Moderator' : 'Write';
        var targetLabel = forServer ? 'any server' : 'an individual user';

        document.getElementById('invite-panel-title').textContent = permLabel + ' Access Invite';
        document.getElementById('invite-panel-desc').textContent = 'Share this invite to grant ' + permLabel.toLowerCase() + ' access to ' + scope + '. Valid for ' + targetLabel + '.';
        document.getElementById('invite-hex').textContent = data.invite;
        document.getElementById('invite-panel').classList.add('visible');
      } catch (err) {
        showToast(err.message, true);
      } finally {
        btn.disabled = false;
        btn.textContent = 'Create Invite';
      }
    }

    function copyInviteHex() {
      const text = document.getElementById('invite-hex').textContent;
      if (navigator.clipboard) {
        navigator.clipboard.writeText(text).then(() => showToast('Invite copied!'));
      }
    }

    function hideInvitePanel() {
      document.getElementById('invite-panel').classList.remove('visible');
    }

    // Load sync status
    async function loadSyncStatus() {
      try {
        const res = await window.fetch(getBasePath() + '/api/groups/' + encodeURIComponent(groupName) + '/sync-status');
        if (!res.ok) return;
        const data = await res.json();
        if (data.shared && data.writerCount > 0) {
          const countEl = document.getElementById('repo-count');
          if (countEl) {
            countEl.textContent += ' | ' + data.writerCount + ' server(s)';
          }
        }
      } catch (err) {
        console.error('Failed to load sync status:', err);
      }
    }
    loadSyncStatus();

    // --- Request Access ---
    async function requestGroupAccess() {
      var perm = document.getElementById('request-permission').value;
      var asServer = document.getElementById('request-as').value === 'server';
      var reason = document.getElementById('request-reason').value.trim();

      try {
        var res = await window.fetch(getBasePath() + '/api/access/request', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            targetType: 'group',
            targetName: groupName,
            permission: perm,
            asServer: asServer,
            reason: reason || undefined
          })
        });

        if (!res.ok) {
          var errData = await safeJsonError(res);
          throw new Error(errData.error || 'Request failed');
        }

        showToast('Access request submitted! The admin will review it.');
        document.getElementById('request-reason').value = '';
      } catch (err) {
        showToast(err.message, true);
      }
    }

    async function requestRepoAccess(repoName) {
      var perm = prompt('Permission level? (write / moderator)', 'write');
      if (!perm || !['write', 'moderator', 'owner'].includes(perm)) return;

      var reason = prompt('Optional: why do you need access?', '');

      try {
        var res = await window.fetch(getBasePath() + '/api/access/request', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({
            targetType: 'repo',
            targetName: repoName,
            permission: perm,
            asServer: true,
            reason: reason || undefined
          })
        });

        if (!res.ok) {
          var errData = await safeJsonError(res);
          throw new Error(errData.error || 'Request failed');
        }

        showToast('Access request for "' + repoName + '" submitted!');
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // --- Pending Requests (admin) ---
    async function loadPendingRequests() {
      try {
        var res = await window.fetch(getBasePath() + '/api/access/requests?status=pending&requestType=group&groupName=' + encodeURIComponent(groupName));
        if (!res.ok) return;
        var data = await res.json();
        if (!data.requests || data.requests.length === 0) return;

        var panel = document.getElementById('pending-requests-panel');
        var list = document.getElementById('pending-requests-list');

        var html = data.requests.map(function(r) {
          var who = r.subjectType === 'server' ? 'Server ' + r.subjectId.slice(0, 12) + '...' : 'User ' + r.subjectId.slice(0, 12) + '...';
          var when = new Date(r.createdAt).toLocaleDateString();
          return '<div style="display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid #21262d;">' +
            '<div style="flex: 1;">' +
              '<strong style="color: #c9d1d9;">' + who + '</strong>' +
              ' requests <span style="color: #58a6ff;">' + r.permission + '</span> access' +
              (r.reason ? '<div style="color: #8b949e; font-size: 12px; margin-top: 2px;">"' + r.reason + '"</div>' : '') +
              '<div style="color: #484f58; font-size: 11px;">' + when + '</div>' +
            '</div>' +
            '<button class="btn btn-primary btn-sm" onclick="approveRequest(\\'' + r.requestId + '\\')" style="font-size: 11px; padding: 3px 10px;">Approve</button>' +
            '<button class="btn btn-danger btn-sm" onclick="denyRequest(\\'' + r.requestId + '\\')" style="font-size: 11px; padding: 3px 10px;">Deny</button>' +
          '</div>';
        }).join('');

        list.innerHTML = html;
        panel.style.display = 'block';
      } catch (err) {
        console.error('Failed to load pending requests:', err);
      }
    }

    async function approveRequest(requestId) {
      try {
        var res = await window.fetch(getBasePath() + '/api/access/requests/' + encodeURIComponent(requestId) + '/approve', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({})
        });
        if (!res.ok) {
          var errData = await res.json();
          throw new Error(errData.error || 'Approve failed');
        }
        showToast('Request approved!');
        loadPendingRequests();
      } catch (err) {
        showToast(err.message, true);
      }
    }

    async function denyRequest(requestId) {
      var reason = prompt('Optional: reason for denying?', '');
      try {
        var res = await window.fetch(getBasePath() + '/api/access/requests/' + encodeURIComponent(requestId) + '/deny', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ reason: reason || undefined })
        });
        if (!res.ok) {
          var errData = await res.json();
          throw new Error(errData.error || 'Deny failed');
        }
        showToast('Request denied.');
        loadPendingRequests();
      } catch (err) {
        showToast(err.message, true);
      }
    }

    // Show request access section for non-admin federated groups, load pending requests for admin
    (function() {
      var isShared = ${group.source === 'shared' ? 'true' : 'false'};
      if (isShared) {
        document.getElementById('request-access-section').style.display = 'block';
      }
      // Admin: load pending requests
      loadPendingRequests();
    })();

    // --- Toast ---
    function showToast(msg, isError) {
      const toast = document.getElementById('toast');
      toast.textContent = msg;
      toast.className = 'toast visible' + (isError ? ' error' : '');
      setTimeout(() => { toast.className = 'toast'; }, 3000);
    }

    // Close dropdowns on outside click
    document.addEventListener('click', function(e) {
      if (!e.target.closest('.dropdown-container')) {
        document.getElementById('repo-dropdown').classList.remove('visible');
      }
      if (e.target.classList.contains('modal-overlay')) {
        e.target.classList.remove('visible');
      }
    });
  `

  return renderPage(escapeHtml(group.displayName || group.name), content2, { pathPrefix, activePage: 'groups', css: PAGE_STYLES, scripts: scripts2 })
}