1/** 2 * Groups â CRUD, table rendering, multi-select builders. 3 */ 4 5import { getEffectiveApiKey, isAuthenticated, getClient, getGroupsStore, setGroupsStore, getWalletsStore, getActionsStore, setStat, updateStatCards, LIST_PAGE_SIZE } from './auth.js'; 6import { escapeHtml, showStatus, hideStatus, showActionProgress, closeActionProgress, openModal, closeModal, confirmDelete, formatError, logError, ICON_PENCIL, ICON_TRASH } from './ui-utils.js'; 7 8// ----- Multi-select builders ----- 9 10export function buildMultiSelect(id, items, getValue, getLabel, placeholder, disabled) { 11 const d = disabled ? ' disabled' : ''; 12 let opts = items.map((item) => 13 '<label class="ms-option"><input type="checkbox" value="' + escapeHtml(String(getValue(item))) + '"' + d + '><span>' + escapeHtml(getLabel(item)) + '</span></label>' 14 ).join(''); 15 if (!opts) opts = '<div class="ms-option" style="opacity:0.6;cursor:default;">No items available</div>'; 16 return ( 17 '<div class="ms-wrap" id="' + id + '" data-placeholder="' + escapeHtml(placeholder) + '">' + 18 '<button type="button" class="ms-trigger"' + (disabled ? ' disabled' : '') + '>' + 19 '<span class="ms-summary">' + escapeHtml(placeholder) + '</span>' + 20 '<span class="ms-arrow" aria-hidden="true"></span>' + 21 '</button>' + 22 '<div class="ms-dropdown">' + opts + '</div>' + 23 '</div>' 24 ); 25} 26 27// Canonical decimal form of a group id. list_groups returns ids as 0x-padded 28// 32-byte hex in API mode but as decimal in sovereign mode, while usage-key 29// permission arrays (can_execute_in_groups, \u2026) are always plain integers. Use 30// decimal everywhere so the multi-select checkbox value matches both the saved 31// permission ids (for pre-fill) and getSelectedGroupIds()'s Number() read. 32export function normalizeGroupId(id) { 33 try { 34 return BigInt(id).toString(); 35 } catch { 36 return String(id); 37 } 38} 39 40export function buildGroupMultiSelect(id, disabled) { 41 const items = [{ id: '0', name: 'All Groups' }, ...getGroupsStore()]; 42 return buildMultiSelect(id, items, (g) => normalizeGroupId(g.id), (g) => g.name || String(g.id), 'Select groups\u2026', disabled); 43} 44 45export function buildWalletMultiSelect(id, disabled) { 46 const items = [{ wallet_address: '0x0000000000000000000000000000000000000000', name: 'All' }, ...getWalletsStore()]; 47 return buildMultiSelect(id, items, (w) => w.wallet_address, (w) => w.name || w.wallet_address, 'Select PKPs\u2026', disabled); 48} 49 50export function buildActionsMultiSelect(id, disabled) { 51 const items = [{ id: '0', name: 'All' }, ...getActionsStore()]; 52 return buildMultiSelect(id, items, (a) => a.id, (a) => a.name || a.id, 'Select actions\u2026', disabled); 53} 54 55export function updateMultiSelectSummary(id) { 56 const wrap = document.getElementById(id); 57 if (!wrap) return; 58 const summaryEl = wrap.querySelector('.ms-summary'); 59 const checked = [...wrap.querySelectorAll('input[type="checkbox"]:checked')]; 60 const placeholder = wrap.dataset.placeholder || 'Select\u2026'; 61 summaryEl.textContent = checked.length === 0 62 ? placeholder 63 : checked.map((c) => c.nextElementSibling.textContent).join(', '); 64} 65 66// Open a multi-select dropdown and start listening for outside clicks. The 67// document listener lives only while the dropdown is open (see closeMultiSelect), 68// so closing the modal never leaves orphan listeners accumulating on document. 69function openMultiSelect(wrap) { 70 wrap.classList.add('is-open'); 71 if (wrap._msOutside) return; 72 const onOutside = (e) => { if (!wrap.contains(e.target)) closeMultiSelect(wrap); }; 73 wrap._msOutside = onOutside; 74 document.addEventListener('click', onOutside); 75} 76 77function closeMultiSelect(wrap) { 78 wrap.classList.remove('is-open'); 79 if (wrap._msOutside) { 80 document.removeEventListener('click', wrap._msOutside); 81 wrap._msOutside = null; 82 } 83} 84 85export function attachGroupMultiSelectLogic(id) { 86 const wrap = document.getElementById(id); 87 if (!wrap) return; 88 const trigger = wrap.querySelector('.ms-trigger'); 89 const allCb = wrap.querySelector('input[value="0"]'); 90 91 trigger.addEventListener('click', (e) => { 92 e.preventDefault(); 93 if (wrap.classList.contains('is-open')) { closeMultiSelect(wrap); return; } 94 document.querySelectorAll('.ms-wrap.is-open').forEach((w) => { if (w !== wrap) closeMultiSelect(w); }); 95 openMultiSelect(wrap); 96 }); 97 98 wrap.querySelectorAll('input[type="checkbox"]').forEach((cb) => { 99 cb.addEventListener('change', () => { 100 if (cb === allCb && allCb.checked) { 101 wrap.querySelectorAll('input[type="checkbox"]').forEach((c) => { if (c !== allCb) c.checked = false; }); 102 } else if (cb !== allCb && cb.checked && allCb && allCb.checked) { 103 allCb.checked = false;
104 } 105 updateMultiSelectSummary(id); 106 }); 107 }); 108} 109 110export function getSelectedGroupIds(id) { 111 const wrap = document.getElementById(id); 112 if (!wrap) return []; 113 return [...wrap.querySelectorAll('input[type="checkbox"]:checked')].map((c) => Number(c.value)); 114} 115 116export function getSelectedStringValues(id) { 117 const wrap = document.getElementById(id); 118 if (!wrap) return []; 119 return [...wrap.querySelectorAll('input[type="checkbox"]:checked')].map((c) => c.value); 120} 121 122function selectAllInMultiSelect(id) { 123 const wrap = document.getElementById(id); 124 if (!wrap) return; 125 const allCb = wrap.querySelector('input[value="0"]') || wrap.querySelector('input[value="0x0000000000000000000000000000000000000000"]'); 126 wrap.querySelectorAll('input[type="checkbox"]').forEach((cb) => { cb.checked = cb === allCb; }); 127 updateMultiSelectSummary(id); 128} 129 130// ----- Table rendering ----- 131 132export function renderGroupsTable(items) { 133 const tbody = document.getElementById('groups-tbody'); 134 const empty = document.getElementById('groups-empty'); 135 if (!tbody) return; 136 tbody.innerHTML = ''; 137 if (!items || items.length === 0) { 138 if (empty) empty.style.display = 'block'; 139 return; 140 } 141 if (empty) empty.style.display = 'none'; 142 items.forEach((item) => { 143 const tr = document.createElement('tr'); 144 tr.innerHTML = 145 '<td><strong>' + escapeHtml(item.name || '') + '</strong></td>' + 146 '<td class="mono">' + escapeHtml(item.description || '') + '</td>' + 147 '<td class="cell-actions"></td>'; 148 const actionsCell = tr.querySelector('.cell-actions'); 149 const editBtn = document.createElement('button'); 150 editBtn.type = 'button'; 151 editBtn.className = 'btn-icon'; 152 editBtn.title = 'Edit'; 153 editBtn.innerHTML = ICON_PENCIL; 154 editBtn.addEventListener('click', () => openEditGroupModal(item)); 155 actionsCell.appendChild(editBtn); 156 const delBtn = document.createElement('button'); 157 delBtn.type = 'button'; 158 delBtn.className = 'btn-icon btn-icon-danger'; 159 delBtn.title = 'Delete'; 160 delBtn.innerHTML = ICON_TRASH; 161 delBtn.addEventListener('click', () => confirmAndRemoveGroup(item)); 162 actionsCell.appendChild(delBtn); 163 tbody.appendChild(tr); 164 }); 165} 166 167// ----- Load ----- 168 169export async function loadGroups() { 170 const apiKey = getEffectiveApiKey(); 171 if (!isAuthenticated()) return; 172 hideStatus('groups-status'); 173 const btn = document.getElementById('btn-load-groups'); 174 if (btn) btn.disabled = true; 175 try { 176 const client = await getClient(); 177 const items = await client.listGroups({ apiKey, pageNumber: '0', pageSize: LIST_PAGE_SIZE }); 178 setGroupsStore(items); 179 renderGroupsTable(items); 180 setStat('groups', items.length); 181 updateStatCards(); 182 return items; 183 } catch (e) { 184 logError('loadGroups', e); 185 showStatus('groups-status', 'Error: ' + formatError(e), 'error'); 186 return []; 187 } finally { 188 if (btn) btn.disabled = false; 189 } 190} 191 192// ----- CRUD ----- 193 194async function confirmAndRemoveGroup(item) { 195 const label = item.name || item.id || 'â'; 196 const msg = 'Delete group "' + label + '"? This cannot be undone.'; 197 const confirmed = await confirmDelete(msg); 198 if (!confirmed) return; 199 const apiKey = getEffectiveApiKey(); 200 if (!isAuthenticated()) return; 201 hideStatus('groups-status'); 202 try { 203 showActionProgress('Deleting group', `Deleting group "${escapeHtml(label)}".`); 204 const client = await getClient(); 205 await client.removeGroup({ apiKey, groupId: item.id }); 206 setGroupsStore(getGroupsStore().filter((g) => g.id !== item.id)); 207 renderGroupsTable(getGroupsStore()); 208 updateStatCards(); 209 showStatus('groups-status', 'Group deleted.', 'success'); 210 } catch (e) { 211 logError('removeGroup', e); 212 showStatus('groups-status', 'Error: ' + formatError(e), 'error'); 213 } finally { 214 closeActionProgress(); 215 } 216} 217 218// Re-check the boxes in a multi-select that match `values`. `wrap` is the 219// captured .ms-wrap node (not an id) so a stale async caller can verify the 220// modal wasn't closed/rebuilt out from under it via wrap.isConnected. 221function preSelectMultiSelect(wrap, values) { 222 if (!wrap || !wrap.isConnected || !values || !values.length) return; 223 const valSet = new Set(values.map(String)); 224 wrap.querySelectorAll('input[type="checkbox"]').forEach((cb) => { 225 if (valSet.has(cb.value)) cb.checked = true; 226 }); 227 updateMultiSelectSummary(wrap.id); 228} 229 230// listGroups returns only {id, name, description}, so the group's currently 231// permitted PKPs and actions have to be fetched per-group to pre-fill the 232// edit modal's multi-selects. 233// The server maps the U256::ZERO "any/all" sentinel to the string id "n/a"
234// (metadata_to_item), but the modal's "All" option uses checkbox value "0". 235const WILDCARD_ITEM_ID = 'n/a'; 236 237async function preselectGroupPermissions(item) { 238 const apiKey = getEffectiveApiKey(); 239 // normalizeGroupId, not Number(): API-mode ids are 0x-padded hex and large 240 // u256 values would lose precision (or become NaN) through JS Number. 241 const groupId = normalizeGroupId(item.id); 242 const pkpWrap = document.getElementById('modal-group-pkp-ids'); 243 const cidWrap = document.getElementById('modal-group-cid-hashes'); 244 let client; 245 try { 246 client = await getClient(); 247 } catch (e) { 248 logError('preselectGroupPermissions:getClient', e); 249 return; 250 } 251 try { 252 const wallets = await client.listWalletsInGroup({ apiKey, groupId, pageNumber: '0', pageSize: LIST_PAGE_SIZE }); 253 preSelectMultiSelect(pkpWrap, wallets.map((w) => w.wallet_address)); 254 } catch (e) { 255 logError('preselectGroupPkps', e); 256 } 257 try { 258 const actions = await client.listActions({ apiKey, groupId, pageNumber: '0', pageSize: LIST_PAGE_SIZE }); 259 // A group that permits "All" actions comes back as the "n/a" wildcard id; 260 // map it to "0" so the modal's All checkbox preselects. 261 preSelectMultiSelect(cidWrap, actions.map((a) => (a.id === WILDCARD_ITEM_ID ? '0' : a.id))); 262 } catch (e) { 263 logError('preselectGroupCids', e); 264 } 265} 266 267function openGroupModal(item = null) { 268 const isEdit = item != null; 269 const nameId = isEdit ? 'modal-edit-group-name' : 'modal-group-name'; 270 const descId = isEdit ? 'modal-edit-group-desc' : 'modal-group-desc'; 271 272 const overlay = document.getElementById('modal-overlay'); 273 const titleEl = document.getElementById('modal-title'); 274 const modalBody = document.getElementById('modal-body'); 275 const modalFooter = document.getElementById('modal-footer'); 276 if (!overlay || !titleEl || !modalBody || !modalFooter) return; 277 278 titleEl.textContent = isEdit ? 'Edit group' : 'Add group'; 279 modalBody.innerHTML = 280 '<div class="form-group"><label for="' + nameId + '">Name</label><input type="text" id="' + nameId + '" class="input" placeholder="My" value="' + escapeHtml(item?.name ?? '') + '"></div>' + 281 '<div class="form-group"><label for="' + descId + '">Description</label><input type="text" id="' + descId + '" class="input" placeholder="Optional" value="' + escapeHtml(item?.description ?? '') + '"></div>' + 282 '<div class="form-group"><label>PKP IDs permitted</label>' + buildWalletMultiSelect('modal-group-pkp-ids', false) + '</div>' + 283 '<div class="form-group"><label>CID hashes permitted</label>' + buildActionsMultiSelect('modal-group-cid-hashes', false) + '</div>'; 284 modalFooter.innerHTML = 285 '<button type="button" class="btn btn-outline" id="modal-all-opts-btn" style="margin-right:auto;">All Options</button>' + 286 '<button type="button" class="btn btn-outline" id="modal-cancel-btn">Cancel</button>' + 287 '<button type="button" class="btn btn-primary" id="modal-save-btn">' + (isEdit ? 'Save' : 'Add') + '</button>'; 288 overlay.classList.add('is-open'); 289 overlay.setAttribute('aria-hidden', 'false'); 290 const firstInput = modalBody.querySelector('input, select, textarea'); 291 if (firstInput) firstInput.focus(); 292 293 const allOptsBtn = modalFooter.querySelector('#modal-all-opts-btn'); 294 const cancelBtn = modalFooter.querySelector('#modal-cancel-btn'); 295 const saveBtn = modalFooter.querySelector('#modal-save-btn'); 296 297 attachGroupMultiSelectLogic('modal-group-pkp-ids'); 298 attachGroupMultiSelectLogic('modal-group-cid-hashes'); 299 // When editing, load the group's saved PKP/action permissions back into the 300 // multi-selects (fire-and-forget; guarded against the modal being closed). 301 if (isEdit) void preselectGroupPermissions(item); 302 if (allOptsBtn) allOptsBtn.addEventListener('click', () => { 303 selectAllInMultiSelect('modal-group-pkp-ids'); 304 selectAllInMultiSelect('modal-group-cid-hashes'); 305 }); 306 if (cancelBtn) cancelBtn.addEventListener('click', closeModal); 307 if (saveBtn) saveBtn.addEventListener('click', async () => { 308 const name = document.getElementById(nameId).value.trim() || 'Group'; 309 const desc = document.getElementById(descId).value.trim(); 310 const pkpIdsPermitted = getSelectedStringValues('modal-group-pkp-ids'); 311 const cidHashesPermitted = getSelectedStringValues('modal-group-cid-hashes'); 312 const apiKey = getEffectiveApiKey(); 313 if (!isAuthenticated()) { 314 showStatus('groups-status', 'Log in first.', 'error'); 315 return; 316 } 317 saveBtn.disabled = true; 318 closeModal(); 319 hideStatus('groups-status'); 320 try { 321 const client = await getClient(); 322 if (isEdit) { 323 const id = String(Number(item.id)); 324 showActionProgress('Updating group', `Updating group "${name}".`); 325 await client.updateGroup({ apiKey, groupId: id, name, description: desc, pkpIdsPermitted, cidHashesPermitted }); 326 } else { 327 showActionProgress('Creating group', `Creating group "${name}".`); 328 await client.addGroup({ apiKey, groupName: name, groupDescription: desc, pkpIdsPermitted, cidHashesPermitted }); 329 } 330 await loadGroups(); 331 showStatus('groups-status', isEdit ? 'Group updated.' : 'Group created.', 'success'); 332 } catch (e) { 333 logError('saveGroup', e); 334 showStatus('groups-status', 'Error: ' + formatError(e), 'error'); 335 } finally { 336 closeActionProgress(); 337 } 338 }); 339} 340 341function openAddGroupModal() { openGroupModal(); } 342function openEditGroupModal(item) { openGroupModal(item); } 343 344// ----- Init ----- 345 346export function initGroups() { 347 document.getElementById('btn-load-groups')?.addEventListener('click', () => loadGroups()); 348 document.getElementById('btn-add-group')?.addEventListener('click', () => openAddGroupModal()); 349}
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.