Production readiness hardening and ops tooling

This commit is contained in:
2026-05-29 10:10:12 +07:00
parent e0b8f9af9a
commit 648e77cee9
68 changed files with 12222 additions and 848 deletions

View File

@ -1,4 +1,5 @@
const ADMIN_TOKEN_KEY = "admin_token";
const ADMIN_PROFILE_KEY = "admin_profile";
function formatMoney(value) {
const number = Number(value || 0);
@ -90,11 +91,64 @@ async function adminFetch(path, options = {}) {
return payload?.data !== undefined ? payload.data : payload;
}
function hasPermission(profile, permission) {
if (!permission) {
return true;
}
const permissions = profile?.permissions;
if (!permissions) {
return false;
}
if (Array.isArray(permissions)) {
return permissions.includes("*") || permissions.includes(permission);
}
if (permissions.admin === "*" || permissions.admin === true) {
return true;
}
if (permissions[permission] === "*" || permissions[permission] === true) {
return true;
}
const [domain, action] = permission.split(":");
const domainValue = permissions[domain];
if (domainValue === "*" || domainValue === true) {
return true;
}
if (Array.isArray(domainValue)) {
return domainValue.includes("*") || domainValue.includes(action);
}
if (domainValue && typeof domainValue === "object") {
return domainValue[action] === "*" || domainValue[action] === true;
}
return false;
}
function getStoredProfile() {
try {
return JSON.parse(localStorage.getItem(ADMIN_PROFILE_KEY) || "null");
} catch (_error) {
return null;
}
}
function applyPermissionAttributes(profile) {
document.querySelectorAll("[data-admin-permission]").forEach((el) => {
const allowed = hasPermission(profile, el.getAttribute("data-admin-permission"));
el.classList.toggle("hidden", !allowed);
if ("disabled" in el) {
el.disabled = !allowed;
}
});
}
window.AdminUIAPI = {
ADMIN_TOKEN_KEY,
ADMIN_PROFILE_KEY,
setToken: (token) => localStorage.setItem(ADMIN_TOKEN_KEY, token),
getToken: () => localStorage.getItem(ADMIN_TOKEN_KEY),
clearToken: () => localStorage.removeItem(ADMIN_TOKEN_KEY),
clearToken: () => {
localStorage.removeItem(ADMIN_TOKEN_KEY);
localStorage.removeItem(ADMIN_PROFILE_KEY);
},
requireToken: () => {
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
if (!token) {
@ -111,9 +165,27 @@ window.AdminUIAPI = {
});
if (data?.token) {
window.AdminUIAPI.setToken(data.token);
if (data.user) {
localStorage.setItem(ADMIN_PROFILE_KEY, JSON.stringify(data.user));
}
}
return data;
},
getMe: async () => {
const profile = await adminFetch("/admin/me");
localStorage.setItem(ADMIN_PROFILE_KEY, JSON.stringify(profile));
return profile;
},
getStoredProfile,
hasPermission: (permission, profile) => hasPermission(profile || getStoredProfile(), permission),
applyPermissions: async () => {
let profile = getStoredProfile();
if (!profile?.permissions) {
profile = await window.AdminUIAPI.getMe();
}
applyPermissionAttributes(profile);
return profile;
},
listMerchants: () => adminFetch("/admin/merchants"),
listOutlets: (query) => adminFetch("/admin/outlets", { query }),
getOutlet: (id) => adminFetch(`/admin/outlets/${id}`),
@ -133,6 +205,11 @@ window.AdminUIAPI = {
getTerminal: (id) => adminFetch(`/admin/terminals/${id}`),
listDevices: (query) => adminFetch("/admin/devices", { query }),
getDevice: (id) => adminFetch(`/admin/devices/${id}`),
rotateDeviceCredential: (id) =>
adminFetch(`/admin/devices/${id}/credentials/rotate`, {
method: "POST",
body: {}
}),
getDeviceHeartbeats: (id, query) =>
adminFetch(`/admin/devices/${id}/heartbeats`, { query }),
getDeviceConfig: (id) => adminFetch(`/admin/devices/${id}/config`),
@ -143,7 +220,87 @@ window.AdminUIAPI = {
body: payload || {}
}),
listTransactions: (query) => adminFetch("/admin/transactions", { query }),
getDynamicQrExpiryScheduler: () => adminFetch("/admin/transactions/expiry-scheduler"),
createSettlementBatches: (payload) =>
adminFetch("/admin/settlement-batches", {
method: "POST",
body: payload || {}
}),
listSettlementBatches: (query) => adminFetch("/admin/settlement-batches", { query }),
getSettlementBatch: (id) => adminFetch(`/admin/settlement-batches/${id}`),
listSettlementAdjustments: (query) =>
adminFetch("/admin/settlement-adjustments", { query }),
createSettlementAdjustmentExportJob: (payload) =>
adminFetch("/admin/exports/settlement-adjustments", {
method: "POST",
body: payload || {}
}),
getExportJob: (id) => adminFetch(`/admin/exports/${id}`),
listExportJobs: (query) => adminFetch("/admin/exports", { query }),
downloadExportJob: async (id) => {
const token = localStorage.getItem(ADMIN_TOKEN_KEY);
if (!token) {
throw new Error("ADMIN_AUTH_MISSING");
}
const response = await fetch(`/admin/exports/${id}/download`, {
headers: { Authorization: `Bearer ${token}` }
});
if (!response.ok) {
throw new Error(`Export download failed: ${response.status}`);
}
return {
blob: await response.blob(),
filename:
response.headers.get("content-disposition")?.match(/filename="([^"]+)"/)?.[1] ||
`${id}.csv`
};
},
getSettlementReconciliationReport: (query) =>
adminFetch("/admin/reconciliation/settlement-batches", { query }),
markSettlementBatchPaid: (id, payload) =>
adminFetch(`/admin/settlement-batches/${id}/mark-paid`, {
method: "POST",
body: payload || {}
}),
updateSettlementBatchReference: (id, payload) =>
adminFetch(`/admin/settlement-batches/${id}/reference`, {
method: "PATCH",
body: payload || {}
}),
recordSettlementBatchAdjustment: (id, payload) =>
adminFetch(`/admin/settlement-batches/${id}/adjustments`, {
method: "POST",
body: payload || {}
}),
approveSettlementAdjustment: (id, payload) =>
adminFetch(`/admin/settlement-adjustments/${id}/approve`, {
method: "POST",
body: payload || {}
}),
rejectSettlementAdjustment: (id, payload) =>
adminFetch(`/admin/settlement-adjustments/${id}/reject`, {
method: "POST",
body: payload || {}
}),
markSettlementBatchFailed: (id, payload) =>
adminFetch(`/admin/settlement-batches/${id}/mark-failed`, {
method: "POST",
body: payload || {}
}),
cancelSettlementBatch: (id, payload) =>
adminFetch(`/admin/settlement-batches/${id}/cancel`, {
method: "POST",
body: payload || {}
}),
reprocessSettlementBatch: (id) =>
adminFetch(`/admin/settlement-batches/${id}/reprocess`, {
method: "POST",
body: {}
}),
getDashboardSummary: () => adminFetch("/admin/dashboard/summary"),
getMqttStatus: (query) => adminFetch("/admin/mqtt/status", { query }),
listFailedNotifications: (query) => adminFetch("/admin/notifications/failed", { query }),
listAuditLogs: (query) => adminFetch("/admin/audit-logs", { query }),
formatMoney,
formatDateTime
};

142
ui/shared/merchant-api.js Normal file
View File

@ -0,0 +1,142 @@
const MERCHANT_TOKEN_KEY = "merchant_token";
const MERCHANT_ID_KEY = "merchant_id";
const MERCHANT_USER_KEY = "merchant_user";
const MERCHANT_AUTH_MODE_KEY = "merchant_auth_mode";
function merchantFormatMoney(value) {
const number = Number(value || 0);
if (!Number.isFinite(number)) {
return "Rp 0";
}
return new Intl.NumberFormat("id-ID", {
style: "currency",
currency: "IDR",
maximumFractionDigits: 0
}).format(number);
}
function merchantFormatDateTime(value) {
if (!value) {
return "-";
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return value;
}
return new Intl.DateTimeFormat("en-GB", {
dateStyle: "medium",
timeStyle: "short"
}).format(date);
}
function merchantBuildQuery(query) {
const search = new URLSearchParams();
Object.entries(query || {}).forEach(([key, val]) => {
if (val !== undefined && val !== null && val !== "") {
search.append(key, String(val));
}
});
return search.toString();
}
async function merchantFetch(path, options = {}) {
const token = localStorage.getItem(MERCHANT_TOKEN_KEY);
const merchantId = localStorage.getItem(MERCHANT_ID_KEY);
const {
method = "GET",
query,
body,
headers: extraHeaders = {},
auth = true
} = options;
const suffix = merchantBuildQuery(query || {});
const headers = { ...extraHeaders };
if (auth) {
if (!token) {
throw new Error("MERCHANT_AUTH_MISSING");
}
headers.Authorization = `Bearer ${token}`;
if (merchantId) {
headers["X-Merchant-Id"] = merchantId;
}
}
if (method !== "GET" && body !== undefined) {
headers["Content-Type"] = "application/json";
}
const response = await fetch(suffix ? `${path}?${suffix}` : path, {
method,
headers,
body: method === "GET" || body === undefined ? undefined : JSON.stringify(body)
});
const raw = await response.text();
let payload;
try {
payload = raw ? JSON.parse(raw) : {};
} catch (error) {
payload = {};
}
if (!response.ok) {
throw new Error(payload?.message || payload?.error || `Request failed with status ${response.status}`);
}
return payload?.data !== undefined ? payload.data : payload;
}
window.MerchantUIAPI = {
MERCHANT_TOKEN_KEY,
MERCHANT_ID_KEY,
setSession: ({ token, merchant, user, auth_mode }) => {
localStorage.setItem(MERCHANT_TOKEN_KEY, token);
localStorage.setItem(MERCHANT_ID_KEY, merchant.id);
if (user) {
localStorage.setItem(MERCHANT_USER_KEY, JSON.stringify(user));
}
if (auth_mode) {
localStorage.setItem(MERCHANT_AUTH_MODE_KEY, auth_mode);
}
},
clearSession: () => {
localStorage.removeItem(MERCHANT_TOKEN_KEY);
localStorage.removeItem(MERCHANT_ID_KEY);
localStorage.removeItem(MERCHANT_USER_KEY);
localStorage.removeItem(MERCHANT_AUTH_MODE_KEY);
},
getSessionUser: () => {
try {
return JSON.parse(localStorage.getItem(MERCHANT_USER_KEY) || "null");
} catch (_error) {
return null;
}
},
getAuthMode: () => localStorage.getItem(MERCHANT_AUTH_MODE_KEY),
requireSession: () => {
const token = localStorage.getItem(MERCHANT_TOKEN_KEY);
const merchantId = localStorage.getItem(MERCHANT_ID_KEY);
if (!token || !merchantId) {
window.location.href = "/ui/merchant-login";
throw new Error("MERCHANT_AUTH_MISSING");
}
return { token, merchantId };
},
login: async ({ username, password }) => {
const data = await merchantFetch("/merchant/login", {
method: "POST",
auth: false,
body: { username, password }
});
if (data?.token && data?.merchant) {
window.MerchantUIAPI.setSession(data);
}
return data;
},
getProfile: async () => {
const profile = await merchantFetch("/merchant/profile");
return profile?.merchant || profile;
},
getSettlementSummary: () => merchantFetch("/merchant/settlement-summary"),
listSettlementBatches: (query) => merchantFetch("/merchant/settlement-batches", { query }),
getSettlementBatch: (id) => merchantFetch(`/merchant/settlement-batches/${id}`),
formatMoney: merchantFormatMoney,
formatDateTime: merchantFormatDateTime
};