diff --git a/tools/productbus-admin/admins.js b/tools/productbus-admin/admins.js
new file mode 100644
index 00000000..e3a3afab
--- /dev/null
+++ b/tools/productbus-admin/admins.js
@@ -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 = `
+
+
No admins found
+
Add your first admin to get started
+
+ `;
+ return;
+ }
+
+ tableWrap.innerHTML = `
+
+
+
+ | Email |
+ Date Added |
+ Added By |
+ Actions |
+
+
+
+ ${admins.map((a) => `
+
+ | ${escapeHtml(a.email)} |
+ ${a.dateAdded ? new Date(a.dateAdded).toLocaleDateString() : '—'} |
+ ${escapeHtml(a.addedBy || '—')} |
+
+
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+
+ 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' });
+ 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 = `
+
+
+
+
+ `;
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ 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 = `
+
+
+
+
+
+
+ `;
+
+ 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 = `Failed to load admins: ${err.message}
`;
+ }
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/api.js b/tools/productbus-admin/api.js
new file mode 100644
index 00000000..3e446b6d
--- /dev/null
+++ b/tools/productbus-admin/api.js
@@ -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;
+ 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',
+ });
+
+ 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;
+}
diff --git a/tools/productbus-admin/catalog.js b/tools/productbus-admin/catalog.js
new file mode 100644
index 00000000..59746a4a
--- /dev/null
+++ b/tools/productbus-admin/catalog.js
@@ -0,0 +1,337 @@
+/**
+ * ProductBus Admin - Catalog page
+ */
+
+import { apiFetch } from './api.js';
+import {
+ showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, escapeHtml,
+} from './ui.js';
+
+const AVAILABILITY_OPTIONS = [
+ 'BackOrder', 'Discontinued', 'InStock', 'InStoreOnly',
+ 'LimitedAvailability', 'OnlineOnly', 'OutOfStock',
+ 'PreOrder', 'PreSale', 'SoldOut',
+];
+
+const CONDITION_OPTIONS = [
+ 'NewCondition', 'DamagedCondition', 'RefurbishedCondition', 'UsedCondition',
+];
+
+const PATH_PATTERN = /^\/[a-z0-9-/]+$/;
+
+function renderTable(container, products, ctx) {
+ const tableWrap = container.querySelector('#catalog-table');
+ if (products.length === 0) {
+ tableWrap.innerHTML = `
+
+
No products found
+
Create your first product to get started
+
+ `;
+ return;
+ }
+
+ tableWrap.innerHTML = `
+
+
+
+ | SKU |
+ Name |
+ Path |
+ Actions |
+
+
+
+ ${products.map((p) => `
+
+ ${escapeHtml(p.sku)} |
+ ${escapeHtml(p.name || '—')} |
+ ${escapeHtml(p.path || '—')} |
+
+
+
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+
+ tableWrap.querySelectorAll('[data-action="edit"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const { path } = btn.dataset;
+ try {
+ const jsonPath = path.endsWith('.json') ? path : `${path}.json`;
+ const resp = await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { method: 'GET' });
+ const product = await resp.json();
+ // eslint-disable-next-line no-use-before-define
+ openProductModal(ctx, product, () => render(container, ctx));
+ } catch (err) {
+ showToast(`Failed to load product: ${err.message}`, 'error');
+ }
+ });
+ });
+
+ tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const { path } = btn.dataset;
+ const jsonPath = path.endsWith('.json') ? path : `${path}.json`;
+ const ok = await confirmModal(`Delete product at ${path}?`, {
+ title: 'Delete product',
+ confirmLabel: 'Delete',
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, { method: 'DELETE' });
+ showToast('Product deleted');
+ // eslint-disable-next-line no-use-before-define
+ render(container, ctx);
+ } catch (err) {
+ showToast(`Failed to delete: ${err.message}`, 'error');
+ }
+ });
+ });
+}
+
+function buildFormHTML(product) {
+ const p = product || {};
+ const price = p.price || {};
+
+ function opt(options, selected) {
+ return options.map((o) => ``).join('');
+ }
+
+ return `
+
+ `;
+}
+
+function openProductModal(ctx, existing, onSaved) {
+ const isNew = !existing;
+ const title = isNew ? 'Create Product' : `Edit: ${existing.sku}`;
+
+ const content = document.createElement('div');
+ let currentView = 'form';
+
+ function renderView() {
+ if (currentView === 'form') {
+ content.innerHTML = `
+
+
+
+
+ ${buildFormHTML(existing)}
+ `;
+ } else {
+ const jsonData = existing ? { ...existing } : {
+ sku: '',
+ name: '',
+ path: '',
+ description: '',
+ price: {
+ currency: 'USD',
+ regular: 0,
+ final: 0,
+ },
+ images: [],
+ };
+ // Remove read-only internal fields for editing
+ delete jsonData.internal;
+
+ content.innerHTML = `
+
+
+
+
+
+ `;
+ }
+
+ content.querySelectorAll('.view-switcher button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ currentView = btn.dataset.view;
+ renderView();
+ });
+ });
+ }
+
+ renderView();
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ const dialog = createModal(title, content, footer);
+
+ dialog.querySelector('.cancel-btn').addEventListener('click', () => {
+ dialog.close();
+ dialog.remove();
+ });
+
+ dialog.querySelector('.save-btn').addEventListener('click', async () => {
+ const saveBtn = dialog.querySelector('.save-btn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving...';
+
+ try {
+ let productData;
+ if (currentView === 'json') {
+ productData = JSON.parse(dialog.querySelector('#json-editor').value);
+ } else {
+ const form = dialog.querySelector('#product-form');
+ const fd = new FormData(form);
+ productData = { price: {}, images: [] };
+
+ fd.forEach((val, key) => {
+ if (key.startsWith('price.')) {
+ const field = key.split('.')[1];
+ productData.price[field] = field === 'currency' ? val : parseFloat(val) || 0;
+ } else if (key.startsWith('images.')) {
+ const parts = key.split('.');
+ const idx = parseInt(parts[1], 10);
+ if (!productData.images[idx]) productData.images[idx] = {};
+ productData.images[idx][parts[2]] = val;
+ } else if (val !== '') {
+ productData[key] = val;
+ }
+ });
+
+ // Clean empty images
+ productData.images = productData.images.filter((img) => img && img.url);
+ }
+
+ // Validate path
+ let { path } = productData;
+ if (!path) throw new Error('Path is required');
+ if (path.endsWith('.json')) path = path.slice(0, -5);
+ if (!PATH_PATTERN.test(path)) {
+ throw new Error('Path must start with / and contain only lowercase letters, numbers, hyphens, and slashes');
+ }
+
+ const jsonPath = `${path}.json`;
+ productData.path = path;
+
+ await apiFetch(ctx.org, ctx.site, `catalog${jsonPath}`, {
+ method: 'PUT',
+ body: JSON.stringify(productData),
+ });
+ showToast(isNew ? 'Product created' : 'Product updated');
+ dialog.close();
+ dialog.remove();
+ onSaved();
+ } catch (err) {
+ showToast(`Failed to save: ${err.message}`, 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = isNew ? 'Create' : 'Update';
+ }
+ });
+}
+
+export async function render(container, ctx) {
+ const initialQ = getUrlParam('q');
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ function filterProducts(products, q) {
+ if (!q) return products;
+ const needle = q.toLowerCase();
+ return products.filter((p) => (p.sku || '').toLowerCase().includes(needle)
+ || (p.name || '').toLowerCase().includes(needle)
+ || (p.path || '').toLowerCase().includes(needle));
+ }
+
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, 'catalog', { method: 'GET' });
+ const data = await resp.json();
+ const products = data.products || [];
+
+ renderTable(container, filterProducts(products, initialQ), ctx);
+
+ container.querySelector('#search-catalog').addEventListener('input', (e) => {
+ const q = e.target.value;
+ setUrlParam('q', q);
+ renderTable(container, filterProducts(products, q), ctx);
+ });
+
+ container.querySelector('#add-product-btn').addEventListener('click', () => {
+ openProductModal(ctx, null, () => render(container, ctx));
+ });
+ } catch (err) {
+ container.querySelector('#catalog-table').innerHTML = `Failed to load catalog: ${err.message}
`;
+ }
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/config.js b/tools/productbus-admin/config.js
new file mode 100644
index 00000000..2db32e33
--- /dev/null
+++ b/tools/productbus-admin/config.js
@@ -0,0 +1,169 @@
+/**
+ * ProductBus Admin - Config page
+ */
+
+import { apiFetch } from './api.js';
+import {
+ showToast, createFormField, confirmModal, escapeHtml,
+} from './ui.js';
+
+function buildFormHTML(config) {
+ const c = config || {};
+ return `
+
+ `;
+}
+
+function getFormConfig(form) {
+ const fd = new FormData(form);
+ const config = {};
+
+ config.authEnabled = form.querySelector('#authEnabled').checked;
+
+ const origins = fd.get('authOrigins');
+ if (origins) {
+ config.authOrigins = origins.split(',').map((s) => s.trim()).filter(Boolean);
+ }
+
+ ['otpEmailSender', 'otpEmailSubject', 'otpEmailBodyTemplate', 'otpEmailBodyUrl'].forEach((key) => {
+ const val = fd.get(key);
+ if (val) config[key] = val;
+ });
+
+ return config;
+}
+
+export async function render(container, ctx) {
+ container.innerHTML = `
+
+
+
Loading configuration...
+
+ `;
+
+ let config = {};
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, 'config', { method: 'GET' });
+ if (resp.ok) {
+ config = await resp.json();
+ }
+ } catch (err) {
+ // Config may not exist yet
+ if (!err.message.includes('Forbidden')) {
+ config = {};
+ } else {
+ container.querySelector('#config-content').innerHTML = `Failed to load config: ${err.message}
`;
+ return;
+ }
+ }
+
+ let currentView = 'form';
+
+ function renderView() {
+ const content = container.querySelector('#config-content');
+ if (currentView === 'form') {
+ content.innerHTML = `
+
+
+
+
+ ${buildFormHTML(config)}
+
+
+
+
+ `;
+ } else {
+ content.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+ }
+
+ content.querySelectorAll('.view-switcher button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ currentView = btn.dataset.view;
+ renderView();
+ });
+ });
+
+ content.querySelector('#save-config-btn').addEventListener('click', async () => {
+ const saveBtn = content.querySelector('#save-config-btn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Saving...';
+
+ try {
+ let configData;
+ if (currentView === 'json') {
+ configData = JSON.parse(content.querySelector('#json-editor').value);
+ } else {
+ configData = getFormConfig(content.querySelector('#config-form'));
+ }
+
+ await apiFetch(ctx.org, ctx.site, 'config', {
+ method: 'POST',
+ body: JSON.stringify(configData),
+ });
+ config = configData;
+ showToast('Config saved');
+ } catch (err) {
+ showToast(`Failed to save: ${err.message}`, 'error');
+ }
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Save Config';
+ });
+
+ content.querySelector('#delete-config-btn').addEventListener('click', async () => {
+ const ok = await confirmModal('Delete configuration? This cannot be undone.', {
+ title: 'Delete config',
+ confirmLabel: 'Delete',
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await apiFetch(ctx.org, ctx.site, 'config', { method: 'DELETE' });
+ config = {};
+ showToast('Config deleted');
+ renderView();
+ } catch (err) {
+ showToast(`Failed to delete: ${err.message}`, 'error');
+ }
+ });
+ }
+
+ renderView();
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/customers.js b/tools/productbus-admin/customers.js
new file mode 100644
index 00000000..0ee1748c
--- /dev/null
+++ b/tools/productbus-admin/customers.js
@@ -0,0 +1,205 @@
+/**
+ * ProductBus Admin - Customers page
+ */
+
+import { apiFetch } from './api.js';
+import {
+ showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, escapeHtml,
+} from './ui.js';
+
+function renderTable(container, customers, ctx) {
+ const tableWrap = container.querySelector('#customers-table');
+ if (customers.length === 0) {
+ tableWrap.innerHTML = `
+
+
No customers found
+
Create your first customer to get started
+
+ `;
+ return;
+ }
+
+ tableWrap.innerHTML = `
+
+
+
+ | Email |
+ First Name |
+ Last Name |
+ Phone |
+ Created |
+ Actions |
+
+
+
+ ${customers.map((c) => `
+
+ | ${escapeHtml(c.email)} |
+ ${escapeHtml(c.firstName || '—')} |
+ ${escapeHtml(c.lastName || '—')} |
+ ${escapeHtml(c.phone || '—')} |
+ ${c.createdAt ? new Date(c.createdAt).toLocaleDateString() : '—'} |
+
+
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+
+ tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const { email } = btn.dataset;
+ const ok = await confirmModal(`Delete customer ${email}? This will also remove their orders and addresses.`, {
+ title: 'Delete customer',
+ confirmLabel: 'Delete',
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await apiFetch(ctx.org, ctx.site, `customers/${encodeURIComponent(email)}`, { method: 'DELETE' });
+ showToast('Customer deleted');
+ // eslint-disable-next-line no-use-before-define
+ render(container, ctx);
+ } catch (err) {
+ showToast(`Failed to delete: ${err.message}`, 'error');
+ }
+ });
+ });
+}
+
+function openCreateModal(ctx, onCreated) {
+ const content = document.createElement('div');
+ let currentView = 'form';
+
+ function renderView() {
+ if (currentView === 'form') {
+ content.innerHTML = `
+
+
+
+
+
+ `;
+ } else {
+ content.innerHTML = `
+
+
+
+
+
+ `;
+ }
+
+ content.querySelectorAll('.view-switcher button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ currentView = btn.dataset.view;
+ renderView();
+ });
+ });
+ }
+
+ renderView();
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ const dialog = createModal('Create Customer', content, footer);
+
+ dialog.querySelector('.cancel-btn').addEventListener('click', () => {
+ dialog.close();
+ dialog.remove();
+ });
+
+ dialog.querySelector('.save-btn').addEventListener('click', async () => {
+ const saveBtn = dialog.querySelector('.save-btn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Creating...';
+
+ try {
+ let customerData;
+ if (currentView === 'json') {
+ customerData = JSON.parse(dialog.querySelector('#json-editor').value);
+ } else {
+ const form = dialog.querySelector('#customer-form');
+ customerData = Object.fromEntries(new FormData(form));
+ }
+
+ await apiFetch(ctx.org, ctx.site, 'customers', {
+ method: 'POST',
+ body: JSON.stringify(customerData),
+ });
+ showToast('Customer created');
+ dialog.close();
+ dialog.remove();
+ onCreated();
+ } catch (err) {
+ showToast(`Failed to create customer: ${err.message}`, 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Create Customer';
+ }
+ });
+}
+
+export async function render(container, ctx) {
+ const initialQ = getUrlParam('q');
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ function filterCustomers(customers, q) {
+ if (!q) return customers;
+ const needle = q.toLowerCase();
+ return customers.filter((c) => c.email.toLowerCase().includes(needle)
+ || (c.firstName || '').toLowerCase().includes(needle)
+ || (c.lastName || '').toLowerCase().includes(needle));
+ }
+
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, 'customers', { method: 'GET' });
+ const data = await resp.json();
+ const customers = data.customers || data || [];
+
+ renderTable(container, filterCustomers(customers, initialQ), ctx);
+
+ container.querySelector('#search-customers').addEventListener('input', (e) => {
+ const q = e.target.value;
+ setUrlParam('q', q);
+ renderTable(container, filterCustomers(customers, q), ctx);
+ });
+
+ container.querySelector('#add-customer-btn').addEventListener('click', () => {
+ openCreateModal(ctx, () => render(container, ctx));
+ });
+ } catch (err) {
+ container.querySelector('#customers-table').innerHTML = `Failed to load customers: ${err.message}
`;
+ }
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/index.html b/tools/productbus-admin/index.html
new file mode 100644
index 00000000..5a64b8a7
--- /dev/null
+++ b/tools/productbus-admin/index.html
@@ -0,0 +1,30 @@
+
+
+
+
+
+
+
+
+ ProductBus Admin
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/tools/productbus-admin/indices.js b/tools/productbus-admin/indices.js
new file mode 100644
index 00000000..378557f3
--- /dev/null
+++ b/tools/productbus-admin/indices.js
@@ -0,0 +1,169 @@
+/**
+ * ProductBus Admin - Indices page
+ */
+
+import { apiFetch } from './api.js';
+import {
+ showToast, createModal, createFormField, getUrlParam, setUrlParam, confirmModal, escapeHtml,
+} from './ui.js';
+
+const DIR_PATH_PATTERN = /^\/[a-z0-9-/]+$/;
+
+function renderTable(container, indices, ctx) {
+ const tableWrap = container.querySelector('#indices-table');
+ if (indices.length === 0) {
+ tableWrap.innerHTML = `
+
+
No indices found
+
Create your first index to get started
+
+ `;
+ return;
+ }
+
+ tableWrap.innerHTML = `
+
+
+
+ | Path |
+ Last Modified |
+ Actions |
+
+
+
+ ${indices.map((idx) => `
+
+ ${escapeHtml(idx.path)} |
+ ${idx.lastModified ? new Date(idx.lastModified).toLocaleDateString() : '—'} |
+
+
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+
+ tableWrap.querySelectorAll('[data-action="delete"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ const { path } = btn.dataset;
+ const ok = await confirmModal(`Delete index at ${path}?`, {
+ title: 'Delete index',
+ confirmLabel: 'Delete',
+ destructive: true,
+ });
+ if (!ok) return;
+ try {
+ await apiFetch(ctx.org, ctx.site, `index${path}`, { method: 'DELETE' });
+ showToast('Index deleted');
+ // eslint-disable-next-line no-use-before-define
+ render(container, ctx);
+ } catch (err) {
+ showToast(`Failed to delete: ${err.message}`, 'error');
+ }
+ });
+ });
+}
+
+function openCreateModal(ctx, onCreated) {
+ const content = document.createElement('form');
+ content.id = 'index-form';
+ content.innerHTML = `
+ ${createFormField('path', 'Path', 'text', {
+ required: true,
+ placeholder: '/products/index.json',
+ }).outerHTML}
+ Must start with /, contain only lowercase letters, numbers, hyphens, and slashes, and end with /index.json
+ `;
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ const dialog = createModal('Create Index', 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 = 'Creating...';
+
+ try {
+ let path = content.querySelector('#path').value.trim();
+
+ // Normalize: strip trailing /index.json if provided, validate base path
+ if (path.endsWith('/index.json')) {
+ path = path.slice(0, -'/index.json'.length);
+ }
+ path = path.replace(/\/+$/, '');
+
+ if (!DIR_PATH_PATTERN.test(path)) {
+ throw new Error('Invalid path. Must start with / and contain only lowercase letters, numbers, hyphens, and slashes.');
+ }
+
+ const indexPath = `${path}/index.json`;
+ await apiFetch(ctx.org, ctx.site, `index${indexPath}`, { method: 'POST' });
+ showToast('Index created');
+ dialog.close();
+ dialog.remove();
+ onCreated();
+ } catch (err) {
+ showToast(`Failed to create index: ${err.message}`, 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Create Index';
+ }
+ });
+}
+
+export async function render(container, ctx) {
+ const initialQ = getUrlParam('q');
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ function filterIndices(indices, q) {
+ if (!q) return indices;
+ const needle = q.toLowerCase();
+ return indices.filter((idx) => idx.path.toLowerCase().includes(needle));
+ }
+
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, 'index', { method: 'GET' });
+ const data = await resp.json();
+ const indices = data.indices || [];
+
+ renderTable(container, filterIndices(indices, initialQ), ctx);
+
+ container.querySelector('#search-indices').addEventListener('input', (e) => {
+ const q = e.target.value;
+ setUrlParam('q', q);
+ renderTable(container, filterIndices(indices, q), ctx);
+ });
+
+ container.querySelector('#add-index-btn').addEventListener('click', () => {
+ openCreateModal(ctx, () => render(container, ctx));
+ });
+ } catch (err) {
+ container.querySelector('#indices-table').innerHTML = `Failed to load indices: ${err.message}
`;
+ }
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/journals.js b/tools/productbus-admin/journals.js
new file mode 100644
index 00000000..e0b72770
--- /dev/null
+++ b/tools/productbus-admin/journals.js
@@ -0,0 +1,384 @@
+/**
+ * ProductBus Admin - Journals viewer (admin only)
+ */
+
+import { apiFetch } from './api.js';
+import { showToast, escapeHtml } from './ui.js';
+
+const FILTER_DEBOUNCE_MS = 150;
+const MAX_RANGE_MS = 12 * 60 * 60 * 1000;
+const RANGE_ADJUST_MS = 15 * 60 * 1000;
+
+function toDatetimeLocal(date) {
+ const pad = (n) => String(n).padStart(2, '0');
+ const y = date.getFullYear();
+ const mo = pad(date.getMonth() + 1);
+ const d = pad(date.getDate());
+ const h = pad(date.getHours());
+ const mi = pad(date.getMinutes());
+ return `${y}-${mo}-${d}T${h}:${mi}`;
+}
+
+function fromDatetimeLocal(value) {
+ if (!value) return null;
+ const d = new Date(value);
+ if (Number.isNaN(d.getTime())) return null;
+ return d.toISOString();
+}
+
+/**
+ * Parse a timestamp string. Accepts any format the Date constructor handles,
+ * plus partial ISO-like timestamps with dash or colon time separators
+ * (matching the filesystem-safe form used in journal buckets and order IDs):
+ * 2026-04-02T17-49-29.869Z
+ * 2026-04-02T17-49-29
+ * 2026-04-02T17
+ * 2026-04-02
+ * Missing time components default to 0. Missing Z is treated as UTC.
+ */
+function parseFlexibleDate(input) {
+ const str = input.trim();
+ let d = new Date(str);
+ if (!Number.isNaN(d.getTime())) return d;
+ const m = str.match(/^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2})(?:[-:](\d{2})(?:[-:](\d{2})(\.\d+)?)?)?)?(Z)?$/);
+ if (m) {
+ const [, y, mo, dy, h = '00', mi = '00', s = '00', ms = '', z = ''] = m;
+ const iso = `${y}-${mo}-${dy}T${h}:${mi}:${s}${ms}${z || 'Z'}`;
+ d = new Date(iso);
+ if (!Number.isNaN(d.getTime())) return d;
+ }
+ return null;
+}
+
+function entryKey(entry) {
+ if (entry.id) return entry.id;
+ // Stable fallback: timestamp + event + any known identifier field.
+ return `${entry.timestamp || ''}|${entry.event || ''}|${entry.orderId || entry.entityId || ''}`;
+}
+
+function summarizeEntry(entry) {
+ const ts = entry.timestamp ? new Date(entry.timestamp).toISOString().replace('T', ' ').replace('Z', '') : '—';
+ const parts = [];
+ if (entry.event) parts.push(entry.event);
+ if (entry.orderId) parts.push(`order:${entry.orderId}`);
+ if (entry.type && entry.entityId) parts.push(`${entry.type}:${entry.entityId}`);
+ else if (entry.type) parts.push(entry.type);
+ if (entry.state) parts.push(`state:${entry.state}`);
+ if (entry.actor) parts.push(`by ${entry.actor}`);
+ return { ts, detail: parts.join(' · ') };
+}
+
+async function copyText(text, successMsg) {
+ try {
+ await navigator.clipboard.writeText(text);
+ showToast(successMsg);
+ } catch (err) {
+ showToast(`Copy failed: ${err.message}`, 'error');
+ }
+}
+
+export async function render(container, ctx) {
+ const urlParams = new URLSearchParams(window.location.search);
+ const initJournal = urlParams.get('journal') === 'orders' ? 'orders' : 'general';
+
+ function parseIsoParam(key, fallbackMs) {
+ const raw = urlParams.get(key);
+ if (raw) {
+ const d = new Date(raw);
+ if (!Number.isNaN(d.getTime())) return d.toISOString();
+ }
+ return new Date(fallbackMs).toISOString();
+ }
+
+ const state = {
+ journal: initJournal,
+ orderId: urlParams.get('orderId') || '',
+ filter: urlParams.get('filter') || '',
+ sinceIso: parseIsoParam('since', Date.now() - 15 * 60 * 1000),
+ untilIso: parseIsoParam('until', Date.now()),
+ allEntries: [],
+ expanded: new Set(),
+ loading: false,
+ };
+
+ function syncUrl() {
+ const url = new URL(window.location.href);
+ url.searchParams.set('journal', state.journal);
+ url.searchParams.set('since', state.sinceIso);
+ url.searchParams.set('until', state.untilIso);
+ if (state.orderId) url.searchParams.set('orderId', state.orderId);
+ else url.searchParams.delete('orderId');
+ if (state.filter) url.searchParams.set('filter', state.filter);
+ else url.searchParams.delete('filter');
+ window.history.replaceState({}, '', url);
+ }
+
+ container.innerHTML = `
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `;
+
+ syncUrl();
+
+ const typeSwitcher = container.querySelector('#journal-type');
+ const sinceInput = container.querySelector('#journals-since');
+ const untilInput = container.querySelector('#journals-until');
+ const orderIdWrap = container.querySelector('.journals-orderid');
+ const orderIdInput = container.querySelector('#journals-orderid');
+ const filterInput = container.querySelector('#journals-filter');
+ const refreshBtn = container.querySelector('#journals-refresh');
+ const copyAllBtn = container.querySelector('#journals-copy-all');
+ const listEl = container.querySelector('#journals-list');
+ const statusEl = container.querySelector('#journals-status');
+
+ function getFilteredEntries() {
+ if (!state.filter) return state.allEntries;
+ const needle = state.filter.toLowerCase();
+ return state.allEntries.filter((e) => JSON.stringify(e).toLowerCase().includes(needle));
+ }
+
+ function renderList() {
+ const visible = getFilteredEntries();
+ const total = state.allEntries.length;
+
+ if (state.loading) {
+ listEl.innerHTML = 'Loading entries…
';
+ statusEl.textContent = '';
+ return;
+ }
+
+ if (visible.length === 0) {
+ listEl.innerHTML = `
+
+
No entries
+
${total === 0 ? 'No journal entries in this time range.' : 'No entries match your filter.'}
+
+ `;
+ } else {
+ listEl.innerHTML = visible.map((entry) => {
+ const { ts, detail } = summarizeEntry(entry);
+ const key = entryKey(entry);
+ const isExpanded = state.expanded.has(key);
+ const pretty = JSON.stringify(entry, null, 2);
+ return `
+
+
+ ${escapeHtml(ts)}
+ ${escapeHtml(detail)}
+
+
+
+
${escapeHtml(pretty)}
+
+
+ `;
+ }).join('');
+ }
+
+ statusEl.textContent = `${visible.length} of ${total} entries · ${new Date(state.sinceIso).toISOString()} → ${new Date(state.untilIso).toISOString()}`;
+ }
+
+ async function fetchEntries() {
+ state.loading = true;
+ state.expanded.clear();
+ renderList();
+
+ const params = new URLSearchParams();
+ params.set('since', state.sinceIso);
+ params.set('until', state.untilIso);
+ if (state.journal === 'orders' && state.orderId) {
+ params.set('orderId', state.orderId);
+ }
+ const path = state.journal === 'orders' ? `orders/journal?${params}` : `journal?${params}`;
+
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, path, { method: 'GET' });
+ if (!resp.ok) {
+ const errMsg = resp.headers.get('x-error') || `HTTP ${resp.status}`;
+ if (resp.status === 404 && state.journal === 'orders' && state.orderId) {
+ showToast('Order not found', 'error');
+ state.allEntries = [];
+ } else {
+ showToast(errMsg, 'error');
+ state.allEntries = [];
+ }
+ } else {
+ const data = await resp.json();
+ const entries = Array.isArray(data.entries) ? data.entries : [];
+ entries.sort((a, b) => {
+ const ta = a.timestamp ? new Date(a.timestamp).getTime() : 0;
+ const tb = b.timestamp ? new Date(b.timestamp).getTime() : 0;
+ return tb - ta;
+ });
+ state.allEntries = entries;
+ }
+ } catch (err) {
+ showToast(`Failed to load journal: ${err.message}`, 'error');
+ state.allEntries = [];
+ } finally {
+ state.loading = false;
+ renderList();
+ }
+ }
+
+ function updateOrderIdVisibility() {
+ orderIdWrap.hidden = state.journal !== 'orders';
+ }
+
+ typeSwitcher.addEventListener('click', (e) => {
+ const btn = e.target.closest('button[data-journal]');
+ if (!btn) return;
+ const next = btn.dataset.journal;
+ if (next === state.journal) return;
+ state.journal = next;
+ typeSwitcher.querySelectorAll('button').forEach((b) => {
+ b.classList.toggle('active', b.dataset.journal === next);
+ });
+ updateOrderIdVisibility();
+ syncUrl();
+ fetchEntries();
+ });
+
+ function bindPasteToDatetimeLocal(input, kind) {
+ input.addEventListener('paste', (e) => {
+ const text = (e.clipboardData || window.clipboardData)?.getData('text');
+ if (!text) return;
+ const d = parseFlexibleDate(text);
+ if (!d) return;
+ e.preventDefault();
+
+ input.value = toDatetimeLocal(d);
+ if (kind === 'since') state.sinceIso = d.toISOString();
+ else state.untilIso = d.toISOString();
+
+ const sinceMs = new Date(state.sinceIso).getTime();
+ const untilMs = new Date(state.untilIso).getTime();
+ const diff = untilMs - sinceMs;
+ if (diff > MAX_RANGE_MS || diff < 0) {
+ if (kind === 'since') {
+ const newUntil = new Date(d.getTime() + RANGE_ADJUST_MS);
+ state.untilIso = newUntil.toISOString();
+ untilInput.value = toDatetimeLocal(newUntil);
+ } else {
+ const newSince = new Date(d.getTime() - RANGE_ADJUST_MS);
+ state.sinceIso = newSince.toISOString();
+ sinceInput.value = toDatetimeLocal(newSince);
+ }
+ }
+ syncUrl();
+ });
+ }
+
+ sinceInput.addEventListener('change', () => {
+ const iso = fromDatetimeLocal(sinceInput.value);
+ if (iso) {
+ state.sinceIso = iso;
+ syncUrl();
+ }
+ });
+ untilInput.addEventListener('change', () => {
+ const iso = fromDatetimeLocal(untilInput.value);
+ if (iso) {
+ state.untilIso = iso;
+ syncUrl();
+ }
+ });
+ bindPasteToDatetimeLocal(sinceInput, 'since');
+ bindPasteToDatetimeLocal(untilInput, 'until');
+
+ orderIdInput.addEventListener('change', () => {
+ state.orderId = orderIdInput.value.trim();
+ syncUrl();
+ });
+ orderIdInput.addEventListener('keydown', (e) => {
+ if (e.key === 'Enter') {
+ e.preventDefault();
+ state.orderId = orderIdInput.value.trim();
+ syncUrl();
+ fetchEntries();
+ }
+ });
+
+ let filterTimer = null;
+ filterInput.addEventListener('input', () => {
+ clearTimeout(filterTimer);
+ filterTimer = setTimeout(() => {
+ state.filter = filterInput.value;
+ syncUrl();
+ renderList();
+ }, FILTER_DEBOUNCE_MS);
+ });
+
+ refreshBtn.addEventListener('click', () => {
+ const sinceIso = fromDatetimeLocal(sinceInput.value);
+ const untilIso = fromDatetimeLocal(untilInput.value);
+ if (sinceIso) state.sinceIso = sinceIso;
+ if (untilIso) state.untilIso = untilIso;
+ state.orderId = orderIdInput.value.trim();
+ syncUrl();
+ fetchEntries();
+ });
+
+ copyAllBtn.addEventListener('click', () => {
+ const visible = getFilteredEntries();
+ if (visible.length === 0) {
+ showToast('No entries to copy', 'error');
+ return;
+ }
+ copyText(JSON.stringify(visible, null, 2), `Copied ${visible.length} entries`);
+ });
+
+ listEl.addEventListener('click', (e) => {
+ const copyBtn = e.target.closest('[data-action="copy"]');
+ const entryEl = e.target.closest('.journals-entry');
+ if (!entryEl) return;
+ const { key } = entryEl.dataset;
+ const entry = state.allEntries.find((en) => entryKey(en) === key);
+ if (copyBtn) {
+ e.stopPropagation();
+ if (entry) copyText(JSON.stringify(entry, null, 2), 'Entry copied');
+ return;
+ }
+ // Only the summary toggles expansion — clicks inside the details pane must
+ // not collapse the entry so users can select/copy JSON text.
+ if (!e.target.closest('.journals-entry-summary')) return;
+ if (state.expanded.has(key)) state.expanded.delete(key);
+ else state.expanded.add(key);
+ entryEl.classList.toggle('expanded');
+ });
+
+ updateOrderIdVisibility();
+ fetchEntries();
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/login.js b/tools/productbus-admin/login.js
new file mode 100644
index 00000000..496cc272
--- /dev/null
+++ b/tools/productbus-admin/login.js
@@ -0,0 +1,147 @@
+/**
+ * ProductBus Admin - Login page (OTP flow)
+ */
+
+import { apiFetch, setAuthState } from './api.js';
+import { showToast, escapeHtml } from './ui.js';
+
+function navigate(search) {
+ window.history.pushState({}, '', `${window.location.pathname}?${search}`);
+ window.dispatchEvent(new PopStateEvent('popstate'));
+}
+
+async function readError(resp) {
+ return resp.headers.get('x-error')
+ || (await resp.text().catch(() => '')).trim()
+ || `HTTP ${resp.status}`;
+}
+
+export async function render(container, ctx) {
+ const { org, site } = ctx;
+
+ container.innerHTML = `
+
+
ProductBus Admin Login
+
Enter your email to receive a verification code
+
+
+ `;
+
+ const form = document.getElementById('login-form');
+ let loginState = null;
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+ const submitBtn = form.querySelector('button[type="submit"]');
+ submitBtn.disabled = true;
+ submitBtn.textContent = 'Please wait...';
+
+ const resetSubmit = () => {
+ submitBtn.disabled = false;
+ submitBtn.textContent = loginState ? 'Verify' : 'Send Code';
+ };
+
+ try {
+ if (!loginState) {
+ // Step 1: Request OTP
+ const email = form.querySelector('#login-email').value;
+ const resp = await apiFetch(org, site, 'auth/login', {
+ method: 'POST',
+ body: JSON.stringify({ email }),
+ skipAuthRedirect: true,
+ });
+ if (!resp.ok) {
+ showToast(await readError(resp), 'error');
+ resetSubmit();
+ return;
+ }
+ const next = await resp.json();
+ loginState = { ...next, email };
+
+ // Switch to OTP entry
+ form.innerHTML = `
+
+
+
+
+
+
+
+
+
+ `;
+
+ form.querySelector('#login-back-btn').addEventListener('click', () => {
+ loginState = null;
+ render(container, ctx);
+ });
+ const otpInput = form.querySelector('#otp-code');
+ otpInput.focus();
+ otpInput.addEventListener('input', () => {
+ otpInput.classList.remove('input-error');
+ });
+ } else {
+ // Step 2: Verify OTP
+ const otpInput = form.querySelector('#otp-code');
+ const code = otpInput.value;
+ const resp = await apiFetch(org, site, 'auth/callback', {
+ method: 'POST',
+ body: JSON.stringify({
+ email: loginState.email,
+ code,
+ hash: loginState.hash,
+ exp: loginState.exp,
+ }),
+ skipAuthRedirect: true,
+ });
+ if (resp.status === 401) {
+ otpInput.classList.add('input-error');
+ otpInput.focus();
+ otpInput.select();
+ showToast('Invalid code', 'error');
+ resetSubmit();
+ return;
+ }
+ if (!resp.ok) {
+ showToast(await readError(resp), 'error');
+ resetSubmit();
+ return;
+ }
+ const result = await resp.json();
+
+ setAuthState(org, site, {
+ token: result.token,
+ email: result.email,
+ roles: result.roles,
+ org: result.org,
+ site: result.site,
+ });
+
+ const p = new URLSearchParams();
+ p.set('org', org);
+ p.set('site', site);
+ p.set('page', 'orders');
+ navigate(p.toString());
+ }
+ } catch (error) {
+ showToast(error.message || 'Login failed', 'error');
+ resetSubmit();
+ }
+ });
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/orders.js b/tools/productbus-admin/orders.js
new file mode 100644
index 00000000..8bd4a81f
--- /dev/null
+++ b/tools/productbus-admin/orders.js
@@ -0,0 +1,286 @@
+/**
+ * ProductBus Admin - Orders page
+ */
+
+import { apiFetch } from './api.js';
+import {
+ showToast, createModal, createFormField, getUrlParam, setUrlParam, escapeHtml,
+} from './ui.js';
+
+function renderTable(container, orders, ctx) {
+ const tableWrap = container.querySelector('#orders-table');
+ if (orders.length === 0) {
+ tableWrap.innerHTML = `
+
+
No orders found
+
Create your first order to get started
+
+ `;
+ return;
+ }
+
+ tableWrap.innerHTML = `
+
+
+
+ | Order ID |
+ State |
+ Customer |
+ Items |
+ Created |
+ Actions |
+
+
+
+ ${orders.map((o) => `
+
+ ${escapeHtml(o.id)} |
+ ${escapeHtml(o.state || 'pending')} |
+ ${escapeHtml(o.customer?.email || o.customMetadata?.customerEmail || 'N/A')} |
+ ${o.items?.length ?? '—'} |
+ ${o.createdAt ? new Date(o.createdAt).toLocaleDateString() : 'N/A'} |
+
+
+
+
+ |
+
+ `).join('')}
+
+
+ `;
+
+ tableWrap.querySelectorAll('[data-action="view"]').forEach((btn) => {
+ btn.addEventListener('click', async () => {
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, `orders/${encodeURIComponent(btn.dataset.id)}`, { method: 'GET' });
+ const order = await resp.json();
+ // eslint-disable-next-line no-use-before-define
+ viewOrderModal(order);
+ } catch (err) {
+ showToast(`Failed to load order: ${err.message}`, 'error');
+ }
+ });
+ });
+}
+
+function viewOrderModal(order) {
+ const pre = document.createElement('pre');
+ pre.className = 'json-display';
+ // textContent is already safe (not parsed as HTML); modal title flows through
+ // createModal -> titleEl.textContent, also safe.
+ pre.textContent = JSON.stringify(order, null, 2);
+ createModal(`Order: ${order.id}`, pre);
+}
+
+function openCreateModal(ctx, onCreated) {
+ const content = document.createElement('div');
+ let currentView = 'form';
+
+ function renderView() {
+ if (currentView === 'form') {
+ content.innerHTML = `
+
+
+
+
+
+ `;
+ } else {
+ content.innerHTML = `
+
+
+
+
+
+ `;
+ }
+
+ content.querySelectorAll('.view-switcher button').forEach((btn) => {
+ btn.addEventListener('click', () => {
+ currentView = btn.dataset.view;
+ renderView();
+ });
+ });
+ }
+
+ renderView();
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ const dialog = createModal('Create Order', content, footer);
+
+ dialog.querySelector('.cancel-btn').addEventListener('click', () => {
+ dialog.close();
+ dialog.remove();
+ });
+
+ dialog.querySelector('.save-btn').addEventListener('click', async () => {
+ const saveBtn = dialog.querySelector('.save-btn');
+ saveBtn.disabled = true;
+ saveBtn.textContent = 'Creating...';
+
+ try {
+ let orderData;
+ if (currentView === 'json') {
+ orderData = JSON.parse(dialog.querySelector('#json-editor').value);
+ } else {
+ const form = dialog.querySelector('#order-form');
+ const fd = new FormData(form);
+ orderData = { customer: {}, shipping: {}, items: [{}] };
+ fd.forEach((val, key) => {
+ const parts = key.split('.');
+ if (parts[0] === 'items') {
+ const idx = parseInt(parts[1], 10);
+ if (!orderData.items[idx]) orderData.items[idx] = {};
+ if (parts.length === 4) {
+ if (!orderData.items[idx][parts[2]]) orderData.items[idx][parts[2]] = {};
+ orderData.items[idx][parts[2]][parts[3]] = val;
+ } else {
+ orderData.items[idx][parts[2]] = val;
+ }
+ } else if (parts.length === 2) {
+ orderData[parts[0]][parts[1]] = val;
+ }
+ });
+ }
+
+ await apiFetch(ctx.org, ctx.site, 'orders', {
+ method: 'POST',
+ body: JSON.stringify(orderData),
+ });
+ showToast('Order created');
+ dialog.close();
+ dialog.remove();
+ onCreated();
+ } catch (err) {
+ showToast(`Failed to create order: ${err.message}`, 'error');
+ saveBtn.disabled = false;
+ saveBtn.textContent = 'Create Order';
+ }
+ });
+}
+
+export async function render(container, ctx) {
+ const initialQ = getUrlParam('q');
+ container.innerHTML = `
+
+
+
+
+
+
+ `;
+
+ function filterOrders(orders, q) {
+ if (!q) return orders;
+ const needle = q.toLowerCase();
+ return orders.filter((o) => (o.id || '').toLowerCase().includes(needle)
+ || (o.customer?.email || '').toLowerCase().includes(needle)
+ || (o.state || '').toLowerCase().includes(needle));
+ }
+
+ try {
+ const resp = await apiFetch(ctx.org, ctx.site, 'orders', { method: 'GET' });
+ const data = await resp.json();
+ const orders = data.orders || data || [];
+
+ renderTable(container, filterOrders(orders, initialQ), ctx);
+
+ container.querySelector('#search-orders').addEventListener('input', (e) => {
+ const q = e.target.value;
+ setUrlParam('q', q);
+ renderTable(container, filterOrders(orders, q), ctx);
+ });
+
+ container.querySelector('#add-order-btn').addEventListener('click', () => {
+ openCreateModal(ctx, () => render(container, ctx));
+ });
+ } catch (err) {
+ container.querySelector('#orders-table').innerHTML = `Failed to load orders: ${err.message}
`;
+ }
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/productbus-admin.css b/tools/productbus-admin/productbus-admin.css
new file mode 100644
index 00000000..b1504764
--- /dev/null
+++ b/tools/productbus-admin/productbus-admin.css
@@ -0,0 +1,836 @@
+/* ProductBus Admin Styles */
+
+/* Override AEM section styles to preserve grid layout */
+.productbus-admin main > .section {
+ margin: 0;
+}
+
+/* stylelint-disable-next-line no-descending-specificity */
+.productbus-admin main > .section > div {
+ margin: 0;
+ padding: 0;
+}
+
+/* App Layout — mobile first: single column */
+.productbus-admin #app-container {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--spacing-l);
+ min-height: 600px;
+}
+
+/* Sidebar — mobile first: fixed drawer, hidden off-canvas until .open */
+.productbus-admin #sidebar {
+ position: fixed;
+ left: -280px;
+ top: 0;
+ bottom: 0;
+ width: 260px;
+ z-index: 1000;
+ padding: var(--spacing-m);
+ border: none;
+ border-right: 1px solid var(--color-border);
+ border-radius: 0;
+ background: var(--layer-elevated);
+ box-shadow: 2px 0 8px rgb(0 0 0 / 10%);
+ transition: left 0.3s ease;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-m);
+ overflow-y: auto;
+}
+
+.productbus-admin #sidebar.open {
+ left: 0;
+}
+
+.productbus-admin .sidebar-connect {
+ padding-bottom: var(--spacing-m);
+ border-bottom: 1px solid var(--color-border);
+}
+
+.productbus-admin .sidebar-connect h2 {
+ margin: 0 0 var(--spacing-m) 0;
+ font-size: var(--heading-size-s);
+ color: var(--color-text);
+}
+
+.productbus-admin .sidebar-connect form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-s);
+}
+
+.productbus-admin .sidebar-connect .connect-btn {
+ width: 100%;
+ justify-content: center;
+ margin-top: var(--spacing-xs);
+}
+
+.productbus-admin .sidebar-nav {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+}
+
+.productbus-admin .sidebar-nav a {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-s);
+ padding: var(--spacing-s) var(--spacing-m);
+ border-radius: 6px;
+ text-decoration: none;
+ color: var(--color-text);
+ transition: background-color 0.2s ease;
+}
+
+.productbus-admin .sidebar-nav a:hover {
+ background: light-dark(var(--gray-100), var(--gray-700));
+}
+
+.productbus-admin .sidebar-nav a.active {
+ background: var(--blue-100);
+ color: var(--blue-700);
+ font-weight: 600;
+}
+
+.productbus-admin .sidebar-footer {
+ padding-top: var(--spacing-m);
+ border-top: 1px solid var(--color-border);
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-s);
+}
+
+.productbus-admin .sidebar-email {
+ font-size: var(--body-size-s);
+ color: var(--color-font-grey);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.productbus-admin .sidebar-footer .button {
+ width: 100%;
+ justify-content: center;
+}
+
+/* Main Content */
+.productbus-admin #main-content {
+ min-height: 400px;
+ min-width: 0;
+ width: 100%;
+}
+
+.productbus-admin .page-header {
+ margin-bottom: var(--spacing-l);
+}
+
+.productbus-admin .page-header h1 {
+ margin: 0 0 var(--spacing-xs) 0;
+ font-size: var(--heading-size-l);
+}
+
+.productbus-admin .page-header p {
+ margin: 0;
+ color: var(--color-font-grey);
+}
+
+/* Loading/Error States */
+.productbus-admin .loading,
+.productbus-admin .error {
+ padding: var(--spacing-xl);
+ text-align: center;
+ color: var(--color-font-grey);
+ font-style: italic;
+}
+
+.productbus-admin .error {
+ color: var(--red-600);
+}
+
+/* Login Page */
+.productbus-admin .login-wrap {
+ max-width: 500px;
+ margin: var(--spacing-xxl) auto;
+ padding: var(--spacing-xl);
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ background: var(--layer-elevated);
+ box-shadow: var(--shadow-default);
+}
+
+.productbus-admin .login-wrap h1 {
+ margin: 0 0 var(--spacing-s) 0;
+ text-align: center;
+}
+
+.productbus-admin .login-wrap .subtitle {
+ text-align: center;
+ color: var(--color-font-grey);
+ margin: 0 0 var(--spacing-l) 0;
+}
+
+.productbus-admin .login-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-m);
+}
+
+/* Forms */
+.productbus-admin .form-field {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-xs);
+}
+
+.productbus-admin .form-field label {
+ font-weight: 600;
+ color: var(--color-text);
+ font-size: var(--body-size-s);
+}
+
+.productbus-admin .form-field input,
+.productbus-admin .form-field textarea,
+.productbus-admin .form-field select {
+ padding: var(--spacing-s) var(--spacing-m);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ font-size: var(--body-size-m);
+ background: var(--layer-elevated);
+ color: var(--color-text);
+ font-family: inherit;
+}
+
+.productbus-admin .form-field input:focus,
+.productbus-admin .form-field textarea:focus,
+.productbus-admin .form-field select:focus {
+ outline: none;
+ border-color: var(--blue-500);
+ box-shadow: 0 0 0 2px var(--blue-100);
+}
+
+.productbus-admin .form-field input:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.productbus-admin .form-field input.input-error,
+.productbus-admin .form-field input.input-error:focus {
+ border-color: var(--red-600);
+ box-shadow: 0 0 0 2px light-dark(var(--red-100), rgb(239 68 68 / 25%));
+}
+
+.productbus-admin .form-field input[type="checkbox"] {
+ width: auto;
+ margin-right: var(--spacing-xs);
+}
+
+.productbus-admin .form-field .field-hint {
+ font-size: var(--body-size-s);
+ color: var(--color-font-grey);
+ margin: 0;
+}
+
+.productbus-admin .button-group {
+ display: flex;
+ gap: var(--spacing-s);
+ margin-top: var(--spacing-m);
+}
+
+.productbus-admin .button-group .button {
+ flex: 1;
+}
+
+/* Data Table */
+.productbus-admin .data-table {
+ width: 100%;
+ border-collapse: collapse;
+ margin-top: var(--spacing-m);
+}
+
+.productbus-admin .data-table th,
+.productbus-admin .data-table td {
+ padding: var(--spacing-s) var(--spacing-m);
+ text-align: left;
+ border-bottom: 1px solid var(--color-border);
+}
+
+.productbus-admin .data-table th {
+ background: var(--layer-elevated);
+ font-weight: 600;
+ color: var(--color-text);
+ font-size: var(--body-size-s);
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.productbus-admin .data-table tr:hover {
+ background: light-dark(var(--gray-50), var(--gray-800));
+}
+
+.productbus-admin .data-table code {
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+}
+
+.productbus-admin .data-table .actions {
+ display: flex;
+ gap: var(--spacing-xs);
+ justify-content: flex-end;
+}
+
+.productbus-admin .data-table .btn-icon {
+ background: none;
+ border: 1px solid var(--color-border);
+ cursor: pointer;
+ padding: 4px 8px;
+ border-radius: 4px;
+ color: var(--color-text);
+ font-size: var(--body-size-xs);
+ transition: all 0.15s;
+}
+
+.productbus-admin .data-table .btn-icon:hover {
+ background: light-dark(var(--gray-200), var(--gray-700));
+}
+
+.productbus-admin .data-table .btn-icon.danger {
+ color: var(--red-700);
+ border-color: var(--red-700);
+}
+
+.productbus-admin .data-table .btn-icon.danger:hover {
+ background: var(--red-700);
+ color: white;
+}
+
+/* Page Actions — mobile first: stacked */
+.productbus-admin .page-actions {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ margin: 0 var(--spacing-l) var(--spacing-l) var(--spacing-m);
+ gap: var(--spacing-m);
+}
+
+.productbus-admin .page-actions .search-input {
+ padding: var(--spacing-s) var(--spacing-m);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ flex: 1;
+ max-width: none;
+ background: var(--layer-elevated);
+ color: var(--color-text);
+}
+
+.productbus-admin .page-actions .search-input:focus {
+ outline: none;
+ border-color: var(--blue-500);
+ box-shadow: 0 0 0 2px var(--blue-100);
+}
+
+/* Modal Styles */
+.productbus-admin dialog.productbus-modal {
+ max-width: 800px;
+ width: 90vw;
+ max-height: 90vh;
+ border: 1px solid var(--color-border);
+ border-radius: 12px;
+ padding: 0;
+ box-shadow: var(--shadow-hover);
+ background: var(--layer-elevated);
+ position: fixed;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ margin: 0;
+}
+
+.productbus-admin dialog.productbus-modal::backdrop {
+ background: rgb(0 0 0 / 50%);
+}
+
+.productbus-admin dialog.productbus-modal .modal-content {
+ display: flex;
+ flex-direction: column;
+ max-height: 90vh;
+}
+
+.productbus-admin dialog.productbus-modal .modal-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: var(--spacing-m) var(--spacing-l);
+ border-bottom: 1px solid var(--color-border);
+ background: var(--layer-elevated);
+ border-radius: 12px 12px 0 0;
+}
+
+.productbus-admin dialog.productbus-modal .modal-header h2 {
+ margin: 0;
+ font-size: var(--heading-size-m);
+ color: var(--color-text);
+}
+
+.productbus-admin dialog.productbus-modal .modal-close {
+ background: none;
+ border: none;
+ font-size: 1.5rem;
+ line-height: 1;
+ color: var(--color-font-grey);
+ cursor: pointer;
+ padding: 0;
+ width: 28px;
+ height: 28px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 4px;
+ transition: all 0.2s ease;
+}
+
+.productbus-admin dialog.productbus-modal .modal-close:hover {
+ background: light-dark(var(--gray-200), var(--gray-700));
+ color: var(--color-text);
+}
+
+.productbus-admin dialog.productbus-modal .modal-body {
+ padding: var(--spacing-l);
+ overflow-y: auto;
+ flex: 1;
+}
+
+.productbus-admin dialog.productbus-modal .modal-footer {
+ display: flex;
+ gap: var(--spacing-s);
+ justify-content: flex-end;
+ padding: var(--spacing-l);
+ border-top: 1px solid var(--color-border);
+}
+
+/* View Switcher (Form/JSON toggle) */
+.productbus-admin .view-switcher {
+ display: flex;
+ gap: var(--spacing-xs);
+ margin-bottom: var(--spacing-m);
+ padding: 4px;
+ background: light-dark(var(--gray-100), var(--gray-800));
+ border-radius: 6px;
+ width: fit-content;
+}
+
+.productbus-admin .view-switcher button {
+ padding: var(--spacing-xs) var(--spacing-m);
+ border: none;
+ background: transparent;
+ color: var(--color-text);
+ cursor: pointer;
+ border-radius: 4px;
+ font-size: var(--body-size-s);
+ font-weight: 500;
+ transition: background-color 0.2s ease;
+}
+
+.productbus-admin .view-switcher button.active {
+ background: var(--layer-elevated);
+ box-shadow: var(--shadow-default);
+}
+
+/* JSON Editor */
+.productbus-admin .json-editor {
+ width: 100%;
+ min-height: 300px;
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+ padding: var(--spacing-m);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--layer-elevated);
+ color: var(--color-text);
+ resize: vertical;
+ box-sizing: border-box;
+}
+
+.productbus-admin .json-editor:focus {
+ outline: none;
+ border-color: var(--blue-500);
+ box-shadow: 0 0 0 2px var(--blue-100);
+}
+
+/* JSON Display (read-only) */
+.productbus-admin .json-display {
+ background: var(--layer-elevated);
+ padding: var(--spacing-m);
+ border-radius: 8px;
+ overflow-x: auto;
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+ border: 1px solid var(--color-border);
+ white-space: pre-wrap;
+ overflow-wrap: break-word;
+}
+
+/* Form View — mobile first: single column */
+.productbus-admin .form-view {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-m);
+}
+
+.productbus-admin .form-row {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--spacing-m);
+ align-items: flex-end;
+}
+
+.productbus-admin .form-view h3 {
+ margin: var(--spacing-m) 0 0;
+ font-size: var(--body-size-l);
+ color: var(--color-text);
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--spacing-xs);
+}
+
+/* Confirmation Modal */
+.productbus-admin .confirm-message {
+ margin: 0;
+ color: var(--color-text);
+ font-size: var(--body-size-m);
+ line-height: 1.5;
+}
+
+/* Toast Notifications */
+.productbus-admin .productbus-toast {
+ position: fixed;
+ bottom: var(--spacing-l);
+ left: 50%;
+ transform: translateX(-50%) translateY(100px);
+ padding: var(--spacing-m) var(--spacing-l);
+ border-radius: 8px;
+ box-shadow: var(--shadow-hover);
+ z-index: 10000;
+ opacity: 0;
+ transition: all 0.3s ease;
+ max-width: 500px;
+ pointer-events: none;
+}
+
+.productbus-admin .productbus-toast.show {
+ transform: translateX(-50%) translateY(0);
+ opacity: 1;
+}
+
+.productbus-admin .productbus-toast.success {
+ background: var(--green-700);
+ color: white;
+}
+
+.productbus-admin .productbus-toast.error {
+ background: var(--red-800);
+ color: white;
+}
+
+/* Badge */
+.productbus-admin .badge {
+ display: inline-block;
+ padding: 2px 8px;
+ border-radius: 4px;
+ font-size: var(--body-size-xs);
+ font-weight: 600;
+ text-transform: uppercase;
+}
+
+.productbus-admin .badge.success {
+ background: var(--green-100);
+ color: var(--green-800);
+}
+
+.productbus-admin .badge.info {
+ background: var(--blue-100);
+ color: var(--blue-800);
+}
+
+/* Config Actions */
+.productbus-admin .config-actions {
+ display: flex;
+ gap: var(--spacing-s);
+ justify-content: flex-end;
+ margin-top: var(--spacing-m);
+}
+
+/* Empty State */
+.productbus-admin .empty-state {
+ padding: var(--spacing-xxl);
+ text-align: center;
+}
+
+/* stylelint-disable-next-line no-descending-specificity */
+.productbus-admin .empty-state h3 {
+ margin: 0 0 var(--spacing-s) 0;
+ color: var(--color-text);
+}
+
+.productbus-admin .empty-state p {
+ margin: 0 0 var(--spacing-l) 0;
+ color: var(--color-font-grey);
+}
+
+/* Danger Button */
+.productbus-admin .button.danger {
+ color: var(--red-700);
+ border-color: var(--red-700);
+}
+
+.productbus-admin .button.danger:hover {
+ background: var(--red-700);
+ color: white;
+ border-color: var(--red-700);
+}
+
+/* Journals View — mobile first: single column */
+.productbus-admin .journals-controls {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--spacing-m);
+ align-items: end;
+ margin-bottom: var(--spacing-l);
+}
+
+.productbus-admin .journals-controls .view-switcher {
+ margin-bottom: 0;
+ align-self: end;
+}
+
+.productbus-admin .journals-controls .form-field {
+ margin: 0;
+}
+
+.productbus-admin .journals-actions {
+ display: flex;
+ gap: var(--spacing-s);
+ justify-content: stretch;
+}
+
+.productbus-admin .journals-entry {
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--layer-elevated);
+ margin-bottom: var(--spacing-xs);
+ transition: background-color 0.15s ease;
+}
+
+.productbus-admin .journals-entry:hover {
+ background: light-dark(var(--gray-50), var(--gray-800));
+}
+
+.productbus-admin .journals-entry-summary {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-m);
+ padding: var(--spacing-s) var(--spacing-m);
+ cursor: pointer;
+ min-width: 0;
+}
+
+.productbus-admin .journals-entry-ts {
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+ color: var(--color-font-grey);
+ white-space: nowrap;
+ flex: 0 0 auto;
+}
+
+.productbus-admin .journals-entry-detail {
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+ color: var(--color-text);
+ flex: 1;
+ min-width: 0;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.productbus-admin .journals-entry .journals-copy-btn {
+ flex: 0 0 auto;
+}
+
+.productbus-admin .journals-entry-details {
+ display: none;
+ padding: 0 var(--spacing-m) var(--spacing-m);
+}
+
+.productbus-admin .journals-entry.expanded .journals-entry-details {
+ display: block;
+}
+
+.productbus-admin .journals-entry-details .json-display {
+ margin: 0;
+ max-height: 500px;
+ overflow: auto;
+}
+
+.productbus-admin .journals-status {
+ margin-top: var(--spacing-m);
+ font-size: var(--body-size-s);
+ color: var(--color-font-grey);
+}
+
+/* Service Tokens View — mobile first: single column */
+.productbus-admin .service-token-form {
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-m);
+ max-width: 700px;
+}
+
+/* stylelint-disable-next-line no-descending-specificity */
+.productbus-admin .service-token-form h2 {
+ margin: var(--spacing-s) 0 0;
+ font-size: var(--body-size-l);
+ border-bottom: 1px solid var(--color-border);
+ padding-bottom: var(--spacing-xs);
+}
+
+.productbus-admin .permissions-grid {
+ display: grid;
+ grid-template-columns: 1fr;
+ gap: var(--spacing-xs) var(--spacing-m);
+}
+
+.productbus-admin .permission-item {
+ display: flex;
+ align-items: center;
+ gap: var(--spacing-xs);
+ padding: var(--spacing-xs);
+ border-radius: 4px;
+ cursor: pointer;
+}
+
+.productbus-admin .permission-item:hover {
+ background: light-dark(var(--gray-50), var(--gray-800));
+}
+
+.productbus-admin .permission-item input[type="checkbox"] {
+ width: auto;
+ margin: 0;
+}
+
+.productbus-admin .field-hint-inline {
+ font-weight: 400;
+ color: var(--color-font-grey);
+ font-size: var(--body-size-s);
+}
+
+.productbus-admin .token-result {
+ max-width: 800px;
+ display: flex;
+ flex-direction: column;
+ gap: var(--spacing-m);
+}
+
+.productbus-admin .token-warning {
+ padding: var(--spacing-m);
+ border: 1px solid light-dark(var(--orange-400, #f59e0b), var(--orange-600, #d97706));
+ background: light-dark(var(--orange-100, #fef3c7), rgb(245 158 11 / 15%));
+ color: light-dark(var(--orange-800, #92400e), var(--orange-200, #fde68a));
+ border-radius: 6px;
+ font-weight: 600;
+}
+
+.productbus-admin .token-box {
+ display: flex;
+ align-items: flex-start;
+ gap: var(--spacing-s);
+ padding: var(--spacing-m);
+ border: 1px solid var(--color-border);
+ border-radius: 6px;
+ background: var(--layer-elevated);
+}
+
+.productbus-admin .token-box code {
+ flex: 1;
+ font-family: var(--code-font-family);
+ font-size: var(--body-size-s);
+ word-break: break-all;
+ color: var(--color-text);
+}
+
+.productbus-admin .token-meta {
+ font-size: var(--body-size-s);
+ color: var(--color-text);
+}
+
+.productbus-admin .token-permissions {
+ margin: 0;
+ padding-left: var(--spacing-l);
+ font-size: var(--body-size-s);
+}
+
+.productbus-admin .token-permissions code {
+ font-family: var(--code-font-family);
+}
+
+/* Tablet and up */
+@media (width >= 600px) {
+ .productbus-admin #app-container {
+ grid-template-columns: 260px 1fr;
+ }
+
+ .productbus-admin #sidebar {
+ position: sticky;
+ top: var(--spacing-m);
+ left: auto;
+ bottom: auto;
+ width: auto;
+ height: calc(100vh - 5 * var(--spacing-m));
+ max-height: calc(100vh - 5 * var(--spacing-m));
+ z-index: auto;
+ border: 1px solid var(--color-border);
+ border-radius: 8px;
+ background: var(--layer-elevated);
+ box-shadow: none;
+ transition: none;
+ align-self: start;
+ overflow-y: visible;
+ }
+
+ .productbus-admin .page-actions {
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ }
+
+ .productbus-admin .page-actions .search-input {
+ max-width: 400px;
+ }
+
+ .productbus-admin .form-row {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .productbus-admin .journals-controls {
+ grid-template-columns: auto 1fr 1fr auto;
+ }
+
+ .productbus-admin .journals-controls .journals-orderid,
+ .productbus-admin .journals-controls .journals-filter {
+ grid-column: 1 / -1;
+ }
+
+ .productbus-admin .journals-actions {
+ justify-content: flex-end;
+ }
+
+ .productbus-admin .permissions-grid {
+ grid-template-columns: 1fr 1fr;
+ }
+}
diff --git a/tools/productbus-admin/productbus-admin.js b/tools/productbus-admin/productbus-admin.js
new file mode 100644
index 00000000..32a20908
--- /dev/null
+++ b/tools/productbus-admin/productbus-admin.js
@@ -0,0 +1,263 @@
+/**
+ * ProductBus Admin - Main entry
+ * Handles routing, sidebar, and auth guard
+ */
+
+import { registerToolReady } from '../../scripts/scripts.js';
+import { getAuthState, clearAuthState, apiFetch } from './api.js';
+import { escapeHtml } from './ui.js';
+
+// ============================================================================
+// Query Params
+// ============================================================================
+
+function getParams() {
+ const p = new URLSearchParams(window.location.search);
+ return {
+ page: p.get('page') || '',
+ org: p.get('org') || '',
+ site: p.get('site') || '',
+ redirect: p.get('redirect') || '',
+ };
+}
+
+function setParams(updates) {
+ const p = new URLSearchParams(window.location.search);
+ Object.entries(updates).forEach(([k, v]) => {
+ if (v) p.set(k, v);
+ else p.delete(k);
+ });
+ window.history.pushState({}, '', `${window.location.pathname}?${p.toString()}`);
+}
+
+// ============================================================================
+// Page modules map (dynamic imports to avoid cycles)
+// ============================================================================
+
+const PAGE_MODULES = {
+ login: () => import('./login.js'),
+ orders: () => import('./orders.js'),
+ customers: () => import('./customers.js'),
+ indices: () => import('./indices.js'),
+ catalog: () => import('./catalog.js'),
+ config: () => import('./config.js'),
+ journals: () => import('./journals.js'),
+ 'service-tokens': () => import('./service-tokens.js'),
+ admins: () => import('./admins.js'),
+};
+
+// ============================================================================
+// Sidebar
+// ============================================================================
+
+// Forward-declared so renderSidebar and bindConnectForm can reference renderApp
+let renderApp;
+
+function bindConnectForm() {
+ const form = document.getElementById('connect-form');
+ if (!form) return;
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const org = form.querySelector('#connect-org').value.trim();
+ const site = form.querySelector('#connect-site').value.trim();
+ if (!org || !site) return;
+
+ setParams({ org, site });
+
+ const auth = getAuthState(org, site);
+ if (!auth) {
+ setParams({ page: 'login', org, site });
+ } else {
+ const params = getParams();
+ if (!params.page || params.page === 'login') {
+ setParams({ page: 'orders' });
+ }
+ }
+ renderApp();
+ });
+}
+
+function renderSidebar(org, site, currentPage) {
+ const sidebar = document.getElementById('sidebar');
+ const auth = getAuthState(org, site);
+
+ const connectForm = `
+
+ `;
+
+ if (!auth || !org || !site) {
+ sidebar.innerHTML = connectForm;
+ bindConnectForm();
+ return;
+ }
+
+ const navLinks = [
+ { id: 'orders', label: 'Orders' },
+ { id: 'customers', label: 'Customers' },
+ { id: 'indices', label: 'Indices' },
+ { id: 'catalog', label: 'Catalog' },
+ { id: 'config', label: 'Config' },
+ ];
+
+ const isAdmin = auth.roles && (auth.roles.includes('admin') || auth.roles.includes('superuser'));
+ if (isAdmin) {
+ navLinks.push({ id: 'journals', label: 'Journals' });
+ navLinks.push({ id: 'service-tokens', label: 'Service Tokens' });
+ }
+
+ if (auth.roles && auth.roles.includes('superuser')) {
+ navLinks.push({ id: 'admins', label: 'Admins' });
+ }
+
+ sidebar.innerHTML = `
+ ${connectForm}
+
+
+ `;
+
+ bindConnectForm();
+
+ sidebar.querySelectorAll('a[data-page]').forEach((link) => {
+ link.addEventListener('click', (e) => {
+ e.preventDefault();
+ // Switching pages wipes view-specific query params so shared URLs only
+ // carry the state the target view itself writes back in.
+ const p = new URLSearchParams();
+ if (org) p.set('org', org);
+ if (site) p.set('site', site);
+ p.set('page', link.dataset.page);
+ window.history.pushState({}, '', `${window.location.pathname}?${p.toString()}`);
+ renderApp();
+ });
+ });
+
+ sidebar.querySelector('#logout-btn').addEventListener('click', async () => {
+ try {
+ await apiFetch(org, site, 'auth/logout', { method: 'POST' });
+ } catch (e) {
+ // ignore
+ }
+ clearAuthState(org, site);
+ setParams({ page: 'login', redirect: '' });
+ renderApp();
+ });
+}
+
+// ============================================================================
+// Router
+// ============================================================================
+
+let currentModule = null;
+
+async function renderPage(page, org, site) {
+ const mainContent = document.getElementById('main-content');
+
+ if (currentModule && currentModule.destroy) {
+ currentModule.destroy();
+ }
+
+ const loader = PAGE_MODULES[page];
+ if (!loader) {
+ mainContent.innerHTML = 'Page not found
';
+ return;
+ }
+
+ try {
+ currentModule = await loader();
+ await currentModule.render(mainContent, { org, site });
+ } catch (err) {
+ mainContent.innerHTML = `Failed to load page: ${err.message}
`;
+ }
+}
+
+// ============================================================================
+// Main render
+// ============================================================================
+
+renderApp = function renderAppFn() {
+ const { page, org, site } = getParams();
+
+ if (!org || !site) {
+ renderSidebar('', '', '');
+ document.getElementById('main-content').innerHTML = `
+
+
Welcome to ProductBus Admin
+
Enter your organization and site in the sidebar to get started
+
+ `;
+ return;
+ }
+
+ const auth = getAuthState(org, site);
+
+ if (!auth && page !== 'login') {
+ const currentUrl = window.location.href;
+ setParams({ page: 'login', redirect: currentUrl });
+ renderSidebar(org, site, 'login');
+ renderPage('login', org, site);
+ return;
+ }
+
+ const activePage = page || (auth ? 'orders' : 'login');
+ if (!page) {
+ setParams({ page: activePage });
+ }
+
+ renderSidebar(org, site, activePage);
+ renderPage(activePage, org, site);
+};
+
+// ============================================================================
+// Init
+// ============================================================================
+
+async function init() {
+ const stageParam = new URLSearchParams(window.location.search).get('stage');
+ if (stageParam === 'true') {
+ sessionStorage.setItem('productbus-stage', 'true');
+ } else if (stageParam === 'false') {
+ sessionStorage.removeItem('productbus-stage');
+ }
+
+ const container = document.getElementById('app-container');
+
+ const sidebar = document.createElement('nav');
+ sidebar.id = 'sidebar';
+ sidebar.setAttribute('aria-label', 'ProductBus Admin');
+
+ const mainContent = document.createElement('div');
+ mainContent.id = 'main-content';
+
+ container.append(sidebar, mainContent);
+
+ renderApp();
+ window.addEventListener('popstate', () => renderApp());
+}
+
+registerToolReady(init());
diff --git a/tools/productbus-admin/service-tokens.js b/tools/productbus-admin/service-tokens.js
new file mode 100644
index 00000000..16827f3a
--- /dev/null
+++ b/tools/productbus-admin/service-tokens.js
@@ -0,0 +1,197 @@
+/**
+ * ProductBus Admin - Service Tokens (admin only)
+ * Backend supports create + revoke only; this view is create-only.
+ * Token is shown once after creation — the API never returns it again.
+ */
+
+import { apiFetch } from './api.js';
+import { showToast, escapeHtml } from './ui.js';
+
+const ALLOWED_PERMISSIONS = [
+ 'catalog:read',
+ 'catalog:write',
+ 'orders:read',
+ 'orders:write',
+ 'orders:custom:write',
+ 'index:read',
+ 'index:write',
+ 'customers:read',
+ 'customers:write',
+ 'price_rules:read',
+ 'price_rules:write',
+ 'journal:general:read',
+ 'journal:orders:read',
+ 'journal:*:read',
+ 'emails:send',
+];
+
+const MAX_TTL_SECONDS = 365 * 24 * 60 * 60;
+
+function humanizeSeconds(total) {
+ if (total % 86400 === 0) return `${total / 86400} day${total === 86400 ? '' : 's'}`;
+ if (total % 3600 === 0) return `${total / 3600} hour${total === 3600 ? '' : 's'}`;
+ if (total % 60 === 0) return `${total / 60} minute${total === 60 ? '' : 's'}`;
+ return `${total} second${total === 1 ? '' : 's'}`;
+}
+
+function renderForm(container, ctx) {
+ container.innerHTML = `
+
+
+ `;
+
+ const form = container.querySelector('#service-token-form');
+ const createBtn = container.querySelector('#create-btn');
+
+ form.addEventListener('submit', async (e) => {
+ e.preventDefault();
+
+ const checked = Array.from(form.querySelectorAll('input[name="permission"]:checked')).map((c) => c.value);
+ const scopesRaw = form.querySelector('#email-scopes').value.trim();
+ const contactEmailsRaw = form.querySelector('#contact-emails').value.trim();
+ const ttlValue = Number(form.querySelector('#ttl-value').value);
+ const ttlUnit = Number(form.querySelector('#ttl-unit').value);
+ const ttl = ttlValue * ttlUnit;
+
+ const scopePatterns = scopesRaw
+ ? scopesRaw.split(',').map((s) => s.trim()).filter(Boolean)
+ : [];
+ const contactEmails = contactEmailsRaw
+ ? contactEmailsRaw.split(',').map((s) => s.trim()).filter(Boolean)
+ : [];
+
+ const permissions = [...checked];
+ if (scopePatterns.length > 0 && !permissions.includes('emails:send')) {
+ permissions.push('emails:send');
+ }
+ scopePatterns.forEach((pat) => {
+ permissions.push(`emails:send:${pat}`);
+ });
+
+ if (permissions.length === 0) {
+ showToast('Select at least one permission', 'error');
+ return;
+ }
+ if (!Number.isInteger(ttl) || ttl <= 0) {
+ showToast('TTL must be a positive integer', 'error');
+ return;
+ }
+ if (ttl > MAX_TTL_SECONDS) {
+ showToast('TTL exceeds maximum of 365 days', 'error');
+ return;
+ }
+
+ createBtn.disabled = true;
+ createBtn.textContent = 'Creating…';
+ try {
+ const body = { permissions, ttl };
+ if (contactEmails.length > 0) body.contactEmails = contactEmails;
+ const resp = await apiFetch(ctx.org, ctx.site, 'auth/service_token', {
+ method: 'POST',
+ body: JSON.stringify(body),
+ });
+ if (!resp.ok) {
+ const errMsg = resp.headers.get('x-error') || `HTTP ${resp.status}`;
+ showToast(errMsg, 'error');
+ createBtn.disabled = false;
+ createBtn.textContent = 'Create Token';
+ return;
+ }
+ const data = await resp.json();
+ // eslint-disable-next-line no-use-before-define
+ renderResult(container, ctx, { token: data.token, ttl: data.ttl, permissions });
+ } catch (err) {
+ showToast(`Failed to create token: ${err.message}`, 'error');
+ createBtn.disabled = false;
+ createBtn.textContent = 'Create Token';
+ }
+ });
+}
+
+function renderResult(container, ctx, { token, ttl, permissions }) {
+ container.innerHTML = `
+
+
+
⚠ Copy this token now — it won't be shown again.
+
+ ${escapeHtml(token)}
+
+
+
Expires in: ${escapeHtml(humanizeSeconds(ttl))}
+
Permissions:
+
+ ${permissions.map((p) => `${escapeHtml(p)} `).join('')}
+
+
+
+
+
+ `;
+
+ container.querySelector('#copy-token').addEventListener('click', async () => {
+ try {
+ await navigator.clipboard.writeText(token);
+ showToast('Token copied');
+ } catch (err) {
+ showToast(`Copy failed: ${err.message}`, 'error');
+ }
+ });
+
+ container.querySelector('#create-another').addEventListener('click', () => {
+ renderForm(container, ctx);
+ });
+}
+
+export async function render(container, ctx) {
+ renderForm(container, ctx);
+}
+
+export function destroy() {}
diff --git a/tools/productbus-admin/ui.js b/tools/productbus-admin/ui.js
new file mode 100644
index 00000000..ff7ab0e1
--- /dev/null
+++ b/tools/productbus-admin/ui.js
@@ -0,0 +1,192 @@
+/**
+ * ProductBus Admin - Shared UI utilities
+ * Separated to avoid circular imports between api.js and page modules
+ */
+
+/**
+ * Return the `.productbus-admin` root so modals and toasts stay scoped to this
+ * tool. Falls back to for defensive safety during pre-mount init.
+ */
+function getRoot() {
+ return document.querySelector('.productbus-admin') || document.body;
+}
+
+/**
+ * HTML-escape a string for safe interpolation into `innerHTML` templates.
+ * Covers `& < > " '` — required for any API- or URL-sourced value that
+ * reaches an innerHTML sink, including textarea RCDATA and attribute values.
+ */
+export function escapeHtml(str) {
+ if (str === null || str === undefined) return '';
+ return String(str)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function showToast(message, type = 'success') {
+ const existing = document.querySelector('.productbus-toast');
+ if (existing) existing.remove();
+
+ const toast = document.createElement('div');
+ toast.classList.add('productbus-toast', type);
+ toast.textContent = message;
+ getRoot().appendChild(toast);
+
+ setTimeout(() => toast.classList.add('show'), 10);
+ setTimeout(() => {
+ toast.classList.remove('show');
+ setTimeout(() => toast.remove(), 300);
+ }, 3000);
+}
+
+export function createModal(title, content, footer) {
+ const dialog = document.createElement('dialog');
+ dialog.className = 'productbus-modal';
+
+ const titleEl = document.createElement('h2');
+ titleEl.textContent = title;
+
+ dialog.innerHTML = `
+
+
+
+ ${footer ? '' : ''}
+
+ `;
+
+ const modalBody = dialog.querySelector('.modal-body');
+ if (typeof content === 'string') {
+ modalBody.innerHTML = content;
+ } else {
+ modalBody.appendChild(content);
+ }
+
+ if (footer) {
+ const modalFooter = dialog.querySelector('.modal-footer');
+ if (typeof footer === 'string') {
+ modalFooter.innerHTML = footer;
+ } else {
+ modalFooter.appendChild(footer);
+ }
+ }
+
+ const closeModal = () => {
+ dialog.close();
+ dialog.remove();
+ };
+
+ dialog.addEventListener('cancel', closeModal);
+ dialog.querySelector('.modal-close').addEventListener('click', closeModal);
+ // Backdrop click: the click event only targets the dialog element itself when
+ // the user clicks the backdrop. Coord-based checks are unreliable because
+ // in-modal handlers can re-render and resize the dialog before the click
+ // bubbles up here (e.g. Form/JSON switcher shrinking the body).
+ dialog.addEventListener('click', (e) => {
+ if (e.target === dialog) closeModal();
+ });
+
+ getRoot().appendChild(dialog);
+ dialog.showModal();
+ return dialog;
+}
+
+/**
+ * Modal confirmation dialog. Returns a Promise that resolves to true when the
+ * user confirms and false on cancel / backdrop-close / Escape.
+ * Preferred over native `confirm()` so we remain compliant with Airbnb's
+ * `no-alert` / `no-restricted-globals` rules and get styled dialogs on mobile.
+ */
+export function confirmModal(message, options = {}) {
+ const {
+ title = 'Confirm',
+ confirmLabel = 'Confirm',
+ cancelLabel = 'Cancel',
+ destructive = false,
+ } = options;
+
+ return new Promise((resolve) => {
+ const content = document.createElement('p');
+ content.className = 'confirm-message';
+ content.textContent = message;
+
+ const footer = document.createElement('div');
+ footer.innerHTML = `
+
+
+ `;
+
+ const dialog = createModal(title, content, footer);
+
+ let settled = false;
+ const settle = (value) => {
+ if (settled) return;
+ settled = true;
+ dialog.close();
+ dialog.remove();
+ resolve(value);
+ };
+
+ dialog.querySelector('.cancel-btn').addEventListener('click', () => settle(false));
+ dialog.querySelector('.confirm-btn').addEventListener('click', () => settle(true));
+ dialog.addEventListener('cancel', () => settle(false));
+ dialog.addEventListener('close', () => settle(false));
+ });
+}
+
+/**
+ * Read a single query param from window.location.search.
+ */
+export function getUrlParam(key) {
+ return new URLSearchParams(window.location.search).get(key) || '';
+}
+
+/**
+ * Set or remove a query param via replaceState so the URL reflects
+ * current view state without pushing history entries.
+ */
+export function setUrlParam(key, value) {
+ const url = new URL(window.location.href);
+ if (value) url.searchParams.set(key, value);
+ else url.searchParams.delete(key);
+ window.history.replaceState({}, '', url);
+}
+
+export function createFormField(name, label, type = 'text', options = {}) {
+ const {
+ required = false, placeholder = '', value = '', disabled = false, maxLength = '',
+ } = options;
+
+ const field = document.createElement('div');
+ field.className = 'form-field';
+
+ const labelEl = document.createElement('label');
+ labelEl.textContent = label;
+ labelEl.setAttribute('for', name);
+
+ const input = document.createElement(type === 'textarea' ? 'textarea' : 'input');
+ input.id = name;
+ input.name = name;
+ if (type !== 'textarea') input.type = type;
+ if (required) input.required = true;
+ if (placeholder) input.placeholder = placeholder;
+ // Use setAttribute (not the .value property) so callers that serialize via
+ // outerHTML still carry the value through — property-only assignment does
+ // not reflect into HTML serialization. For textarea the value lives in the
+ // element's text content, not an attribute.
+ if (value !== '' && value !== null && value !== undefined) {
+ if (type === 'textarea') input.textContent = String(value);
+ else input.setAttribute('value', String(value));
+ }
+ if (disabled) input.disabled = true;
+ if (maxLength) input.maxLength = maxLength;
+
+ field.appendChild(labelEl);
+ field.appendChild(input);
+ return field;
+}