From 8f65a3dca69167f044429790eff156f74d57eb70 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Thu, 26 Mar 2026 01:59:11 +0000 Subject: [PATCH] refactor(cloudcp): split Pulse Account service runtime --- .../cloudcp/portal/dist/build_manifest.json | 4 +- internal/cloudcp/portal/dist/portal_app.js | 403 ++++++++++-------- internal/cloudcp/portal/frontend/build.mjs | 2 + .../cloudcp/portal/frontend/src/services.ts | 401 +++-------------- .../frontend/src/services_controller.ts | 96 +++++ .../portal/frontend/src/services_view.ts | 249 +++++++++++ 6 files changed, 628 insertions(+), 527 deletions(-) create mode 100644 internal/cloudcp/portal/frontend/src/services_controller.ts create mode 100644 internal/cloudcp/portal/frontend/src/services_view.ts 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.

Need a new code? Send again
'; + } + 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.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; + } + function renderExportPanel(flowState) { + var root = getElement3("data-export-root"); + if (!root) return; + var resultDisplay = flowState.result ? "block" : "none"; + root.innerHTML = '

Export My Data

Need a new code? Send again
"; + } + function renderExportResult(result) { + setVisible("data-export-result", !!result); + setValue("data-export-payload", result ? JSON.stringify(result, null, 2) : ""); + } + function renderDeletePanel(flowState) { + var root = getElement3("data-delete-root"); + if (!root) return; + root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; + } + + // 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.

Need a new code? Send again
'; - } + renderPanel: renderManagePanel }, retrieve: { requestPath: "/v1/retrieve-license/request", @@ -951,19 +1060,7 @@ serviceState.flows.retrieve.codeValue = ""; setFlowStatus("retrieve", "License retrieved successfully.", false); }, - renderPanel: function(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.

View Invoice
Use the latest active self-hosted license for this commercial email.
Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
"; - }, - renderResult: function(result) { - void result; - } + renderPanel: renderRetrievePanel }, export: { requestPath: "/v1/gdpr/request-export", @@ -999,16 +1096,8 @@ resetVerificationFlow("export"); serviceState.flows.export.result = data; }, - renderPanel: function(flowState) { - var root = getElement3("data-export-root"); - if (!root) return; - var resultDisplay = flowState.result ? "block" : "none"; - root.innerHTML = '

Export My Data

Need a new code? Send again
"; - }, - renderResult: function(result) { - setVisible("data-export-result", !!result); - setValue("data-export-payload", result ? JSON.stringify(result, null, 2) : ""); - } + renderPanel: renderExportPanel, + renderResult: renderExportResult }, delete: { requestPath: "/v1/gdpr/request-delete", @@ -1050,11 +1139,7 @@ resetVerificationFlow("delete"); setFlowStatus("delete", data.deleted_count > 0 && data.stripe_reminder ? data.message + " " + data.stripe_reminder : data.message, false); }, - renderPanel: function(flowState) { - var root = getElement3("data-delete-root"); - if (!root) return; - root.innerHTML = '

Delete My Data

Warning: deleting commercial data also revokes license records and cannot be undone.
I understand this permanently deletes my commercial data and revokes associated licenses.
Need a new code? Send again
'; - } + 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.

' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
Need a new code? Send again
' + - '
' + - '
'; - } + renderPanel: renderManagePanel }, retrieve: { requestPath: '/v1/retrieve-license/request', @@ -343,60 +228,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme serviceState.flows.retrieve.codeValue = ''; setFlowStatus('retrieve', 'License retrieved successfully.', false); }, - renderPanel: function(flowState) { - var root = getElement('retrieve-service-root'); - if (!root) return; - var result = flowState.result as { - invoice_url?: string; - token?: string; - tier?: string; - issued_at?: string; - expires_at?: string | null; - email?: string; - } | null; - 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.

' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '' + - 'View Invoice' + - '
' + - '
Use the latest active self-hosted license for this commercial email.
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
Plan
' + escapeText(result ? result.tier : '') + '
' + - '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : '') + '
' + - '
Expires
' + escapeText(result ? (result.expires_at ? new Date(result.expires_at).toLocaleString() : 'Does not expire') : '') + '
' + - '
Purchase Email
' + escapeText(result ? result.email : '') + '
' + - '
' + - '
'; - }, - renderResult: function(result) { - void result; - } + renderPanel: renderRetrievePanel }, export: { requestPath: '/v1/gdpr/request-export', @@ -432,41 +264,8 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme resetVerificationFlow('export'); serviceState.flows.export.result = data; }, - renderPanel: function(flowState) { - var root = getElement('data-export-root'); - if (!root) return; - var resultDisplay = flowState.result ? 'block' : 'none'; - root.innerHTML = '' + - '

Export My Data

' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
Need a new code? Send again
' + - '
' + - '
' + - '
' + - '' + - '' + - '
'; - }, - renderResult: function(result) { - setVisible('data-export-result', !!result); - setValue('data-export-payload', result ? JSON.stringify(result, null, 2) : ''); - } + renderPanel: renderExportPanel, + renderResult: renderExportResult }, delete: { requestPath: '/v1/gdpr/request-delete', @@ -508,37 +307,7 @@ type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectEleme resetVerificationFlow('delete'); setFlowStatus('delete', data.deleted_count > 0 && data.stripe_reminder ? data.message + ' ' + data.stripe_reminder : data.message, false); }, - renderPanel: function(flowState) { - var root = getElement('data-delete-root'); - if (!root) return; - root.innerHTML = '' + - '

Delete My Data

' + - '
Warning: deleting commercial data also revokes license records and cannot be undone.
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - '
' + - '
' + - '
' + - '
' + - '' + - '' + - '
' + - '
' + - '' + - 'I understand this permanently deletes my commercial data and revokes associated licenses.' + - '
' + - '
' + - '' + - '
' + - '
Need a new code? Send again
' + - '
' + - '
'; - } + 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.

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
Need a new code? Send again
' + + '
' + + '
'; +} + +export function renderRetrievePanel(flowState: VerificationFlowState): void { + var root = getElement('retrieve-service-root'); + if (!root) return; + var result = flowState.result as { + invoice_url?: string; + token?: string; + tier?: string; + issued_at?: string; + expires_at?: string | null; + email?: string; + } | null; + 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.

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + 'View Invoice' + + '
' + + '
Use the latest active self-hosted license for this commercial email.
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
Plan
' + escapeText(result ? result.tier : '') + '
' + + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : '') + '
' + + '
Expires
' + escapeText(result ? (result.expires_at ? new Date(result.expires_at).toLocaleString() : 'Does not expire') : '') + '
' + + '
Purchase Email
' + escapeText(result ? result.email : '') + '
' + + '
' + + '
'; +} + +export function renderExportPanel(flowState: VerificationFlowState): void { + var root = getElement('data-export-root'); + if (!root) return; + var resultDisplay = flowState.result ? 'block' : 'none'; + root.innerHTML = '' + + '

Export My Data

' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
Need a new code? Send again
' + + '
' + + '
' + + '
' + + '' + + '' + + '
'; +} + +export function renderExportResult(result: unknown): void { + setVisible('data-export-result', !!result); + setValue('data-export-payload', result ? JSON.stringify(result, null, 2) : ''); +} + +export function renderDeletePanel(flowState: VerificationFlowState): void { + var root = getElement('data-delete-root'); + if (!root) return; + root.innerHTML = '' + + '

Delete My Data

' + + '
Warning: deleting commercial data also revokes license records and cannot be undone.
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + 'I understand this permanently deletes my commercial data and revokes associated licenses.' + + '
' + + '
' + + '' + + '
' + + '
Need a new code? Send again
' + + '
' + + '
'; +}