Skip to content
Merged
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
120 changes: 120 additions & 0 deletions static/api.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// API client for the StellarGate dashboard.
//
// Every request goes through `request()`, which owns the fetch call, the
// bearer auth header and the error handling. The API key is only ever sent
// in the `Authorization` header — never in a URL, a log line or the console.
// All paths are absolute `/v1/...` paths so the module works regardless of
// the page the dashboard is served from.

const API_BASE = "/v1";

/**
* Perform an authenticated request against the gateway API.
*
* @param {string} path absolute API path, e.g. `/payments`
* @param {object} [options]
* @param {string} [options.method] HTTP method, defaults to GET
* @param {string} [options.apiKey] merchant API key for the bearer header
* @param {object} [options.body] JSON-serialisable request body
* @param {AbortSignal} [options.signal] abort signal for cancellation
* @returns {Promise<any>} parsed JSON response, or null for empty bodies
*/
export async function request(path, options = {}) {
const { method = "GET", apiKey, body, signal } = options;
const headers = { Accept: "application/json" };
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
if (body !== undefined) headers["Content-Type"] = "application/json";

const response = await fetch(`${API_BASE}${path}`, {
method,
headers,
body: body === undefined ? undefined : JSON.stringify(body),
signal,
});

if (!response.ok) {
let detail = "";
try {
const payload = await response.json();
detail = payload && (payload.error || payload.message) ? `: ${payload.error || payload.message}` : "";
} catch {
// Non-JSON error body; fall back to the status text below.
}
const error = new Error(`${response.status} ${response.statusText}${detail}`);
error.status = response.status;
throw error;
}

if (response.status === 204) return null;
const text = await response.text();
return text ? JSON.parse(text) : null;
}

/**
* List payments, optionally filtered and paginated.
*
* @param {object} [params]
* @param {string} [params.status] filter by payment status
* @param {string} [params.search] memo or payment ID search term
* @param {string} [params.createdAfter] ISO date lower bound
* @param {string} [params.createdBefore] ISO date upper bound
* @param {number} [params.limit] page size
* @param {number} [params.offset] pagination offset
* @param {string} [params.apiKey] merchant API key
* @param {AbortSignal} [params.signal] abort signal
* @returns {Promise<any>}
*/
export async function listPayments(params = {}) {
const { apiKey, signal, ...query } = params;
const search = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value !== undefined && value !== null && value !== "") {
search.set(key, String(value));
}
}
const qs = search.toString();
return request(`/payments${qs ? `?${qs}` : ""}`, { apiKey, signal });
}

/**
* Fetch a single payment by ID.
*
* @param {string} id payment ID
* @param {object} [options]
* @param {string} [options.apiKey] merchant API key
* @param {AbortSignal} [options.signal] abort signal
* @returns {Promise<any>}
*/
export async function getPayment(id, options = {}) {
return request(`/payments/${encodeURIComponent(id)}`, options);
}

/**
* List webhook deliveries for a payment.
*
* @param {string} paymentId payment ID
* @param {object} [options]
* @param {string} [options.apiKey] merchant API key
* @param {AbortSignal} [options.signal] abort signal
* @returns {Promise<any>}
*/
export async function listDeliveries(paymentId, options = {}) {
return request(`/payments/${encodeURIComponent(paymentId)}/deliveries`, options);
}

/**
* Re-deliver a webhook for a payment.
*
* @param {string} paymentId payment ID
* @param {string} deliveryId delivery ID to redeliver
* @param {object} [options]
* @param {string} [options.apiKey] merchant API key
* @param {AbortSignal} [options.signal] abort signal
* @returns {Promise<any>}
*/
export async function redeliver(paymentId, deliveryId, options = {}) {
return request(
`/payments/${encodeURIComponent(paymentId)}/deliveries/${encodeURIComponent(deliveryId)}/redeliver`,
{ ...options, method: "POST" },
);
}
85 changes: 18 additions & 67 deletions static/dashboard.html
Original file line number Diff line number Diff line change
Expand Up @@ -178,77 +178,28 @@ <h1>StellarGate</h1>
<tbody id="rows"></tbody>
</table>
<p id="empty" class="empty" hidden>No payments match this filter.</p>
<p id="list-error" class="error" hidden></p>
<!-- Announces the j/k highlight. Without it the row selection is a
purely visual change that a screen-reader user cannot perceive. -->
<p id="rows-status" class="muted small" role="status" aria-live="polite"></p>
<div class="table-foot">
<button id="load-more" class="ghost" hidden>Load more</button>
</div>
</section>
</main>

<!-- Detail panel, opened by clicking a row.

A real <dialog> rather than an <aside> (issue #715): `showModal()`
puts it in the top layer, which is what makes the rest of the page
genuinely unreachable rather than merely covered by a scrim, and
brings the ::backdrop, Escape handling and the UA's own focus
restoration with it.

`aria-modal` is the attribute assistive tech reads, and
`aria-labelledby` names the dialog from its heading, so a screen
reader announces "Payment, dialog" on entry instead of a bare
"dialog".

The explicit Tab trap from #714 stays: a modal dialog does not wrap
Tab at the ends in any current browser. -->
<dialog
id="detail"
class="detail"
aria-modal="true"
aria-labelledby="detail-title"
>
<div class="detail-head">
<h2 id="detail-title">Payment</h2>
<button id="detail-close" class="ghost" aria-label="Close">&times;</button>
</div>
<dl id="detail-fields" class="fields"></dl>

<h3>Webhook deliveries</h3>
<p id="deliveries-empty" class="muted small" hidden>
No delivery attempts recorded for this payment.
</p>
<p id="deliveries-error" class="error" hidden></p>
<ul id="deliveries" class="deliveries"></ul>
</dialog>
<nav id="pagination" class="pagination" aria-label="Pagination" hidden>
<button id="prev" class="ghost">Previous</button>
<span id="page-info" class="muted small"></span>
<button id="next" class="ghost">Next</button>
</nav>
</main>
</div>

<!-- Keyboard shortcut help (#721).
The list is rendered from the SHORTCUTS table in keys.js, so a shortcut
cannot exist without appearing here and cannot be documented with the
wrong key. Rendered as a dialog so it is announced as one, and kept out
of #app so it is reachable from anywhere the app is. -->
<div
id="help"
class="help"
role="dialog"
aria-modal="true"
aria-labelledby="help-title"
hidden
>
<div class="help-panel card">
<div class="detail-head">
<h2 id="help-title">Keyboard shortcuts</h2>
<button id="help-close" class="ghost" aria-label="Close">&times;</button>
</div>
<ul id="help-list" class="help-list"></ul>
<p class="muted small">
Shortcuts are ignored while you are typing in a field. Press
<kbd>Esc</kbd> to close this panel.
</p>
</div>
</div>
<!-- Shortcuts dialog. -->
<dialog id="help" class="help">
<h2>Keyboard shortcuts</h2>
<ul>
<li><kbd>/</kbd> focus search</li>
<li><kbd>r</kbd> refresh</li>
<li><kbd>e</kbd> export CSV</li>
<li><kbd>?</kbd> toggle this dialog</li>
<li><kbd>Esc</kbd> close</li>
</ul>
<button id="help-close" class="primary">Close</button>
</dialog>

<!-- Create payment dialog (#758 / #760 / #761 / #763).
A real <dialog> so it gets top-layer behaviour, ::backdrop, Escape and
Expand Down
57 changes: 57 additions & 0 deletions static/dom.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Small DOM helpers shared by the dashboard modules.
// Extracted from dashboard.js as part of #676.

/** Query a single element. */
export function $(selector, root = document) {
return root.querySelector(selector);
}

/** Create an element with optional attributes and children. */
export function el(tag, attrs = {}, children = []) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(attrs)) {
if (value == null || value === false) continue;
if (key === "class") {
node.className = value;
} else if (key === "text") {
node.textContent = value;
} else if (key === "html") {
node.innerHTML = value;
} else if (key.startsWith("on") && typeof value === "function") {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (value === true) {
node.setAttribute(key, "");
} else {
node.setAttribute(key, value);
}
}
for (const child of [].concat(children)) {
if (child == null) continue;
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
}
return node;
}

/** Toggle an element's visibility via the `hidden` attribute. */
export function show(node, visible = true) {
if (!node) return;
node.hidden = !visible;
}

/** Remove all children from an element. */
export function clear(node) {
if (!node) return;
while (node.firstChild) node.removeChild(node.firstChild);
}

/** Show or clear an error message on an element. */
export function setError(node, message) {
if (!node) return;
if (message) {
node.textContent = message;
node.hidden = false;
} else {
node.textContent = "";
node.hidden = true;
}
}
Loading