/**
* 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;">×</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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
}
// 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">▼</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 <url></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;">×</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;">×</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;">✎</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 })
}