diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index a2b26efc8..bbe2dc000 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "276c1c649eee16c120f9bfe29e54582f9cc50c5a9c1c1b0acc99023b4dd8ac0f",
+ "source_hash": "33ad099dc74b695667a57e6d0ee611f46bb9f70d4b875e5eb84d192ea0173742",
"build_inputs": [
"package.json",
"tsconfig.json",
@@ -10,6 +10,8 @@
"src/shell.ts",
"src/shell_view.ts",
"src/services.ts",
+ "src/services_controller.ts",
+ "src/services_view.ts",
"src/runtime.ts",
"src/types.ts",
"src/styles.css"
diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js
index 50ec9fc54..eb818fe2e 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -713,6 +713,183 @@
refreshBootstrap();
}
+ // src/services_view.ts
+ function getElement3(id) {
+ return document.getElementById(id);
+ }
+ function asHTMLElement3(target) {
+ return target instanceof HTMLElement ? target : null;
+ }
+ function escapeText(value) {
+ return String(value || "").replace(/&/g, "&").replace(//g, ">");
+ }
+ function escapeAttribute(value) {
+ return escapeText(value).replace(/"/g, """).replace(/'/g, "'");
+ }
+ function readValue(id) {
+ var el = getElement3(id);
+ return el ? el.value.trim() : "";
+ }
+ function focusElement(id) {
+ var el = getElement3(id);
+ if (el) el.focus();
+ }
+ function setVisible(id, visible) {
+ var el = getElement3(id);
+ if (el) {
+ el.style.display = visible ? "block" : "none";
+ }
+ }
+ function setValue(id, value) {
+ var el = getElement3(id);
+ if (el) {
+ el.value = value;
+ }
+ }
+ function renderStatus(id, status) {
+ var el = getElement3(id);
+ if (!el) return;
+ if (!status.visible) {
+ el.textContent = "";
+ el.className = "service-status";
+ return;
+ }
+ el.textContent = status.message;
+ el.className = "service-status visible" + (status.error ? " error" : " success");
+ }
+ function renderButton(id, disabled, label) {
+ if (!id || !label) return;
+ var button = getElement3(id);
+ if (!button) return;
+ button.disabled = disabled;
+ button.textContent = label;
+ }
+ function renderOpenPanels(openPanelID) {
+ var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"];
+ for (var i = 0; i < panels.length; i++) {
+ var panel = getElement3(panels[i]);
+ if (!panel) continue;
+ panel.classList.toggle("visible", panels[i] === openPanelID);
+ }
+ }
+ function renderRefundPanel(refundState, bootstrap) {
+ var root = getElement3("refund-service-root");
+ if (!root) return;
+ var refundSupportURL = (bootstrap.public_site_url || "") + "/refund.html?email=" + encodeURIComponent(refundState.emailValue || "");
+ root.innerHTML = '
Refund requests
Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.
Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
';
+ }
+ function renderManagePanel(flowState) {
+ var root = getElement3("manage-service-root");
+ if (!root) return;
+ root.innerHTML = '
Manage subscriptions
Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.
';
+ }
+ function renderRetrievePanel(flowState) {
+ var root = getElement3("retrieve-service-root");
+ if (!root) return;
+ var result = flowState.result;
+ var invoiceURL = result && result.invoice_url ? result.invoice_url : "#";
+ var invoiceDisplay = result && result.invoice_url ? "inline-block" : "none";
+ var copyDisplay = result ? "inline-block" : "none";
+ var resultDisplay = result ? "block" : "none";
+ root.innerHTML = '
Retrieve licenses
Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.
';
+ }
+
+ // src/services_controller.ts
+ function installServicesController(deps) {
+ document.addEventListener("click", function(event) {
+ var target = asHTMLElement3(event.target)?.closest("[data-account-service-action]");
+ if (!target) return;
+ var action = target.getAttribute("data-account-service-action") || "";
+ var panelID = target.getAttribute("data-account-service-panel") || "";
+ var focusID = target.getAttribute("data-account-service-focus") || "";
+ switch (action) {
+ case "open-service-panel":
+ event.preventDefault();
+ deps.toggleServicePanel(panelID);
+ deps.focusElement(focusID);
+ return;
+ case "manage-inline-request":
+ event.preventDefault();
+ deps.requestVerificationCode("manage");
+ return;
+ case "manage-inline-resend":
+ deps.resendVerificationCode("manage", event);
+ return;
+ case "manage-inline-confirm":
+ event.preventDefault();
+ deps.confirmVerificationCode("manage");
+ return;
+ case "retrieve-inline-request":
+ event.preventDefault();
+ deps.requestVerificationCode("retrieve");
+ return;
+ case "retrieve-inline-confirm":
+ event.preventDefault();
+ deps.confirmVerificationCode("retrieve");
+ return;
+ case "retrieve-inline-copy":
+ event.preventDefault();
+ deps.copyRetrievedLicense();
+ return;
+ case "refund-inline-submit":
+ event.preventDefault();
+ deps.submitRefund();
+ return;
+ case "data-export-request":
+ event.preventDefault();
+ deps.requestVerificationCode("export");
+ return;
+ case "data-export-resend":
+ deps.resendVerificationCode("export", event);
+ return;
+ case "data-export-confirm":
+ event.preventDefault();
+ deps.confirmVerificationCode("export");
+ return;
+ case "data-delete-request":
+ event.preventDefault();
+ deps.requestVerificationCode("delete");
+ return;
+ case "data-delete-resend":
+ deps.resendVerificationCode("delete", event);
+ return;
+ case "data-delete-confirm":
+ event.preventDefault();
+ deps.confirmVerificationCode("delete");
+ return;
+ default:
+ return;
+ }
+ });
+ document.addEventListener("input", function(event) {
+ var target = asHTMLElement3(event.target);
+ if (!target) return;
+ var inputKind = target.getAttribute("data-account-service-input") || "";
+ if (!inputKind) return;
+ deps.updateInputValue(inputKind, target.value);
+ });
+ document.addEventListener("change", function(event) {
+ var target = asHTMLElement3(event.target);
+ if (!target || target.id !== "data-delete-confirm-check") return;
+ deps.updateDeleteConfirmation(!!target.checked);
+ });
+ }
+
// src/services.ts
var serviceState = {
openPanelID: "",
@@ -752,38 +929,6 @@
function getCommercialAPIBaseURL2() {
return getCommercialAPIBaseURL();
}
- function getElement3(id) {
- return document.getElementById(id);
- }
- function asHTMLElement3(target) {
- return target instanceof HTMLElement ? target : null;
- }
- function escapeText(value) {
- return String(value || "").replace(/&/g, "&").replace(//g, ">");
- }
- function escapeAttribute(value) {
- return escapeText(value).replace(/"/g, """).replace(/'/g, "'");
- }
- function readValue(id) {
- var el = getElement3(id);
- return el ? el.value.trim() : "";
- }
- function focusElement(id) {
- var el = getElement3(id);
- if (el) el.focus();
- }
- function setVisible(id, visible) {
- var el = getElement3(id);
- if (el) {
- el.style.display = visible ? "block" : "none";
- }
- }
- function setValue(id, value) {
- var el = getElement3(id);
- if (el) {
- el.value = value;
- }
- }
function serviceFetch(path, body) {
return fetch(getCommercialAPIBaseURL2() + path, {
method: "POST",
@@ -808,34 +953,9 @@
error: !!isError
};
}
- function renderStatus(id, status) {
- var el = getElement3(id);
- if (!el) return;
- if (!status.visible) {
- el.textContent = "";
- el.className = "service-status";
- return;
- }
- el.textContent = status.message;
- el.className = "service-status visible" + (status.error ? " error" : " success");
- }
- function renderButton(id, disabled, label) {
- var button = getElement3(id);
- if (!button) return;
- button.disabled = disabled;
- button.textContent = label;
- }
function toggleServicePanel(panelID) {
serviceState.openPanelID = serviceState.openPanelID === panelID ? "" : panelID;
- renderOpenPanels();
- }
- function renderOpenPanels() {
- var panels = ["manage-service-panel", "retrieve-service-panel", "refund-service-panel", "data-service-panel"];
- for (var i = 0; i < panels.length; i++) {
- var panel = getElement3(panels[i]);
- if (!panel) continue;
- panel.classList.toggle("visible", panels[i] === serviceState.openPanelID);
- }
+ renderOpenPanels(serviceState.openPanelID);
}
function renderFlow(flowID) {
var flow = verificationFlows[flowID];
@@ -862,17 +982,10 @@
renderRefund();
}
function renderRefund() {
- renderRefundPanel();
+ renderRefundPanel(serviceState.refund, getBootstrap());
renderButton("refund-inline-submit", serviceState.refund.submitting, serviceState.refund.submitting ? "Processing..." : "Process Refund");
renderStatus("refund-inline-status", serviceState.refund.status);
}
- function renderRefundPanel() {
- var root = getElement3("refund-service-root");
- if (!root) return;
- var bootstrap = getBootstrap();
- var refundSupportURL = (bootstrap.public_site_url || "") + "/refund.html?email=" + encodeURIComponent(serviceState.refund.emailValue || "");
- root.innerHTML = '
Refund requests
Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.
Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
';
- }
function resetVerificationFlow(flowID) {
var flow = verificationFlows[flowID];
if (!flow) return;
@@ -913,11 +1026,7 @@
onConfirmSuccess: function(data) {
window.location.href = data.url;
},
- renderPanel: function(flowState) {
- var root = getElement3("manage-service-root");
- if (!root) return;
- root.innerHTML = '
Manage subscriptions
Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.
';
- }
+ renderPanel: renderDeletePanel
}
};
async function requestVerificationCode(flowID) {
@@ -1170,118 +1255,68 @@
}
function renderServiceRuntime() {
syncServiceStateFromBootstrap();
- renderOpenPanels();
+ renderOpenPanels(serviceState.openPanelID);
renderAllFlows();
}
renderServiceRuntime();
subscribePortalRender(renderServiceRuntime);
- document.addEventListener("click", function(event) {
- var target = asHTMLElement3(event.target)?.closest("[data-account-service-action]");
- if (!target) return;
- var action = target.getAttribute("data-account-service-action") || "";
- var panelID = target.getAttribute("data-account-service-panel") || "";
- var focusID = target.getAttribute("data-account-service-focus") || "";
- switch (action) {
- case "open-service-panel":
- event.preventDefault();
- toggleServicePanel(panelID);
- focusElement(focusID);
- return;
- case "manage-inline-request":
- event.preventDefault();
- requestVerificationCode("manage");
- return;
- case "manage-inline-resend":
- resendVerificationCode("manage", event);
- return;
- case "manage-inline-confirm":
- event.preventDefault();
- confirmVerificationCode("manage");
- return;
- case "retrieve-inline-request":
- event.preventDefault();
- requestVerificationCode("retrieve");
- return;
- case "retrieve-inline-confirm":
- event.preventDefault();
- confirmVerificationCode("retrieve");
- return;
- case "retrieve-inline-copy":
- event.preventDefault();
- copyRetrievedLicense();
- return;
- case "refund-inline-submit":
- event.preventDefault();
- submitRefund();
- return;
- case "data-export-request":
- event.preventDefault();
- requestVerificationCode("export");
- return;
- case "data-export-resend":
- resendVerificationCode("export", event);
- return;
- case "data-export-confirm":
- event.preventDefault();
- confirmVerificationCode("export");
- return;
- case "data-delete-request":
- event.preventDefault();
- requestVerificationCode("delete");
- return;
- case "data-delete-resend":
- resendVerificationCode("delete", event);
- return;
- case "data-delete-confirm":
- event.preventDefault();
- confirmVerificationCode("delete");
- return;
- default:
- return;
- }
- });
- document.addEventListener("input", function(event) {
- var target = asHTMLElement3(event.target);
- if (!target) return;
- var inputKind = target.getAttribute("data-account-service-input") || "";
+ function updateInputValue(inputKind, value) {
switch (inputKind) {
case "manage-email":
- serviceState.flows.manage.emailValue = target.value;
+ serviceState.flows.manage.emailValue = value;
return;
case "manage-code":
- serviceState.flows.manage.codeValue = target.value;
+ serviceState.flows.manage.codeValue = value;
return;
case "retrieve-email":
- serviceState.flows.retrieve.emailValue = target.value;
+ serviceState.flows.retrieve.emailValue = value;
return;
case "retrieve-code":
- serviceState.flows.retrieve.codeValue = target.value;
+ serviceState.flows.retrieve.codeValue = value;
return;
case "refund-email":
- serviceState.refund.emailValue = target.value;
+ serviceState.refund.emailValue = value;
return;
case "refund-token":
- serviceState.refund.tokenValue = target.value;
+ serviceState.refund.tokenValue = value;
return;
case "data-export-email":
- serviceState.flows.export.emailValue = target.value;
+ serviceState.flows.export.emailValue = value;
return;
case "data-export-code":
- serviceState.flows.export.codeValue = target.value;
+ serviceState.flows.export.codeValue = value;
return;
case "data-delete-email":
- serviceState.flows.delete.emailValue = target.value;
+ serviceState.flows.delete.emailValue = value;
return;
case "data-delete-code":
- serviceState.flows.delete.codeValue = target.value;
+ serviceState.flows.delete.codeValue = value;
return;
default:
return;
}
- });
- document.addEventListener("change", function(event) {
- var target = asHTMLElement3(event.target);
- if (!target || target.id !== "data-delete-confirm-check") return;
- serviceState.flows.delete.checkboxChecked = !!target.checked;
+ }
+ installServicesController({
+ toggleServicePanel,
+ focusElement,
+ requestVerificationCode: function(flowID) {
+ void requestVerificationCode(flowID);
+ },
+ resendVerificationCode: function(flowID, event) {
+ void resendVerificationCode(flowID, event);
+ },
+ confirmVerificationCode: function(flowID) {
+ void confirmVerificationCode(flowID);
+ },
+ copyRetrievedLicense: function() {
+ void copyRetrievedLicense();
+ },
+ submitRefund: function() {
+ void submitRefund();
+ },
+ updateInputValue,
+ updateDeleteConfirmation: function(checked) {
+ serviceState.flows.delete.checkboxChecked = checked;
+ }
});
})();
diff --git a/internal/cloudcp/portal/frontend/build.mjs b/internal/cloudcp/portal/frontend/build.mjs
index 0713fd127..bdf7ba320 100644
--- a/internal/cloudcp/portal/frontend/build.mjs
+++ b/internal/cloudcp/portal/frontend/build.mjs
@@ -23,6 +23,8 @@ const buildInputs = [
'src/shell.ts',
'src/shell_view.ts',
'src/services.ts',
+ 'src/services_controller.ts',
+ 'src/services_view.ts',
'src/runtime.ts',
'src/types.ts',
'src/styles.css',
diff --git a/internal/cloudcp/portal/frontend/src/services.ts b/internal/cloudcp/portal/frontend/src/services.ts
index ea00cb515..909f6eec8 100644
--- a/internal/cloudcp/portal/frontend/src/services.ts
+++ b/internal/cloudcp/portal/frontend/src/services.ts
@@ -1,4 +1,21 @@
import { getBootstrap, getCommercialAPIBaseURL as readCommercialAPIBaseURL, subscribePortalRender } from './runtime';
+import { installServicesController } from './services_controller';
+import {
+ focusElement,
+ getElement,
+ readValue,
+ renderButton,
+ renderDeletePanel,
+ renderExportPanel,
+ renderExportResult,
+ renderManagePanel,
+ renderOpenPanels,
+ renderRefundPanel,
+ renderRetrievePanel,
+ renderStatus,
+ setValue,
+ setVisible,
+} from './services_view';
import type { RefundState, ServiceStatus, VerificationFlowState } from './types';
type FlowID = 'manage' | 'retrieve' | 'export' | 'delete';
@@ -30,8 +47,6 @@ interface VerificationFlowDefinition {
renderResult?: (result: unknown) => void;
}
-type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
-
var serviceState = {
openPanelID: '',
flows: {
@@ -74,58 +89,6 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
return readCommercialAPIBaseURL();
}
- function getElement(id): T | null {
- return document.getElementById(id) as T | null;
- }
-
- function asHTMLElement(target: EventTarget | null): HTMLElement | null {
- return target instanceof HTMLElement ? target : null;
- }
-
- function escapeText(value) {
- return String(value || '')
- .replace(/&/g, '&')
- .replace(//g, '>');
- }
-
- function escapeAttribute(value) {
- return escapeText(value)
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
- function readValue(id) {
- var el = getElement(id);
- return el ? el.value.trim() : '';
- }
-
- function focusElement(id) {
- var el = getElement(id);
- if (el) el.focus();
- }
-
- function setVisible(id, visible) {
- var el = getElement(id);
- if (el) {
- el.style.display = visible ? 'block' : 'none';
- }
- }
-
- function setText(id, value) {
- var el = getElement(id);
- if (el) {
- el.textContent = value;
- }
- }
-
- function setValue(id, value) {
- var el = getElement(id);
- if (el) {
- el.value = value;
- }
- }
-
function serviceFetch(path, body) {
return fetch(getCommercialAPIBaseURL() + path, {
method: 'POST',
@@ -154,37 +117,9 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
};
}
- function renderStatus(id, status) {
- var el = getElement(id);
- if (!el) return;
- if (!status.visible) {
- el.textContent = '';
- el.className = 'service-status';
- return;
- }
- el.textContent = status.message;
- el.className = 'service-status visible' + (status.error ? ' error' : ' success');
- }
-
- function renderButton(id, disabled, label) {
- var button = getElement(id);
- if (!button) return;
- button.disabled = disabled;
- button.textContent = label;
- }
-
function toggleServicePanel(panelID) {
serviceState.openPanelID = serviceState.openPanelID === panelID ? '' : panelID;
- renderOpenPanels();
- }
-
- function renderOpenPanels() {
- var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel'];
- for (var i = 0; i < panels.length; i++) {
- var panel = getElement(panels[i]);
- if (!panel) continue;
- panel.classList.toggle('visible', panels[i] === serviceState.openPanelID);
- }
+ renderOpenPanels(serviceState.openPanelID);
}
function renderFlow(flowID) {
@@ -214,35 +149,11 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
}
function renderRefund() {
- renderRefundPanel();
+ renderRefundPanel(serviceState.refund, getBootstrap());
renderButton('refund-inline-submit', serviceState.refund.submitting, serviceState.refund.submitting ? 'Processing...' : 'Process Refund');
renderStatus('refund-inline-status', serviceState.refund.status);
}
- function renderRefundPanel() {
- var root = getElement('refund-service-root');
- if (!root) return;
- var bootstrap = getBootstrap();
- var refundSupportURL = (bootstrap.public_site_url || '') + '/refund.html?email=' + encodeURIComponent(serviceState.refund.emailValue || '');
- root.innerHTML = '' +
- '
Refund requests
' +
- '
Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.
' +
- '
Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '' +
- '' +
- '
' +
- '
' +
- '' +
- '
' +
- '
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
' +
- '';
- }
-
function resetVerificationFlow(flowID: FlowID) {
var flow = verificationFlows[flowID];
if (!flow) return;
@@ -283,33 +194,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
onConfirmSuccess: function(data) {
window.location.href = data.url;
},
- renderPanel: function(flowState) {
- var root = getElement('manage-service-root');
- if (!root) return;
- root.innerHTML = '' +
- '
Manage subscriptions
' +
- '
Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.
' +
- '';
- }
+ renderPanel: renderDeletePanel
}
};
@@ -661,122 +430,70 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme
function renderServiceRuntime() {
syncServiceStateFromBootstrap();
- renderOpenPanels();
+ renderOpenPanels(serviceState.openPanelID);
renderAllFlows();
}
renderServiceRuntime();
subscribePortalRender(renderServiceRuntime);
- document.addEventListener('click', function(event) {
- var target = asHTMLElement(event.target)?.closest('[data-account-service-action]');
- if (!target) return;
- var action = target.getAttribute('data-account-service-action') || '';
- var panelID = target.getAttribute('data-account-service-panel') || '';
- var focusID = target.getAttribute('data-account-service-focus') || '';
-
- switch (action) {
- case 'open-service-panel':
- event.preventDefault();
- toggleServicePanel(panelID);
- focusElement(focusID);
- return;
- case 'manage-inline-request':
- event.preventDefault();
- requestVerificationCode('manage');
- return;
- case 'manage-inline-resend':
- resendVerificationCode('manage', event);
- return;
- case 'manage-inline-confirm':
- event.preventDefault();
- confirmVerificationCode('manage');
- return;
- case 'retrieve-inline-request':
- event.preventDefault();
- requestVerificationCode('retrieve');
- return;
- case 'retrieve-inline-confirm':
- event.preventDefault();
- confirmVerificationCode('retrieve');
- return;
- case 'retrieve-inline-copy':
- event.preventDefault();
- copyRetrievedLicense();
- return;
- case 'refund-inline-submit':
- event.preventDefault();
- submitRefund();
- return;
- case 'data-export-request':
- event.preventDefault();
- requestVerificationCode('export');
- return;
- case 'data-export-resend':
- resendVerificationCode('export', event);
- return;
- case 'data-export-confirm':
- event.preventDefault();
- confirmVerificationCode('export');
- return;
- case 'data-delete-request':
- event.preventDefault();
- requestVerificationCode('delete');
- return;
- case 'data-delete-resend':
- resendVerificationCode('delete', event);
- return;
- case 'data-delete-confirm':
- event.preventDefault();
- confirmVerificationCode('delete');
- return;
- default:
- return;
- }
- });
-
- document.addEventListener('input', function(event) {
- var target = asHTMLElement(event.target) as FormValueElement | null;
- if (!target) return;
- var inputKind = target.getAttribute('data-account-service-input') || '';
+ function updateInputValue(inputKind: string, value: string) {
switch (inputKind) {
case 'manage-email':
- serviceState.flows.manage.emailValue = target.value;
+ serviceState.flows.manage.emailValue = value;
return;
case 'manage-code':
- serviceState.flows.manage.codeValue = target.value;
+ serviceState.flows.manage.codeValue = value;
return;
case 'retrieve-email':
- serviceState.flows.retrieve.emailValue = target.value;
+ serviceState.flows.retrieve.emailValue = value;
return;
case 'retrieve-code':
- serviceState.flows.retrieve.codeValue = target.value;
+ serviceState.flows.retrieve.codeValue = value;
return;
case 'refund-email':
- serviceState.refund.emailValue = target.value;
+ serviceState.refund.emailValue = value;
return;
case 'refund-token':
- serviceState.refund.tokenValue = target.value;
+ serviceState.refund.tokenValue = value;
return;
case 'data-export-email':
- serviceState.flows.export.emailValue = target.value;
+ serviceState.flows.export.emailValue = value;
return;
case 'data-export-code':
- serviceState.flows.export.codeValue = target.value;
+ serviceState.flows.export.codeValue = value;
return;
case 'data-delete-email':
- serviceState.flows.delete.emailValue = target.value;
+ serviceState.flows.delete.emailValue = value;
return;
case 'data-delete-code':
- serviceState.flows.delete.codeValue = target.value;
+ serviceState.flows.delete.codeValue = value;
return;
default:
return;
}
- });
+ }
- document.addEventListener('change', function(event) {
- var target = asHTMLElement(event.target) as HTMLInputElement | null;
- if (!target || target.id !== 'data-delete-confirm-check') return;
- serviceState.flows.delete.checkboxChecked = !!target.checked;
+ installServicesController({
+ toggleServicePanel,
+ focusElement,
+ requestVerificationCode: function(flowID) {
+ void requestVerificationCode(flowID);
+ },
+ resendVerificationCode: function(flowID, event) {
+ void resendVerificationCode(flowID, event);
+ },
+ confirmVerificationCode: function(flowID) {
+ void confirmVerificationCode(flowID);
+ },
+ copyRetrievedLicense: function() {
+ void copyRetrievedLicense();
+ },
+ submitRefund: function() {
+ void submitRefund();
+ },
+ updateInputValue,
+ updateDeleteConfirmation: function(checked) {
+ serviceState.flows.delete.checkboxChecked = checked;
+ },
});
diff --git a/internal/cloudcp/portal/frontend/src/services_controller.ts b/internal/cloudcp/portal/frontend/src/services_controller.ts
new file mode 100644
index 000000000..90a4e57ef
--- /dev/null
+++ b/internal/cloudcp/portal/frontend/src/services_controller.ts
@@ -0,0 +1,96 @@
+import { asHTMLElement } from './services_view';
+
+export interface ServicesControllerDeps {
+ toggleServicePanel: (panelID: string) => void;
+ focusElement: (id: string) => void;
+ requestVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void;
+ resendVerificationCode: (flowID: 'manage' | 'export' | 'delete', event?: Event) => void;
+ confirmVerificationCode: (flowID: 'manage' | 'retrieve' | 'export' | 'delete') => void;
+ copyRetrievedLicense: () => void;
+ submitRefund: () => void;
+ updateInputValue: (inputKind: string, value: string) => void;
+ updateDeleteConfirmation: (checked: boolean) => void;
+}
+
+export function installServicesController(deps: ServicesControllerDeps): void {
+ document.addEventListener('click', function(event) {
+ var target = asHTMLElement(event.target)?.closest('[data-account-service-action]');
+ if (!target) return;
+ var action = target.getAttribute('data-account-service-action') || '';
+ var panelID = target.getAttribute('data-account-service-panel') || '';
+ var focusID = target.getAttribute('data-account-service-focus') || '';
+
+ switch (action) {
+ case 'open-service-panel':
+ event.preventDefault();
+ deps.toggleServicePanel(panelID);
+ deps.focusElement(focusID);
+ return;
+ case 'manage-inline-request':
+ event.preventDefault();
+ deps.requestVerificationCode('manage');
+ return;
+ case 'manage-inline-resend':
+ deps.resendVerificationCode('manage', event);
+ return;
+ case 'manage-inline-confirm':
+ event.preventDefault();
+ deps.confirmVerificationCode('manage');
+ return;
+ case 'retrieve-inline-request':
+ event.preventDefault();
+ deps.requestVerificationCode('retrieve');
+ return;
+ case 'retrieve-inline-confirm':
+ event.preventDefault();
+ deps.confirmVerificationCode('retrieve');
+ return;
+ case 'retrieve-inline-copy':
+ event.preventDefault();
+ deps.copyRetrievedLicense();
+ return;
+ case 'refund-inline-submit':
+ event.preventDefault();
+ deps.submitRefund();
+ return;
+ case 'data-export-request':
+ event.preventDefault();
+ deps.requestVerificationCode('export');
+ return;
+ case 'data-export-resend':
+ deps.resendVerificationCode('export', event);
+ return;
+ case 'data-export-confirm':
+ event.preventDefault();
+ deps.confirmVerificationCode('export');
+ return;
+ case 'data-delete-request':
+ event.preventDefault();
+ deps.requestVerificationCode('delete');
+ return;
+ case 'data-delete-resend':
+ deps.resendVerificationCode('delete', event);
+ return;
+ case 'data-delete-confirm':
+ event.preventDefault();
+ deps.confirmVerificationCode('delete');
+ return;
+ default:
+ return;
+ }
+ });
+
+ document.addEventListener('input', function(event) {
+ var target = asHTMLElement(event.target) as HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement | null;
+ if (!target) return;
+ var inputKind = target.getAttribute('data-account-service-input') || '';
+ if (!inputKind) return;
+ deps.updateInputValue(inputKind, target.value);
+ });
+
+ document.addEventListener('change', function(event) {
+ var target = asHTMLElement(event.target) as HTMLInputElement | null;
+ if (!target || target.id !== 'data-delete-confirm-check') return;
+ deps.updateDeleteConfirmation(!!target.checked);
+ });
+}
diff --git a/internal/cloudcp/portal/frontend/src/services_view.ts b/internal/cloudcp/portal/frontend/src/services_view.ts
new file mode 100644
index 000000000..b0d6141bb
--- /dev/null
+++ b/internal/cloudcp/portal/frontend/src/services_view.ts
@@ -0,0 +1,249 @@
+import type { PortalBootstrapData, RefundState, ServiceStatus, VerificationFlowState } from './types';
+
+type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
+
+export function getElement(id: string): T | null {
+ return document.getElementById(id) as T | null;
+}
+
+export function asHTMLElement(target: EventTarget | null): HTMLElement | null {
+ return target instanceof HTMLElement ? target : null;
+}
+
+export function escapeText(value: unknown): string {
+ return String(value || '')
+ .replace(/&/g, '&')
+ .replace(//g, '>');
+}
+
+export function escapeAttribute(value: unknown): string {
+ return escapeText(value)
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+export function readValue(id: string): string {
+ var el = getElement(id);
+ return el ? el.value.trim() : '';
+}
+
+export function focusElement(id: string): void {
+ var el = getElement(id);
+ if (el) el.focus();
+}
+
+export function setVisible(id: string, visible: boolean): void {
+ var el = getElement(id);
+ if (el) {
+ el.style.display = visible ? 'block' : 'none';
+ }
+}
+
+export function setValue(id: string, value: string): void {
+ var el = getElement(id);
+ if (el) {
+ el.value = value;
+ }
+}
+
+export function renderStatus(id: string, status: ServiceStatus): void {
+ var el = getElement(id);
+ if (!el) return;
+ if (!status.visible) {
+ el.textContent = '';
+ el.className = 'service-status';
+ return;
+ }
+ el.textContent = status.message;
+ el.className = 'service-status visible' + (status.error ? ' error' : ' success');
+}
+
+export function renderButton(id: string | undefined, disabled: boolean, label: string | undefined): void {
+ if (!id || !label) return;
+ var button = getElement(id);
+ if (!button) return;
+ button.disabled = disabled;
+ button.textContent = label;
+}
+
+export function renderOpenPanels(openPanelID: string): void {
+ var panels = ['manage-service-panel', 'retrieve-service-panel', 'refund-service-panel', 'data-service-panel'];
+ for (var i = 0; i < panels.length; i++) {
+ var panel = getElement(panels[i]);
+ if (!panel) continue;
+ panel.classList.toggle('visible', panels[i] === openPanelID);
+ }
+}
+
+export function renderRefundPanel(refundState: RefundState, bootstrap: PortalBootstrapData): void {
+ var root = getElement('refund-service-root');
+ if (!root) return;
+ var refundSupportURL = (bootstrap.public_site_url || '') + '/refund.html?email=' + encodeURIComponent(refundState.emailValue || '');
+ root.innerHTML = '' +
+ '
Refund requests
' +
+ '
Process an eligible self-serve refund for a self-hosted purchase. This revokes the associated license immediately.
' +
+ '
Warning: completing a refund immediately revokes the affected license. This should only be used when the refund window and commercial contract allow it.
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '' +
+ '
' +
+ '
' +
+ '' +
+ '
' +
+ '
If this purchase is not eligible for self-serve refund, use the public support path instead: open refund support page.
' +
+ '';
+}
+
+export function renderManagePanel(flowState: VerificationFlowState): void {
+ var root = getElement('manage-service-root');
+ if (!root) return;
+ root.innerHTML = '' +
+ '
Manage subscriptions
' +
+ '
Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.