-
Notifications
You must be signed in to change notification settings - Fork 8
feat: productbus admin tool #303
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
205d476
0b4b507
0745f36
36dac3a
557d018
f96c5e8
5d8cd81
69c4be2
601b2dc
7aff0cb
edc5676
efef752
56beb9e
5b546be
da2aba4
c66a0be
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,156 @@ | ||||||
| /** | ||||||
| * ProductBus Admin - Admins page (superuser only) | ||||||
| */ | ||||||
|
|
||||||
| import { apiFetch } from './api.js'; | ||||||
| import { | ||||||
| showToast, createModal, getUrlParam, setUrlParam, confirmModal, escapeHtml, | ||||||
| } from './ui.js'; | ||||||
|
|
||||||
| function renderTable(container, admins, ctx) { | ||||||
| const tableWrap = container.querySelector('#admins-table'); | ||||||
| if (admins.length === 0) { | ||||||
| tableWrap.innerHTML = ` | ||||||
| <div class="empty-state"> | ||||||
| <h3>No admins found</h3> | ||||||
| <p>Add your first admin to get started</p> | ||||||
| </div> | ||||||
| `; | ||||||
| return; | ||||||
| } | ||||||
|
|
||||||
| tableWrap.innerHTML = ` | ||||||
| <table class="data-table"> | ||||||
| <thead> | ||||||
| <tr> | ||||||
| <th>Email</th> | ||||||
| <th>Date Added</th> | ||||||
| <th>Added By</th> | ||||||
| <th>Actions</th> | ||||||
| </tr> | ||||||
| </thead> | ||||||
| <tbody> | ||||||
| ${admins.map((a) => ` | ||||||
| <tr> | ||||||
| <td>${escapeHtml(a.email)}</td> | ||||||
| <td>${a.dateAdded ? new Date(a.dateAdded).toLocaleDateString() : '—'}</td> | ||||||
| <td>${escapeHtml(a.addedBy || '—')}</td> | ||||||
| <td> | ||||||
| <div class="actions"> | ||||||
| <button class="btn-icon danger" data-action="delete" data-email="${escapeHtml(a.email)}" title="Remove">Remove</button> | ||||||
| </div> | ||||||
| </td> | ||||||
| </tr> | ||||||
| `).join('')} | ||||||
| </tbody> | ||||||
| </table> | ||||||
| `; | ||||||
|
|
||||||
| tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => { | ||||||
| btn.addEventListener('click', async () => { | ||||||
| const { email } = btn.dataset; | ||||||
| const ok = await confirmModal(`Remove admin ${email}?`, { | ||||||
| title: 'Remove admin', | ||||||
| confirmLabel: 'Remove', | ||||||
| destructive: true, | ||||||
| }); | ||||||
| if (!ok) return; | ||||||
| try { | ||||||
| await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'DELETE' }); | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SHOULD FIX — Silent success on HTTP errors: This same pattern repeats across all write operations in const resp = await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error(resp.headers.get('x-error') || `HTTP ${resp.status}`);
showToast('Admin removed'); |
||||||
| showToast('Admin removed'); | ||||||
| // eslint-disable-next-line no-use-before-define | ||||||
| render(container, ctx); | ||||||
| } catch (err) { | ||||||
| showToast(`Failed to remove: ${err.message}`, 'error'); | ||||||
| } | ||||||
| }); | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| function openAddModal(ctx, onAdded) { | ||||||
| const content = document.createElement('form'); | ||||||
| content.id = 'add-admin-form'; | ||||||
| content.innerHTML = ` | ||||||
| <div class="form-field"> | ||||||
| <label for="admin-email">Email</label> | ||||||
| <input type="email" id="admin-email" name="email" required placeholder="admin@example.com"> | ||||||
| </div> | ||||||
| `; | ||||||
|
|
||||||
| const footer = document.createElement('div'); | ||||||
| footer.innerHTML = ` | ||||||
| <button type="button" class="button outline cancel-btn">Cancel</button> | ||||||
| <button type="submit" form="add-admin-form" class="button save-btn">Add Admin</button> | ||||||
| `; | ||||||
|
|
||||||
| const dialog = createModal('Add Admin', content, footer); | ||||||
|
|
||||||
| dialog.querySelector('.cancel-btn').addEventListener('click', () => { | ||||||
| dialog.close(); | ||||||
| dialog.remove(); | ||||||
| }); | ||||||
|
|
||||||
| content.addEventListener('submit', async (e) => { | ||||||
| e.preventDefault(); | ||||||
| const saveBtn = dialog.querySelector('.save-btn'); | ||||||
| saveBtn.disabled = true; | ||||||
| saveBtn.textContent = 'Adding...'; | ||||||
|
|
||||||
| try { | ||||||
| const email = content.querySelector('#admin-email').value; | ||||||
| await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'PUT' }); | ||||||
| showToast('Admin added'); | ||||||
| dialog.close(); | ||||||
| dialog.remove(); | ||||||
| onAdded(); | ||||||
| } catch (err) { | ||||||
| showToast(`Failed to add admin: ${err.message}`, 'error'); | ||||||
| saveBtn.disabled = false; | ||||||
| saveBtn.textContent = 'Add Admin'; | ||||||
| } | ||||||
| }); | ||||||
| } | ||||||
|
|
||||||
| export async function render(container, ctx) { | ||||||
| const initialQ = getUrlParam('q'); | ||||||
| container.innerHTML = ` | ||||||
| <div class="page-header"> | ||||||
| <h1>Admins</h1> | ||||||
| </div> | ||||||
| <div class="page-actions"> | ||||||
| <input type="text" class="search-input" placeholder="Search admins..." id="search-admins" value="${escapeHtml(initialQ)}"> | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SHOULD FIX — Accessibility (WCAG 2.1 AA / SC 4.1.2): Placeholder text alone does not provide a programmatically determinable accessible name. Per AGENTS.md: "Ensure accessibility standards (ARIA labels)" and "Follow WCAG 2.1 AA guidelines".
Suggested change
|
||||||
| <button class="button" id="add-admin-btn">+ Add Admin</button> | ||||||
| </div> | ||||||
| <div id="admins-table"> | ||||||
| <p class="loading">Loading admins...</p> | ||||||
| </div> | ||||||
| `; | ||||||
|
|
||||||
| function filterAdmins(admins, q) { | ||||||
| if (!q) return admins; | ||||||
| const needle = q.toLowerCase(); | ||||||
| return admins.filter((a) => a.email.toLowerCase().includes(needle)); | ||||||
| } | ||||||
|
|
||||||
| try { | ||||||
| const resp = await apiFetch(ctx.org, ctx.site, 'auth/admins', { method: 'GET' }); | ||||||
| const data = await resp.json(); | ||||||
| const admins = data.admins || data || []; | ||||||
|
|
||||||
| renderTable(container, filterAdmins(admins, initialQ), ctx); | ||||||
|
|
||||||
| container.querySelector('#search-admins').addEventListener('input', (e) => { | ||||||
| const q = e.target.value; | ||||||
| setUrlParam('q', q); | ||||||
| renderTable(container, filterAdmins(admins, q), ctx); | ||||||
| }); | ||||||
|
|
||||||
| container.querySelector('#add-admin-btn').addEventListener('click', () => { | ||||||
| openAddModal(ctx, () => render(container, ctx)); | ||||||
| }); | ||||||
| } catch (err) { | ||||||
| container.querySelector('#admins-table').innerHTML = `<p class="error">Failed to load admins: ${err.message}</p>`; | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKING — XSS:
Suggested change
|
||||||
| } | ||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. BLOCKING - XSS:
Suggested change
|
||||||
| } | ||||||
|
|
||||||
| export function destroy() {} | ||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,73 @@ | ||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||
| * ProductBus Admin API client | ||||||||||||||||||||||||||
| * Shared fetch wrapper with auth and error handling | ||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| import { showToast } from './ui.js'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const DEFAULT_API_BASE = 'https://api.adobecommerce.live'; | ||||||||||||||||||||||||||
| const STAGE_API_BASE = 'https://api-stage.adobecommerce.live'; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function getApiBase() { | ||||||||||||||||||||||||||
| const override = localStorage.getItem('productbus-api-url'); | ||||||||||||||||||||||||||
| if (override) return override; | ||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. SHOULD FIX - Security: Validate the override against an allowlist before using it:
Suggested change
|
||||||||||||||||||||||||||
| if (sessionStorage.getItem('productbus-stage') === 'true') return STAGE_API_BASE; | ||||||||||||||||||||||||||
| return DEFAULT_API_BASE; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function getAuthState(org, site) { | ||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||
| const data = sessionStorage.getItem(`pbus-auth-${org}-${site}`); | ||||||||||||||||||||||||||
| return data ? JSON.parse(data) : null; | ||||||||||||||||||||||||||
| } catch (e) { | ||||||||||||||||||||||||||
| return null; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function setAuthState(org, site, state) { | ||||||||||||||||||||||||||
| sessionStorage.setItem(`pbus-auth-${org}-${site}`, JSON.stringify(state)); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export function clearAuthState(org, site) { | ||||||||||||||||||||||||||
| sessionStorage.removeItem(`pbus-auth-${org}-${site}`); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| export async function apiFetch(org, site, path, options = {}) { | ||||||||||||||||||||||||||
| const { skipAuthRedirect, ...fetchOptions } = options; | ||||||||||||||||||||||||||
| const base = getApiBase(); | ||||||||||||||||||||||||||
| const url = `${base}/${org}/sites/${site}/${path}`; | ||||||||||||||||||||||||||
| const auth = getAuthState(org, site); | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const headers = { | ||||||||||||||||||||||||||
| 'Content-Type': 'application/json', | ||||||||||||||||||||||||||
| ...fetchOptions.headers, | ||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (auth?.token) { | ||||||||||||||||||||||||||
| headers.Authorization = `Bearer ${auth.token}`; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| const response = await fetch(url, { | ||||||||||||||||||||||||||
| ...fetchOptions, | ||||||||||||||||||||||||||
| headers, | ||||||||||||||||||||||||||
| credentials: 'include', | ||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. CONSIDER - Security: Since auth is carried exclusively via
Suggested change
|
||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (response.status === 401 && !skipAuthRedirect) { | ||||||||||||||||||||||||||
| clearAuthState(org, site); | ||||||||||||||||||||||||||
| const params = new URLSearchParams(window.location.search); | ||||||||||||||||||||||||||
| params.set('page', 'login'); | ||||||||||||||||||||||||||
| params.set('redirect', window.location.href); | ||||||||||||||||||||||||||
| window.history.pushState({}, '', `${window.location.pathname}?${params.toString()}`); | ||||||||||||||||||||||||||
| window.dispatchEvent(new PopStateEvent('popstate')); | ||||||||||||||||||||||||||
| throw new Error('Unauthorized'); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| if (response.status === 403) { | ||||||||||||||||||||||||||
| const errorMsg = response.headers.get('x-error') || 'Forbidden'; | ||||||||||||||||||||||||||
| showToast(`${errorMsg} (${response.status})`, 'error'); | ||||||||||||||||||||||||||
| throw new Error(errorMsg); | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||
| return response; | ||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.