Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions tools/productbus-admin/admins.js
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 {
Comment thread
maxakuru marked this conversation as resolved.
await apiFetch(ctx.org, ctx.site, `auth/admins/${encodeURIComponent(email)}`, { method: 'DELETE' });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOULD FIX — Silent success on HTTP errors: apiFetch only throws on HTTP 401 and 403. For any other non-2xx response (400, 404, 500...), it returns a resolved promise. Without a resp.ok guard, showToast('Admin removed') runs unconditionally — the UI shows success even when the server rejected the operation.

This same pattern repeats across all write operations in admins.js, catalog.js, customers.js, indices.js, orders.js, config.js, and service-tokens.js. Each call site needs:

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)}">

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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". journals.js uses <label for="..."> correctly and is the reference pattern.

Suggested change
<input type="text" class="search-input" placeholder="Search admins..." id="search-admins" value="${escapeHtml(initialQ)}">
<input type="text" class="search-input" placeholder="Search admins..." id="search-admins" value="${escapeHtml(initialQ)}" aria-label="Search admins">

<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>`;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING — XSS: err.message comes from the server-controlled x-error response header (api.js L67-70) and is interpolated into innerHTML without escaping. escapeHtml() is already imported in this file.

Suggested change
container.querySelector('#admins-table').innerHTML = `<p class="error">Failed to load admins: ${err.message}</p>`;
container.querySelector('#admins-table').innerHTML = `<p class="error">Failed to load admins: ${escapeHtml(err.message)}</p>`;

}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BLOCKING - XSS: err.message is sourced from the x-error response header (set in api.js) and interpolated directly into innerHTML without escaping. A malicious API server (or MITM) can inject arbitrary HTML/JS into the admin session.

escapeHtml is already imported in this file. Apply the same fix in catalog.js, config.js, customers.js, indices.js, orders.js, and productbus-admin.js.

Suggested change
}
container.querySelector('#admins-table').innerHTML = `<p class="error">Failed to load admins: ${escapeHtml(err.message)}</p>`;

}

export function destroy() {}
73 changes: 73 additions & 0 deletions tools/productbus-admin/api.js
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;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOULD FIX - Security: localStorage persists across browser sessions and is writable by any same-origin script. An XSS anywhere on tools.aem.live can permanently redirect all ProductBus API calls, including Bearer tokens, to an attacker-controlled origin via this override.

Validate the override against an allowlist before using it:

Suggested change
if (override) return override;
export function getApiBase() {
const override = localStorage.getItem('productbus-api-url');
if (override) {
try {
const url = new URL(override);
if (url.hostname.endsWith('.adobecommerce.live')) return override;
} catch (e) { /* invalid URL - ignore */ }
}
if (sessionStorage.getItem('productbus-stage') === 'true') return STAGE_API_BASE;
return DEFAULT_API_BASE;
}

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',
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CONSIDER - Security: Since auth is carried exclusively via Authorization: Bearer, credentials: 'include' is not needed and unnecessarily sends ambient cookies for api.adobecommerce.live on every cross-origin request.

Suggested change
});
credentials: 'same-origin',


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;
}
Loading
Loading