PageSourceSearch

https://dashboard.chipotle.litprotocol.com/dapps/dashboard/groups.js

js litprotocol.com collected 2026-10-02 04:11:02 UTC 15,093 bytes, 349 lines download raw bytes

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.