diff --git a/.gitignore b/.gitignore index b34a52345..8f8bf2a25 100644 --- a/.gitignore +++ b/.gitignore @@ -54,6 +54,9 @@ frontend-modern/.vite/ frontend/ frontend-modern/public/download/ data/ +!internal/cloudcp/portal/frontend/ +!internal/cloudcp/portal/frontend/** +internal/cloudcp/portal/frontend/node_modules/ # Environment .env @@ -67,6 +70,8 @@ build/ *.tar.gz pulse-fixes*.tar.gz scripts/macos/dist/ +!internal/cloudcp/portal/dist/ +!internal/cloudcp/portal/dist/** # Frontend copy for embedding (generated during build) # Frontend build artifact for Go embedding diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index 9efbf0537..aea0cad9e 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -224,6 +224,9 @@ The `customer-account-portal` lane should deliver: 5. a renderer-owned frontend bootstrap contract for the account shell, so a maintained frontend can consume canonical account state without scraping ad-hoc DOM attributes or hardcoded production URLs +6. a maintained bundled frontend source tree and sync-proof path inside + `internal/cloudcp/portal`, so the account shell does not regress into + handwritten embedded asset drift ### Current frontend seam @@ -248,7 +251,12 @@ same contract, so route wiring can promote the shell toward a maintained frontend/API split without inventing a second state model. New frontend work should extend that contract deliberately instead of adding -one-off data attributes or baking production hostnames into static assets. +one-off data attributes or baking production hostnames into static assets. The +maintained frontend source now lives under `internal/cloudcp/portal/frontend/`, +is embedded from `internal/cloudcp/portal/dist/`, and is guarded by +`internal/cloudcp/portal/frontend_sync_test.go`, so Pulse Account frontend work +should extend that source tree and rebuild the committed bundle instead of +editing embedded script or CSS blobs directly. ### Post-lane follow-on diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 5672a5e89..af1a1b077 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -59,6 +59,15 @@ Own canonical runtime payload shapes between backend and frontend. 36. `internal/api/ai_hosted_runtime.go` 37. `internal/api/router_routes_licensing.go` 38. `internal/api/reporting_inventory_handlers.go` +39. `internal/cloudcp/portal/bootstrap.go` +40. `internal/cloudcp/portal/handlers.go` +41. `internal/cloudcp/portal/page.go` +42. `internal/cloudcp/portal/page_templates.go` +43. `internal/cloudcp/portal/frontend/src/index.ts` +44. `internal/cloudcp/portal/frontend/src/shell.ts` +45. `internal/cloudcp/portal/frontend/src/services.ts` +46. `internal/cloudcp/portal/frontend/src/styles.css` +47. `internal/cloudcp/portal/frontend_sync_test.go` ## Shared Boundaries @@ -201,7 +210,11 @@ path, signup path, and stable workspace summary fields such as `created_at`. authenticated users, so new account frontend work must extend that shared contract rather than inventing a second local payload shape, reviving separate login/portal templates, or hardcoding production URLs, route prefixes, or -DOM-scraped account facts in static assets. +DOM-scraped account facts in static assets. That canonical renderer now lives +under `internal/cloudcp/portal/frontend/`, is embedded from +`internal/cloudcp/portal/dist/`, and is guarded by +`internal/cloudcp/portal/frontend_sync_test.go`, so the maintained frontend +sources and the committed embedded bundle cannot drift silently. Hosted Pulse Cloud tenant-org AI reads now also follow that same canonical rule: `internal/api/ai_hosted_runtime.go`, `internal/api/ai_handlers.go`, `internal/api/ai_handler.go`, and `internal/api/hosted_billing_state.go` diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json new file mode 100644 index 000000000..f80736585 --- /dev/null +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -0,0 +1,11 @@ +{ + "source_hash": "f9e071bc97f6d800cf831621a677dbb409502e721382b74a36bb72586181848f", + "build_inputs": [ + "package.json", + "build.mjs", + "src/index.ts", + "src/shell.ts", + "src/services.ts", + "src/styles.css" + ] +} diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css new file mode 100644 index 000000000..b731ad6f3 --- /dev/null +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -0,0 +1,627 @@ +/* src/styles.css */ +:root { + color-scheme: light; +} +* { + box-sizing: border-box; +} +body { + margin: 0; + font-family: + -apple-system, + BlinkMacSystemFont, + "Segoe UI", + sans-serif; + background: #f1f5f9; + color: #0f172a; +} +header { + background: #1e293b; + color: #f8fafc; + padding: 12px 24px; + display: flex; + align-items: center; + justify-content: space-between; +} +header .brand { + font-weight: 700; + font-size: 18px; + letter-spacing: -0.3px; +} +header .user-info { + display: flex; + align-items: center; + gap: 16px; + font-size: 13px; + color: #94a3b8; +} +header .logout-btn { + background: none; + border: 1px solid #475569; + color: #94a3b8; + border-radius: 6px; + padding: 5px 12px; + cursor: pointer; + font-size: 13px; +} +header .logout-btn:hover { + border-color: #94a3b8; + color: #f8fafc; +} +.main { + max-width: 860px; + margin: 32px auto; + padding: 0 16px 48px; +} +.account-section { + margin-bottom: 40px; +} +.account-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 16px; +} +.account-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; +} +.badge { + font-size: 11px; + font-weight: 600; + padding: 2px 8px; + border-radius: 999px; + text-transform: uppercase; + letter-spacing: 0.5px; +} +.badge-msp { + background: #dbeafe; + color: #1e40af; +} +.badge-cloud { + background: #dcfce7; + color: #166534; +} +.badge-individual { + background: #dcfce7; + color: #166534; +} +.badge-healthy { + background: #dcfce7; + color: #166534; +} +.badge-unhealthy { + background: #fee2e2; + color: #991b1b; +} +.badge-active { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} +.badge-suspended { + background: #fef9c3; + color: #854d0e; + border: 1px solid #fef08a; +} +.badge-failed { + background: #fee2e2; + color: #991b1b; + border: 1px solid #fecaca; +} +.badge-provisioning { + background: #eff6ff; + color: #1d4ed8; + border: 1px solid #bfdbfe; +} +.badge-canceled { + background: #f1f5f9; + color: #64748b; + border: 1px solid #e2e8f0; +} +.badge-deleting { + background: #fef3c7; + color: #92400e; + border: 1px solid #fde68a; +} +.workspace-list { + display: flex; + flex-direction: column; + gap: 10px; +} +.workspace-card { + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 14px 18px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + box-shadow: 0 1px 3px rgba(15, 23, 42, .04); +} +.workspace-card:hover { + border-color: #cbd5e1; +} +.ws-info { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} +.ws-name { + font-weight: 600; + font-size: 15px; +} +.ws-meta { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} +.ws-created { + font-size: 11px; + color: #94a3b8; +} +.ws-actions { + display: flex; + align-items: center; + gap: 8px; + flex-shrink: 0; +} +.btn-primary { + background: #1d4ed8; + color: #fff; + border: 0; + border-radius: 8px; + padding: 8px 18px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + text-decoration: none; + display: inline-block; +} +.btn-primary:hover { + background: #1e40af; +} +.btn-secondary { + background: #fff; + color: #334155; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 7px 14px; + font-size: 13px; + font-weight: 500; + cursor: pointer; +} +.btn-secondary:hover { + background: #f8fafc; + border-color: #94a3b8; +} +.btn-danger { + background: #fff; + color: #dc2626; + border: 1px solid #fca5a5; + border-radius: 8px; + padding: 7px 14px; + font-size: 13px; + cursor: pointer; +} +.btn-danger:hover { + background: #fef2f2; +} +.account-actions { + margin-top: 14px; + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; +} +.add-workspace-form { + margin-top: 12px; + display: none; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 16px; +} +.add-workspace-form.visible { + display: block; +} +.add-workspace-form label { + display: block; + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} +.add-workspace-form input { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 12px; + font-size: 14px; + margin-bottom: 10px; +} +.add-workspace-form .form-actions { + display: flex; + gap: 8px; +} +.team-section { + margin-top: 12px; + display: none; + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 16px; +} +.team-section.visible { + display: block; +} +.team-section h3 { + margin: 0 0 12px; + font-size: 15px; + font-weight: 700; +} +.team-table { + width: 100%; + border-collapse: collapse; + font-size: 14px; +} +.team-table th { + text-align: left; + font-size: 12px; + font-weight: 600; + color: #64748b; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 6px 8px; + border-bottom: 1px solid #e2e8f0; +} +.team-table td { + padding: 8px; + border-bottom: 1px solid #f1f5f9; + vertical-align: middle; +} +.team-table select { + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 4px 8px; + font-size: 13px; + background: #fff; +} +.team-table .btn-remove { + background: none; + border: none; + color: #dc2626; + cursor: pointer; + font-size: 13px; + padding: 4px 8px; +} +.team-table .btn-remove:hover { + text-decoration: underline; +} +.team-invite { + margin-top: 12px; + display: flex; + gap: 8px; + align-items: flex-end; + flex-wrap: wrap; +} +.team-invite label { + font-size: 12px; + font-weight: 600; + display: block; + margin-bottom: 4px; +} +.team-invite input { + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 8px 12px; + font-size: 14px; + min-width: 200px; +} +.team-invite select { + border: 1px solid #cbd5e1; + border-radius: 6px; + padding: 8px 10px; + font-size: 14px; + background: #fff; +} +.intro-card { + background: + linear-gradient( + 145deg, + #fff, + #f8fafc); + border: 1px solid #dbe4f0; + border-radius: 16px; + padding: 22px 24px; + margin-bottom: 24px; + box-shadow: 0 10px 24px rgba(15, 23, 42, .05); +} +.intro-card h1 { + margin: 0 0 8px; + font-size: 26px; + line-height: 1.1; +} +.intro-card p { + margin: 0; + color: #475569; + font-size: 15px; + line-height: 1.6; + max-width: 760px; +} +.service-section { + margin-top: 40px; +} +.service-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + margin-bottom: 14px; + flex-wrap: wrap; +} +.service-header h2 { + margin: 0; + font-size: 20px; + font-weight: 700; +} +.service-note { + font-size: 13px; + color: #64748b; + max-width: 680px; +} +.service-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 12px; +} +.service-card { + display: block; + text-decoration: none; + color: inherit; + background: #fff; + border: 1px solid #e2e8f0; + border-radius: 12px; + padding: 16px; + box-shadow: 0 1px 3px rgba(15, 23, 42, .04); +} +.service-card:hover { + border-color: #93c5fd; + box-shadow: 0 6px 18px rgba(29, 78, 216, .08); +} +.service-card-button { + width: 100%; + text-align: left; + border: 1px solid #e2e8f0; + cursor: pointer; + font: inherit; +} +.service-card h3 { + margin: 0 0 6px; + font-size: 16px; + font-weight: 700; +} +.service-card p { + margin: 0; + font-size: 14px; + color: #64748b; + line-height: 1.5; +} +.service-panel { + display: none; + margin-top: 14px; + background: #fff; + border: 1px solid #dbe4f0; + border-radius: 12px; + padding: 18px; +} +.service-panel.visible { + display: block; +} +.service-panel h3 { + margin: 0 0 8px; + font-size: 17px; +} +.service-panel p { + margin: 0 0 14px; + font-size: 14px; + color: #64748b; +} +.service-panel label { + display: block; + margin-bottom: 6px; + font-size: 13px; + font-weight: 600; + color: #475569; +} +.service-panel input, +.service-panel textarea { + width: 100%; + border: 1px solid #cbd5e1; + border-radius: 8px; + padding: 10px 12px; + font: inherit; + box-sizing: border-box; + background: #fff; + color: #0f172a; +} +.service-panel input:focus, +.service-panel textarea:focus { + outline: 2px solid #93c5fd; + border-color: #1d4ed8; +} +.service-panel textarea { + min-height: 120px; + resize: vertical; + font-family: + ui-monospace, + SFMono-Regular, + Menlo, + Consolas, + monospace; +} +.service-panel .form-group { + margin-bottom: 14px; +} +.service-panel .form-actions { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} +.service-panel .helper-text { + margin-top: 10px; + font-size: 13px; + color: #64748b; +} +.service-panel .helper-text a { + color: #1d4ed8; +} +.service-panel .result-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-top: 14px; +} +.service-panel .result-meta-label { + font-size: 12px; + color: #64748b; + margin-bottom: 4px; +} +.service-panel .result-meta-value { + font-size: 14px; + color: #0f172a; + word-break: break-word; +} +.service-panel .subsection { + margin-top: 18px; + padding-top: 18px; + border-top: 1px solid #e2e8f0; +} +.service-panel .subsection:first-of-type { + margin-top: 0; + padding-top: 0; + border-top: none; +} +.service-panel .subsection h4 { + margin: 0 0 8px; + font-size: 15px; + font-weight: 700; +} +.service-panel .warning { + background: #fef2f2; + border: 1px solid #fecaca; + color: #991b1b; + border-radius: 8px; + padding: 12px; + font-size: 13px; + line-height: 1.5; + margin-bottom: 12px; +} +.service-panel .checkbox-row { + display: flex; + align-items: flex-start; + gap: 10px; + margin: 12px 0; +} +.service-panel .checkbox-row input[type=checkbox] { + width: 18px; + height: 18px; + margin-top: 2px; + flex-shrink: 0; +} +.service-panel .checkbox-row span { + font-size: 13px; + color: #475569; + line-height: 1.5; +} +.service-status { + margin-top: 12px; + padding: 10px 12px; + border-radius: 8px; + font-size: 13px; + display: none; +} +.service-status.visible { + display: block; +} +.service-status.error { + background: #fef2f2; + color: #991b1b; + border: 1px solid #fecaca; +} +.service-status.success { + background: #f0fdf4; + color: #166534; + border: 1px solid #bbf7d0; +} +.empty-state { + background: #fff; + border: 1px dashed #cbd5e1; + border-radius: 10px; + padding: 32px; + text-align: center; + color: #64748b; +} +.empty-state p { + margin: 0; + font-size: 15px; +} +.spinner { + display: none; + width: 18px; + height: 18px; + border: 2px solid #93c5fd; + border-top-color: #1d4ed8; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.toast { + position: fixed; + bottom: 24px; + right: 24px; + background: #1e293b; + color: #f8fafc; + border-radius: 8px; + padding: 12px 20px; + font-size: 14px; + display: none; + z-index: 100; +} +.toast.visible { + display: block; + animation: fadein 0.2s; +} +@keyframes fadein { + from { + opacity: 0; + transform: translateY(8px); + } +} +.toast.error { + background: #991b1b; +} +@media (max-width: 560px) { + .workspace-card { + flex-direction: column; + align-items: flex-start; + } + .ws-actions { + align-self: stretch; + } + .ws-actions form, + .ws-actions .btn-primary { + width: 100%; + text-align: center; + } +} diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js new file mode 100644 index 000000000..7b7975b30 --- /dev/null +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -0,0 +1,1180 @@ +(() => { + // src/shell.ts + var bootstrapEl = document.getElementById("pulse-account-bootstrap"); + var portalBootstrap = {}; + if (bootstrapEl) { + try { + portalBootstrap = JSON.parse(bootstrapEl.textContent || "{}"); + } catch (_) { + portalBootstrap = {}; + } + } + var LICENSE_API_BASE = portalBootstrap.commercial_api_base_url || ""; + var PORTAL_PATH = portalBootstrap.portal_path || "/portal"; + var BOOTSTRAP_PATH = portalBootstrap.bootstrap_path || "/api/portal/bootstrap"; + var MAGIC_LINK_REQUEST_PATH = portalBootstrap.magic_link_request_path || "/api/public/magic-link/request"; + var SIGNUP_PATH = portalBootstrap.signup_path || "/signup"; + var LOGOUT_PATH = portalBootstrap.logout_path || "/auth/logout"; + var ACCOUNT_API_BASE_PATH = portalBootstrap.account_api_base_path || "/api/accounts"; + var PORTAL_API_BASE_PATH = portalBootstrap.portal_api_base_path || "/api/portal"; + var loginState = { + emailValue: "", + sending: false, + success: false, + error: "" + }; + function escapeHTML(value) { + return String(value || "").replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); + } + function escapeAttr(value) { + return escapeHTML(value); + } + function formatWorkspaceDate(value) { + if (!value) return ""; + var date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + return date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" }); + } + function roleBadgeHTML(role) { + if (role === "owner") return 'Owner'; + if (role === "admin") return 'Admin'; + if (role === "tech") return 'Tech'; + return ""; + } + function anonymousBootstrap() { + return { + authenticated: false, + email: "", + public_site_url: portalBootstrap.public_site_url || "https://pulserelay.pro", + support_email: portalBootstrap.support_email || "support@pulserelay.pro", + commercial_api_base_url: LICENSE_API_BASE, + portal_path: PORTAL_PATH, + bootstrap_path: BOOTSTRAP_PATH, + magic_link_request_path: MAGIC_LINK_REQUEST_PATH, + signup_path: SIGNUP_PATH, + logout_path: LOGOUT_PATH, + account_api_base_path: ACCOUNT_API_BASE_PATH, + portal_api_base_path: PORTAL_API_BASE_PATH, + accounts: [] + }; + } + function renderHeader() { + var userInfo = document.getElementById("portal-user-info"); + if (!userInfo) return; + if (portalBootstrap.authenticated) { + userInfo.innerHTML = "" + escapeHTML(portalBootstrap.email || "") + ''; + return; + } + userInfo.innerHTML = 'Create account'; + } + function renderWorkspaceCard(account, workspace) { + var state = String(workspace.state || ""); + var safeState = escapeHTML(state); + var createdLabel = formatWorkspaceDate(workspace.created_at); + var openAction = ""; + if (state === "active") { + openAction = '
'; + } else { + openAction = '' + safeState + ""; + } + var manageAction = ""; + if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) { + manageAction = ''; + } + var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + "" : ""; + return '
' + escapeHTML(workspace.display_name) + '
' + (workspace.healthy ? 'Healthy' : 'Checking') + '' + safeState + "" + createdMeta + '
' + openAction + manageAction + "
"; + } + function renderAccountSection(account) { + var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; + var workspaceHTML = ""; + if (workspaces.length === 0) { + workspaceHTML = '

No workspaces yet. Create one to get started.

'; + } else { + workspaceHTML = '
' + workspaces.map(function(workspace) { + return renderWorkspaceCard(account, workspace); + }).join("") + "
"; + } + var actions = ""; + var teamSection = ""; + var addWorkspaceForm = ""; + if (account.can_manage) { + actions = '
' + (account.kind === "msp" ? '' : "") + (account.has_billing ? '' : "") + '
'; + teamSection = '

Team members

EmailRole
Loading\u2026
'; + if (account.kind === "msp") { + addWorkspaceForm = '
'; + } + } + return '

' + escapeHTML(account.name) + '

' + escapeHTML(account.kind_label) + "" + roleBadgeHTML(account.role) + "
" + workspaceHTML + actions + teamSection + addWorkspaceForm + "
"; + } + function renderAccounts(accounts) { + var root = document.getElementById("accounts-root"); + if (!root) return; + var safeAccounts = Array.isArray(accounts) ? accounts : []; + if (safeAccounts.length === 0) { + root.innerHTML = '

No workspaces found. If you just signed up, check your email for setup instructions.

Need help? Contact ' + escapeHTML(portalBootstrap.support_email || "") + "

"; + return; + } + root.innerHTML = safeAccounts.map(renderAccountSection).join(""); + } + function renderAuthenticatedPortal() { + return '

Pulse Account

Manage Cloud workspaces, MSP access, and self-hosted commercial account services from one account surface. Hosted workspace lifecycle lives here today, and the self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell instead of staying fragmented across public utility pages.

Other account services

Self-hosted commercial account actions now live here. The public utility pages remain as compatibility entry points, not the primary account surface.

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(portalBootstrap.support_email || "") + ".
"; + } + function renderSignedOutPortal() { + var statusHTML = ""; + if (loginState.error) { + statusHTML = '
' + escapeHTML(loginState.error) + "
"; + } else if (loginState.success) { + statusHTML = `
Magic link sent. Check your inbox and click the link to sign in.

Don't see it? Send a new link.
`; + } + return '

Pulse Account

Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; + } + function dispatchPortalRender() { + document.dispatchEvent(new CustomEvent("pulse-account-render")); + } + function renderPortalApp() { + renderHeader(); + var root = document.getElementById("portal-app-root"); + if (!root) return; + root.innerHTML = portalBootstrap.authenticated ? renderAuthenticatedPortal() : renderSignedOutPortal(); + if (portalBootstrap.authenticated) { + renderAccounts(portalBootstrap.accounts || []); + } + dispatchPortalRender(); + } + function applyBootstrap(data) { + portalBootstrap = data || anonymousBootstrap(); + LICENSE_API_BASE = portalBootstrap.commercial_api_base_url || LICENSE_API_BASE; + PORTAL_PATH = portalBootstrap.portal_path || PORTAL_PATH; + BOOTSTRAP_PATH = portalBootstrap.bootstrap_path || BOOTSTRAP_PATH; + MAGIC_LINK_REQUEST_PATH = portalBootstrap.magic_link_request_path || MAGIC_LINK_REQUEST_PATH; + SIGNUP_PATH = portalBootstrap.signup_path || SIGNUP_PATH; + LOGOUT_PATH = portalBootstrap.logout_path || LOGOUT_PATH; + ACCOUNT_API_BASE_PATH = portalBootstrap.account_api_base_path || ACCOUNT_API_BASE_PATH; + PORTAL_API_BASE_PATH = portalBootstrap.portal_api_base_path || PORTAL_API_BASE_PATH; + if (!portalBootstrap.authenticated && !loginState.emailValue) { + loginState.emailValue = portalBootstrap.email || ""; + } + renderPortalApp(); + } + async function refreshBootstrap() { + if (!BOOTSTRAP_PATH) return false; + try { + var response = await fetch(BOOTSTRAP_PATH, { + headers: { "Accept": "application/json" } + }); + if (response.status === 401) { + applyBootstrap(anonymousBootstrap()); + return true; + } + if (!response.ok) return false; + var data = await response.json(); + applyBootstrap(data); + return true; + } catch (_) { + } + return false; + } + function showToast(msg, isError) { + var t = document.getElementById("toast"); + if (!t) return; + t.textContent = msg; + t.className = "toast visible" + (isError ? " error" : ""); + clearTimeout(t._timer); + t._timer = setTimeout(function() { + t.className = "toast"; + }, 4e3); + } + async function sendMagicLink() { + var email = String(loginState.emailValue || "").trim(); + if (!email) { + var input = document.getElementById("portal-login-email"); + if (input) input.focus(); + return; + } + loginState.sending = true; + loginState.error = ""; + loginState.success = false; + renderPortalApp(); + try { + var response = await fetch(MAGIC_LINK_REQUEST_PATH, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email }) + }); + if (response.ok || response.status === 404) { + loginState.sending = false; + loginState.success = true; + renderPortalApp(); + return; + } + if (response.status === 429) { + loginState.error = "Too many requests. Please wait a moment and try again."; + } else { + loginState.error = "Something went wrong. Please try again."; + } + } catch (_) { + loginState.error = "Network error. Please check your connection and try again."; + } + loginState.sending = false; + renderPortalApp(); + } + window.PulseAccountPortal = { + getBootstrap: function() { + return portalBootstrap; + }, + getCommercialAPIBaseURL: function() { + return LICENSE_API_BASE; + }, + getPortalPath: function() { + return PORTAL_PATH; + }, + getAccountAPIBasePath: function() { + return ACCOUNT_API_BASE_PATH; + }, + getPortalAPIBasePath: function() { + return PORTAL_API_BASE_PATH; + }, + refreshBootstrap, + showToast + }; + document.addEventListener("click", function(event) { + var portalActionEl = event.target.closest("[data-portal-action]"); + if (portalActionEl) { + var portalAction = portalActionEl.getAttribute("data-portal-action") || ""; + switch (portalAction) { + case "send-magic-link": + event.preventDefault(); + sendMagicLink(); + return; + case "resend-magic-link": + event.preventDefault(); + loginState.success = false; + loginState.error = ""; + renderPortalApp(); + sendMagicLink(); + return; + default: + break; + } + } + var logoutBtn = event.target.closest("#logout-btn"); + if (logoutBtn) { + event.preventDefault(); + logoutBtn.disabled = true; + logoutBtn.textContent = "Signing out\u2026"; + (async function() { + try { + await fetch(LOGOUT_PATH, { method: "POST" }); + } catch (_) { + } + window.location.href = PORTAL_PATH; + })(); + return; + } + var actionEl = event.target.closest("[data-action]"); + if (!actionEl) return; + var action = actionEl.getAttribute("data-action") || ""; + var accountID = actionEl.getAttribute("data-account-id") || ""; + switch (action) { + case "toggle-add-workspace": + event.preventDefault(); + toggleAddWorkspace(accountID); + return; + case "open-billing": + event.preventDefault(); + openBilling(accountID); + return; + case "toggle-team": + event.preventDefault(); + toggleTeam(accountID); + return; + case "invite-member": + event.preventDefault(); + inviteMember(accountID); + return; + case "create-workspace": + event.preventDefault(); + createWorkspace(accountID); + return; + case "workspace-manage": + event.preventDefault(); + suspendOrDelete( + event, + accountID, + actionEl.getAttribute("data-workspace-id") || "", + actionEl.getAttribute("data-workspace-state") || "", + actionEl.getAttribute("data-workspace-name") || "" + ); + return; + case "remove-member": + event.preventDefault(); + removeMember( + accountID, + actionEl.getAttribute("data-user-id") || "", + actionEl.getAttribute("data-member-email") || "" + ); + return; + default: + return; + } + }); + document.addEventListener("change", function(event) { + var target = event.target; + if (!target || target.getAttribute("data-action") !== "change-role") return; + changeRole( + target.getAttribute("data-account-id") || "", + target.getAttribute("data-user-id") || "", + target.value + ); + }); + document.addEventListener("input", function(event) { + var target = event.target; + if (!target) return; + if (target.getAttribute("data-portal-input") === "login-email") { + loginState.emailValue = target.value; + } + }); + function toggleAddWorkspace(accountID) { + var form = document.getElementById("add-ws-form-" + accountID); + if (!form) return; + var visible = form.classList.contains("visible"); + form.classList.toggle("visible", !visible); + if (!visible) { + var input = document.getElementById("ws-name-" + accountID); + if (input) input.focus(); + } + } + async function createWorkspace(accountID) { + var nameEl = document.getElementById("ws-name-" + accountID); + if (!nameEl) return; + var name = nameEl.value.trim(); + if (!name) { + nameEl.focus(); + return; + } + var spinner = document.getElementById("ws-spinner-" + accountID); + if (spinner) spinner.style.display = "block"; + try { + var resp = await fetch(ACCOUNT_API_BASE_PATH + "/" + accountID + "/tenants", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ display_name: name }) + }); + if (!resp.ok) { + var err = await resp.json().catch(function() { + return {}; + }); + showToast(err && err.error || "Failed to create workspace", true); + return; + } + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return; + } + showToast("Workspace created!"); + } catch (_) { + showToast("Network error. Please try again.", true); + } finally { + if (spinner) spinner.style.display = "none"; + } + } + async function suspendOrDelete(evt, accountID, tenantID, state, name) { + evt.stopPropagation(); + var action = state === "active" ? "Suspend" : "Delete"; + if (!confirm(action + ' workspace "' + name + '"?')) return; + var method = state === "active" ? "PATCH" : "DELETE"; + var body = state === "active" ? JSON.stringify({ state: "suspended" }) : void 0; + try { + var response = await fetch(ACCOUNT_API_BASE_PATH + "/" + accountID + "/tenants/" + tenantID, { + method, + headers: body ? { "Content-Type": "application/json" } : {}, + body + }); + if (!response.ok) { + showToast("Failed to " + action.toLowerCase() + " workspace.", true); + return; + } + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return; + } + showToast(action + "d workspace."); + } catch (_) { + showToast("Network error.", true); + } + } + async function openBilling(accountID) { + try { + var r = await fetch(PORTAL_API_BASE_PATH + "/billing?account_id=" + encodeURIComponent(accountID), { method: "POST" }); + if (!r.ok) { + var err = await r.json().catch(function() { + return {}; + }); + showToast(err && err.error || "Failed to open billing portal.", true); + return; + } + var data = await r.json(); + if (data && data.url) { + window.location.href = data.url; + } else { + showToast("Failed to open billing portal.", true); + } + } catch (_) { + showToast("Network error.", true); + } + } + function toggleTeam(accountID) { + var section = document.getElementById("team-section-" + accountID); + if (!section) return; + var visible = section.classList.contains("visible"); + section.classList.toggle("visible", !visible); + if (!visible) loadTeam(accountID); + } + function setTbodyMessage(tbody, msg, isError) { + tbody.textContent = ""; + var tr = document.createElement("tr"); + var td = document.createElement("td"); + td.setAttribute("colspan", "3"); + td.style.cssText = "text-align:center;padding:16px;color:" + (isError ? "#991b1b" : "#94a3b8"); + td.textContent = msg; + tr.appendChild(td); + tbody.appendChild(tr); + } + async function loadTeam(accountID) { + var tbody = document.getElementById("team-list-" + accountID); + var section = document.getElementById("team-section-" + accountID); + if (!tbody || !section) return; + var actorRole = section.getAttribute("data-actor-role") || ""; + var isOwner = actorRole === "owner"; + setTbodyMessage(tbody, "Loading\u2026", false); + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + "/" + encodeURIComponent(accountID) + "/members"); + if (!r.ok) { + setTbodyMessage(tbody, "Failed to load team.", true); + return; + } + var members = await r.json(); + if (!members || members.length === 0) { + setTbodyMessage(tbody, "No team members.", false); + return; + } + var allRoles = ["owner", "admin", "tech", "read_only"]; + var nonOwnerRoles = ["admin", "tech", "read_only"]; + tbody.textContent = ""; + for (var i = 0; i < members.length; i++) { + (function(m) { + var tr = document.createElement("tr"); + var tdEmail = document.createElement("td"); + tdEmail.textContent = m.email; + tr.appendChild(tdEmail); + var tdRole = document.createElement("td"); + if (m.role === "owner" && !isOwner) { + tdRole.textContent = "owner"; + } else { + var sel = document.createElement("select"); + var roles = isOwner ? allRoles : nonOwnerRoles; + for (var j = 0; j < roles.length; j++) { + var opt = document.createElement("option"); + opt.value = roles[j]; + opt.textContent = roles[j].replace("_", " "); + if (m.role === roles[j]) opt.selected = true; + sel.appendChild(opt); + } + sel.setAttribute("data-action", "change-role"); + sel.setAttribute("data-account-id", accountID); + sel.setAttribute("data-user-id", m.user_id); + tdRole.appendChild(sel); + } + tr.appendChild(tdRole); + var tdAction = document.createElement("td"); + if (!(m.role === "owner" && !isOwner)) { + var btn = document.createElement("button"); + btn.type = "button"; + btn.className = "btn-remove"; + btn.textContent = "Remove"; + btn.setAttribute("data-action", "remove-member"); + btn.setAttribute("data-account-id", accountID); + btn.setAttribute("data-user-id", m.user_id); + btn.setAttribute("data-member-email", m.email); + tdAction.appendChild(btn); + } + tr.appendChild(tdAction); + tbody.appendChild(tr); + })(members[i]); + } + } catch (_) { + setTbodyMessage(tbody, "Network error.", true); + } + } + async function refreshAccountTeamSection(accountID) { + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return false; + } + var section = document.getElementById("team-section-" + accountID); + if (!section) { + return true; + } + section.classList.add("visible"); + await loadTeam(accountID); + return true; + } + async function inviteMember(accountID) { + var emailEl = document.getElementById("invite-email-" + accountID); + var roleEl = document.getElementById("invite-role-" + accountID); + if (!emailEl || !roleEl) return; + var email = emailEl.value.trim(); + if (!email) { + emailEl.focus(); + return; + } + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + "/" + encodeURIComponent(accountID) + "/members", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, role: roleEl.value }) + }); + if (r.status === 409) { + showToast("Member already exists.", true); + return; + } + if (!r.ok) { + var err = await r.text(); + showToast(err || "Failed to invite member.", true); + return; + } + emailEl.value = ""; + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast("Member invited!"); + } catch (_) { + showToast("Network error.", true); + } + } + async function changeRole(accountID, userID, newRole) { + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + "/" + encodeURIComponent(accountID) + "/members/" + encodeURIComponent(userID), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ role: newRole }) + }); + if (r.status === 409) { + showToast("Cannot demote last owner.", true); + loadTeam(accountID); + return; + } + if (!r.ok) { + showToast("Failed to update role.", true); + loadTeam(accountID); + return; + } + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast("Role updated."); + } catch (_) { + showToast("Network error.", true); + loadTeam(accountID); + } + } + async function removeMember(accountID, userID, email) { + if (!confirm("Remove " + email + " from this account?")) return; + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + "/" + encodeURIComponent(accountID) + "/members/" + encodeURIComponent(userID), { + method: "DELETE" + }); + if (r.status === 409) { + showToast("Cannot remove last owner.", true); + return; + } + if (!r.ok) { + showToast("Failed to remove member.", true); + return; + } + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast("Member removed."); + } catch (_) { + showToast("Network error.", true); + } + } + loginState.emailValue = portalBootstrap.email || ""; + applyBootstrap(portalBootstrap); + if (portalBootstrap.authenticated) { + refreshBootstrap(); + } + + // src/services.ts + (function() { + var runtime = window.PulseAccountPortal || {}; + var serviceState = { + openPanelID: "", + flows: { + manage: newVerificationFlowState(), + retrieve: newVerificationFlowState(), + export: newVerificationFlowState(), + delete: newVerificationFlowState() + }, + refund: { + emailValue: "", + tokenValue: "", + submitting: false, + status: emptyStatus() + } + }; + function newVerificationFlowState() { + return { + pendingEmail: "", + requesting: false, + confirming: false, + step2Visible: false, + status: emptyStatus(), + result: null, + emailValue: "", + codeValue: "", + checkboxChecked: false + }; + } + function emptyStatus() { + return { + visible: false, + message: "", + error: false + }; + } + function getCommercialAPIBaseURL() { + return runtime.getCommercialAPIBaseURL ? runtime.getCommercialAPIBaseURL() : ""; + } + function getElement(id) { + return document.getElementById(id); + } + 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", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body) + }); + } + function setFlowStatus(flowID, message, isError) { + serviceState.flows[flowID].status = { + visible: true, + message, + error: !!isError + }; + } + function clearFlowStatus(flowID) { + serviceState.flows[flowID].status = emptyStatus(); + } + function setRefundStatus(message, isError) { + serviceState.refund.status = { + visible: true, + message, + error: !!isError + }; + } + 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); + } + } + function renderFlow(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var flowState = serviceState.flows[flowID]; + if (flow.renderPanel) { + flow.renderPanel(flowState); + } + renderButton(flow.requestButtonID, flowState.requesting, flowState.requesting ? flow.requestPendingLabel : flow.requestLabel); + renderButton(flow.confirmButtonID, flowState.confirming, flowState.confirming ? flow.confirmPendingLabel : flow.confirmLabel); + renderStatus(flow.statusID, flowState.status); + if (flow.step2ID) { + setVisible(flow.step2ID, flowState.step2Visible); + } + if (flow.renderResult) { + flow.renderResult(flowState.result); + } + } + function renderAllFlows() { + renderFlow("manage"); + renderFlow("retrieve"); + renderFlow("export"); + renderFlow("delete"); + renderRefund(); + } + function renderRefund() { + renderRefundPanel(); + 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 = runtime.getBootstrap ? runtime.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; + var previous = serviceState.flows[flowID]; + serviceState.flows[flowID] = newVerificationFlowState(); + serviceState.flows[flowID].emailValue = previous.emailValue; + if (flow.codeInputID) { + setValue(flow.codeInputID, ""); + } + } + var verificationFlows = { + manage: { + requestPath: "/v1/manage/request", + confirmPath: "/v1/manage", + panelID: "manage-service-panel", + emailInputID: "manage-inline-email", + codeInputID: "manage-inline-code", + requestButtonID: "manage-inline-request", + confirmButtonID: "manage-inline-confirm", + step2ID: "manage-inline-step2", + statusID: "manage-inline-status", + requestLabel: "Send Verification Code", + requestPendingLabel: "Sending...", + confirmLabel: "Open Customer Portal", + confirmPendingLabel: "Redirecting...", + requestSuccessMessage: "Verification code sent. Check your email.", + resendSuccessMessage: "New verification code sent.", + requestErrorMessage: "Failed to send verification code", + confirmErrorMessage: "Failed to open customer portal", + readEmailValue: function() { + return serviceState.flows.manage.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.manage.codeValue; + }, + onRequestStart: function() { + }, + 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
'; + } + }, + retrieve: { + requestPath: "/v1/retrieve-license/request", + confirmPath: "/v1/retrieve-license", + panelID: "retrieve-service-panel", + emailInputID: "retrieve-inline-email", + codeInputID: "retrieve-inline-code", + requestButtonID: "retrieve-inline-request", + confirmButtonID: "retrieve-inline-confirm", + step2ID: "retrieve-inline-step2", + statusID: "retrieve-inline-status", + requestLabel: "Send Verification Code", + requestPendingLabel: "Sending...", + confirmLabel: "Show License", + confirmPendingLabel: "Loading...", + requestSuccessMessage: "Verification code sent. Check your email.", + resendSuccessMessage: "New verification code sent.", + requestErrorMessage: "Failed to send verification code", + confirmErrorMessage: "Failed to retrieve license", + readEmailValue: function() { + return serviceState.flows.retrieve.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.retrieve.codeValue; + }, + onRequestStart: function() { + serviceState.flows.retrieve.result = null; + }, + onConfirmSuccess: function(data) { + serviceState.flows.retrieve.result = data.license; + 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; + 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; + } + }, + export: { + requestPath: "/v1/gdpr/request-export", + confirmPath: "/v1/gdpr/export", + panelID: "data-service-panel", + emailInputID: "data-export-email", + codeInputID: "data-export-code", + requestButtonID: "data-export-request", + confirmButtonID: "data-export-confirm", + step2ID: "data-export-step2", + statusID: "data-export-status", + requestLabel: "Send Verification Code", + requestPendingLabel: "Sending...", + confirmLabel: "Export My Data", + confirmPendingLabel: "Exporting...", + requestSuccessMessage: "Verification code sent. Check your email.", + resendSuccessMessage: "New verification code sent.", + requestErrorMessage: "Request failed", + confirmErrorMessage: "Export failed", + readEmailValue: function() { + return serviceState.flows.export.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.export.codeValue; + }, + onRequestStart: function() { + serviceState.flows.export.result = null; + }, + onConfirmSuccess: function(data) { + serviceState.flows.export.result = data; + serviceState.flows.export.codeValue = ""; + setFlowStatus("export", "Data export retrieved successfully.", false); + 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) : ""); + } + }, + delete: { + requestPath: "/v1/gdpr/request-delete", + confirmPath: "/v1/gdpr/confirm-delete", + panelID: "data-service-panel", + emailInputID: "data-delete-email", + codeInputID: "data-delete-code", + requestButtonID: "data-delete-request", + confirmButtonID: "data-delete-confirm", + step2ID: "data-delete-step2", + statusID: "data-delete-status", + requestLabel: "Send Verification Code", + requestPendingLabel: "Sending...", + confirmLabel: "Delete My Data", + confirmPendingLabel: "Deleting...", + requestSuccessMessage: "Verification code sent. Check your email.", + resendSuccessMessage: "New verification code sent.", + requestErrorMessage: "Request failed", + confirmErrorMessage: "Deletion failed", + readEmailValue: function() { + return serviceState.flows.delete.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.delete.codeValue; + }, + beforeConfirm: function() { + if (!getElement("data-delete-confirm-check").checked) { + setFlowStatus("delete", "You must confirm that you understand this action is permanent.", true); + renderFlow("delete"); + return false; + } + return true; + }, + onConfirmSuccess: function(data) { + getElement("data-delete-confirm-check").checked = false; + 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
'; + } + } + }; + async function requestVerificationCode(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = flow.readEmailValue ? flow.readEmailValue() : readValue(flow.emailInputID); + if (!email) { + focusElement(flow.emailInputID); + return; + } + flow.onRequestStart(); + serviceState.flows[flowID].requesting = true; + clearFlowStatus(flowID); + renderFlow(flowID); + try { + var res = await serviceFetch(flow.requestPath, { email }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.requestErrorMessage); + serviceState.flows[flowID].pendingEmail = email; + serviceState.flows[flowID].step2Visible = !!flow.step2ID; + setFlowStatus(flowID, flow.requestSuccessMessage, false); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } finally { + serviceState.flows[flowID].requesting = false; + renderFlow(flowID); + } + } + async function resendVerificationCode(flowID, event) { + if (event) event.preventDefault(); + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = serviceState.flows[flowID].pendingEmail; + if (!email) return; + try { + var res = await serviceFetch(flow.requestPath, { email }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.requestErrorMessage); + setFlowStatus(flowID, flow.resendSuccessMessage, false); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } + renderFlow(flowID); + } + async function confirmVerificationCode(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = serviceState.flows[flowID].pendingEmail; + var code = flow.readCodeValue ? flow.readCodeValue() : readValue(flow.codeInputID); + if (!email || !code) return; + if (flow.beforeConfirm && flow.beforeConfirm() === false) { + return; + } + serviceState.flows[flowID].confirming = true; + renderFlow(flowID); + try { + var res = await serviceFetch(flow.confirmPath, { email, code }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.confirmErrorMessage); + flow.onConfirmSuccess(data, email); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } finally { + serviceState.flows[flowID].confirming = false; + renderFlow(flowID); + } + } + async function copyRetrievedLicense() { + var token = serviceState.flows.retrieve.result ? serviceState.flows.retrieve.result.token : ""; + if (!token) return; + try { + await navigator.clipboard.writeText(token); + setFlowStatus("retrieve", "License key copied to clipboard.", false); + } catch (_) { + setFlowStatus("retrieve", "Failed to copy automatically. Please copy the key manually.", true); + } + renderFlow("retrieve"); + } + async function submitRefund() { + var email = serviceState.refund.emailValue; + var token = serviceState.refund.tokenValue; + if (!email || !token) return; + if (!confirm("Are you sure? This will immediately revoke the license and request the refund.")) return; + serviceState.refund.submitting = true; + serviceState.refund.status = emptyStatus(); + renderRefund(); + try { + var res = await serviceFetch("/v1/self-refund", { email, token }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || "Refund failed"); + serviceState.refund.tokenValue = ""; + setRefundStatus("Success! Your refund has been processed. Stripe will follow up by email.", false); + } catch (err) { + setRefundStatus(err.message, true); + } finally { + serviceState.refund.submitting = false; + renderRefund(); + } + } + function syncServiceStateFromBootstrap() { + var bootstrap = runtime.getBootstrap ? runtime.getBootstrap() || {} : {}; + if (!bootstrap.authenticated) { + return; + } + if (!serviceState.flows.manage.emailValue) serviceState.flows.manage.emailValue = bootstrap.email || ""; + if (!serviceState.flows.retrieve.emailValue) serviceState.flows.retrieve.emailValue = bootstrap.email || ""; + if (!serviceState.flows.export.emailValue) serviceState.flows.export.emailValue = bootstrap.email || ""; + if (!serviceState.flows.delete.emailValue) serviceState.flows.delete.emailValue = bootstrap.email || ""; + if (!serviceState.refund.emailValue) serviceState.refund.emailValue = bootstrap.email || ""; + } + function renderServiceRuntime() { + syncServiceStateFromBootstrap(); + renderOpenPanels(); + renderAllFlows(); + } + renderServiceRuntime(); + document.addEventListener("pulse-account-render", renderServiceRuntime); + document.addEventListener("click", function(event) { + var target = 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 = event.target; + if (!target) return; + var inputKind = target.getAttribute("data-account-service-input") || ""; + switch (inputKind) { + case "manage-email": + serviceState.flows.manage.emailValue = target.value; + return; + case "manage-code": + serviceState.flows.manage.codeValue = target.value; + return; + case "retrieve-email": + serviceState.flows.retrieve.emailValue = target.value; + return; + case "retrieve-code": + serviceState.flows.retrieve.codeValue = target.value; + return; + case "refund-email": + serviceState.refund.emailValue = target.value; + return; + case "refund-token": + serviceState.refund.tokenValue = target.value; + return; + case "data-export-email": + serviceState.flows.export.emailValue = target.value; + return; + case "data-export-code": + serviceState.flows.export.codeValue = target.value; + return; + case "data-delete-email": + serviceState.flows.delete.emailValue = target.value; + return; + case "data-delete-code": + serviceState.flows.delete.codeValue = target.value; + return; + default: + return; + } + }); + document.addEventListener("change", function(event) { + var target = event.target; + if (!target || target.id !== "data-delete-confirm-check") return; + serviceState.flows.delete.checkboxChecked = !!target.checked; + }); + })(); +})(); diff --git a/internal/cloudcp/portal/frontend/build.mjs b/internal/cloudcp/portal/frontend/build.mjs new file mode 100644 index 000000000..4f55e9aaf --- /dev/null +++ b/internal/cloudcp/portal/frontend/build.mjs @@ -0,0 +1,73 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +import { withExclusiveLock } from '../../../../scripts/exclusive-lock.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const frontendRoot = __dirname; +const repoRoot = path.resolve(frontendRoot, '../../../../'); +const distRoot = path.resolve(frontendRoot, '../dist'); +const lockPath = path.join(repoRoot, 'tmp', 'locks', 'pulse-account-frontend-build.lock'); +const manifestPath = path.join(distRoot, 'build_manifest.json'); +const buildInputs = [ + 'package.json', + 'build.mjs', + 'src/index.ts', + 'src/shell.ts', + 'src/services.ts', + 'src/styles.css', +]; + +async function computeSourceHash() { + const hash = crypto.createHash('sha256'); + for (const relativePath of buildInputs) { + hash.update(relativePath); + hash.update('\n'); + hash.update(await fs.readFile(path.join(frontendRoot, relativePath))); + hash.update('\n'); + } + return hash.digest('hex'); +} + +await withExclusiveLock( + lockPath, + async () => { + const sourceHash = await computeSourceHash(); + await fs.mkdir(distRoot, { recursive: true }); + await fs.rm(path.join(distRoot, 'portal_app.js'), { force: true }); + await fs.rm(path.join(distRoot, 'portal_app.css'), { force: true }); + await fs.rm(manifestPath, { force: true }); + + await build({ + absWorkingDir: frontendRoot, + entryPoints: ['src/index.ts'], + outfile: path.join(distRoot, 'portal_app.js'), + bundle: true, + format: 'iife', + platform: 'browser', + target: ['es2020'], + legalComments: 'none', + sourcemap: false, + minify: false, + logLevel: 'info', + }); + + await fs.writeFile( + manifestPath, + JSON.stringify( + { + source_hash: sourceHash, + build_inputs: buildInputs, + }, + null, + 2, + ) + '\n', + 'utf8', + ); + }, + { description: 'Pulse Account frontend build' }, +); diff --git a/internal/cloudcp/portal/frontend/package-lock.json b/internal/cloudcp/portal/frontend/package-lock.json new file mode 100644 index 000000000..827049cfe --- /dev/null +++ b/internal/cloudcp/portal/frontend/package-lock.json @@ -0,0 +1,497 @@ +{ + "name": "pulse-account-frontend", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pulse-account-frontend", + "devDependencies": { + "esbuild": "^0.25.12" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + } + } +} diff --git a/internal/cloudcp/portal/frontend/package.json b/internal/cloudcp/portal/frontend/package.json new file mode 100644 index 000000000..94e465f7c --- /dev/null +++ b/internal/cloudcp/portal/frontend/package.json @@ -0,0 +1,11 @@ +{ + "name": "pulse-account-frontend", + "private": true, + "type": "module", + "scripts": { + "build": "node build.mjs" + }, + "devDependencies": { + "esbuild": "^0.25.12" + } +} diff --git a/internal/cloudcp/portal/frontend/src/index.ts b/internal/cloudcp/portal/frontend/src/index.ts new file mode 100644 index 000000000..0fe4b2ff6 --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/index.ts @@ -0,0 +1,3 @@ +import './styles.css'; +import './shell'; +import './services'; diff --git a/internal/cloudcp/portal/frontend/src/services.ts b/internal/cloudcp/portal/frontend/src/services.ts new file mode 100644 index 000000000..ee38c0eec --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/services.ts @@ -0,0 +1,734 @@ +(function() { + var runtime = window.PulseAccountPortal || {}; + var serviceState = { + openPanelID: '', + flows: { + manage: newVerificationFlowState(), + retrieve: newVerificationFlowState(), + export: newVerificationFlowState(), + delete: newVerificationFlowState(), + }, + refund: { + emailValue: '', + tokenValue: '', + submitting: false, + status: emptyStatus(), + }, + }; + + function newVerificationFlowState() { + return { + pendingEmail: '', + requesting: false, + confirming: false, + step2Visible: false, + status: emptyStatus(), + result: null, + emailValue: '', + codeValue: '', + checkboxChecked: false, + }; + } + + function emptyStatus() { + return { + visible: false, + message: '', + error: false, + }; + } + + function getCommercialAPIBaseURL() { + return runtime.getCommercialAPIBaseURL ? runtime.getCommercialAPIBaseURL() : ''; + } + + function getElement(id) { + return document.getElementById(id); + } + + 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', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + } + + function setFlowStatus(flowID, message, isError) { + serviceState.flows[flowID].status = { + visible: true, + message: message, + error: !!isError, + }; + } + + function clearFlowStatus(flowID) { + serviceState.flows[flowID].status = emptyStatus(); + } + + function setRefundStatus(message, isError) { + serviceState.refund.status = { + visible: true, + message: message, + error: !!isError, + }; + } + + 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); + } + } + + function renderFlow(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var flowState = serviceState.flows[flowID]; + if (flow.renderPanel) { + flow.renderPanel(flowState); + } + renderButton(flow.requestButtonID, flowState.requesting, flowState.requesting ? flow.requestPendingLabel : flow.requestLabel); + renderButton(flow.confirmButtonID, flowState.confirming, flowState.confirming ? flow.confirmPendingLabel : flow.confirmLabel); + renderStatus(flow.statusID, flowState.status); + if (flow.step2ID) { + setVisible(flow.step2ID, flowState.step2Visible); + } + if (flow.renderResult) { + flow.renderResult(flowState.result); + } + } + + function renderAllFlows() { + renderFlow('manage'); + renderFlow('retrieve'); + renderFlow('export'); + renderFlow('delete'); + renderRefund(); + } + + function renderRefund() { + renderRefundPanel(); + 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 = runtime.getBootstrap ? (runtime.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; + var previous = serviceState.flows[flowID]; + serviceState.flows[flowID] = newVerificationFlowState(); + serviceState.flows[flowID].emailValue = previous.emailValue; + if (flow.codeInputID) { + setValue(flow.codeInputID, ''); + } + } + + var verificationFlows = { + manage: { + requestPath: '/v1/manage/request', + confirmPath: '/v1/manage', + panelID: 'manage-service-panel', + emailInputID: 'manage-inline-email', + codeInputID: 'manage-inline-code', + requestButtonID: 'manage-inline-request', + confirmButtonID: 'manage-inline-confirm', + step2ID: 'manage-inline-step2', + statusID: 'manage-inline-status', + requestLabel: 'Send Verification Code', + requestPendingLabel: 'Sending...', + confirmLabel: 'Open Customer Portal', + confirmPendingLabel: 'Redirecting...', + requestSuccessMessage: 'Verification code sent. Check your email.', + resendSuccessMessage: 'New verification code sent.', + requestErrorMessage: 'Failed to send verification code', + confirmErrorMessage: 'Failed to open customer portal', + readEmailValue: function() { + return serviceState.flows.manage.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.manage.codeValue; + }, + onRequestStart: function() {}, + 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
' + + '
' + + '
'; + } + }, + retrieve: { + requestPath: '/v1/retrieve-license/request', + confirmPath: '/v1/retrieve-license', + panelID: 'retrieve-service-panel', + emailInputID: 'retrieve-inline-email', + codeInputID: 'retrieve-inline-code', + requestButtonID: 'retrieve-inline-request', + confirmButtonID: 'retrieve-inline-confirm', + step2ID: 'retrieve-inline-step2', + statusID: 'retrieve-inline-status', + requestLabel: 'Send Verification Code', + requestPendingLabel: 'Sending...', + confirmLabel: 'Show License', + confirmPendingLabel: 'Loading...', + requestSuccessMessage: 'Verification code sent. Check your email.', + resendSuccessMessage: 'New verification code sent.', + requestErrorMessage: 'Failed to send verification code', + confirmErrorMessage: 'Failed to retrieve license', + readEmailValue: function() { + return serviceState.flows.retrieve.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.retrieve.codeValue; + }, + onRequestStart: function() { + serviceState.flows.retrieve.result = null; + }, + onConfirmSuccess: function(data) { + serviceState.flows.retrieve.result = data.license; + 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; + 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; + } + }, + export: { + requestPath: '/v1/gdpr/request-export', + confirmPath: '/v1/gdpr/export', + panelID: 'data-service-panel', + emailInputID: 'data-export-email', + codeInputID: 'data-export-code', + requestButtonID: 'data-export-request', + confirmButtonID: 'data-export-confirm', + step2ID: 'data-export-step2', + statusID: 'data-export-status', + requestLabel: 'Send Verification Code', + requestPendingLabel: 'Sending...', + confirmLabel: 'Export My Data', + confirmPendingLabel: 'Exporting...', + requestSuccessMessage: 'Verification code sent. Check your email.', + resendSuccessMessage: 'New verification code sent.', + requestErrorMessage: 'Request failed', + confirmErrorMessage: 'Export failed', + readEmailValue: function() { + return serviceState.flows.export.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.export.codeValue; + }, + onRequestStart: function() { + serviceState.flows.export.result = null; + }, + onConfirmSuccess: function(data) { + serviceState.flows.export.result = data; + serviceState.flows.export.codeValue = ''; + setFlowStatus('export', 'Data export retrieved successfully.', false); + 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) : ''); + } + }, + delete: { + requestPath: '/v1/gdpr/request-delete', + confirmPath: '/v1/gdpr/confirm-delete', + panelID: 'data-service-panel', + emailInputID: 'data-delete-email', + codeInputID: 'data-delete-code', + requestButtonID: 'data-delete-request', + confirmButtonID: 'data-delete-confirm', + step2ID: 'data-delete-step2', + statusID: 'data-delete-status', + requestLabel: 'Send Verification Code', + requestPendingLabel: 'Sending...', + confirmLabel: 'Delete My Data', + confirmPendingLabel: 'Deleting...', + requestSuccessMessage: 'Verification code sent. Check your email.', + resendSuccessMessage: 'New verification code sent.', + requestErrorMessage: 'Request failed', + confirmErrorMessage: 'Deletion failed', + readEmailValue: function() { + return serviceState.flows.delete.emailValue; + }, + readCodeValue: function() { + return serviceState.flows.delete.codeValue; + }, + beforeConfirm: function() { + if (!getElement('data-delete-confirm-check').checked) { + setFlowStatus('delete', 'You must confirm that you understand this action is permanent.', true); + renderFlow('delete'); + return false; + } + return true; + }, + onConfirmSuccess: function(data) { + getElement('data-delete-confirm-check').checked = false; + 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
' + + '
' + + '
'; + } + } + }; + + async function requestVerificationCode(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = flow.readEmailValue ? flow.readEmailValue() : readValue(flow.emailInputID); + if (!email) { + focusElement(flow.emailInputID); + return; + } + flow.onRequestStart(); + serviceState.flows[flowID].requesting = true; + clearFlowStatus(flowID); + renderFlow(flowID); + try { + var res = await serviceFetch(flow.requestPath, { email: email }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.requestErrorMessage); + serviceState.flows[flowID].pendingEmail = email; + serviceState.flows[flowID].step2Visible = !!flow.step2ID; + setFlowStatus(flowID, flow.requestSuccessMessage, false); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } finally { + serviceState.flows[flowID].requesting = false; + renderFlow(flowID); + } + } + + async function resendVerificationCode(flowID, event) { + if (event) event.preventDefault(); + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = serviceState.flows[flowID].pendingEmail; + if (!email) return; + try { + var res = await serviceFetch(flow.requestPath, { email: email }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.requestErrorMessage); + setFlowStatus(flowID, flow.resendSuccessMessage, false); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } + renderFlow(flowID); + } + + async function confirmVerificationCode(flowID) { + var flow = verificationFlows[flowID]; + if (!flow) return; + var email = serviceState.flows[flowID].pendingEmail; + var code = flow.readCodeValue ? flow.readCodeValue() : readValue(flow.codeInputID); + if (!email || !code) return; + if (flow.beforeConfirm && flow.beforeConfirm() === false) { + return; + } + serviceState.flows[flowID].confirming = true; + renderFlow(flowID); + try { + var res = await serviceFetch(flow.confirmPath, { email: email, code: code }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || flow.confirmErrorMessage); + flow.onConfirmSuccess(data, email); + } catch (err) { + setFlowStatus(flowID, err.message, true); + } finally { + serviceState.flows[flowID].confirming = false; + renderFlow(flowID); + } + } + + async function copyRetrievedLicense() { + var token = serviceState.flows.retrieve.result ? serviceState.flows.retrieve.result.token : ''; + if (!token) return; + try { + await navigator.clipboard.writeText(token); + setFlowStatus('retrieve', 'License key copied to clipboard.', false); + } catch (_) { + setFlowStatus('retrieve', 'Failed to copy automatically. Please copy the key manually.', true); + } + renderFlow('retrieve'); + } + + async function submitRefund() { + var email = serviceState.refund.emailValue; + var token = serviceState.refund.tokenValue; + if (!email || !token) return; + if (!confirm('Are you sure? This will immediately revoke the license and request the refund.')) return; + serviceState.refund.submitting = true; + serviceState.refund.status = emptyStatus(); + renderRefund(); + try { + var res = await serviceFetch('/v1/self-refund', { email: email, token: token }); + var data = await res.json(); + if (!res.ok) throw new Error(data.error || 'Refund failed'); + serviceState.refund.tokenValue = ''; + setRefundStatus('Success! Your refund has been processed. Stripe will follow up by email.', false); + } catch (err) { + setRefundStatus(err.message, true); + } finally { + serviceState.refund.submitting = false; + renderRefund(); + } + } + + function syncServiceStateFromBootstrap() { + var bootstrap = runtime.getBootstrap ? (runtime.getBootstrap() || {}) : {}; + if (!bootstrap.authenticated) { + return; + } + if (!serviceState.flows.manage.emailValue) serviceState.flows.manage.emailValue = bootstrap.email || ''; + if (!serviceState.flows.retrieve.emailValue) serviceState.flows.retrieve.emailValue = bootstrap.email || ''; + if (!serviceState.flows.export.emailValue) serviceState.flows.export.emailValue = bootstrap.email || ''; + if (!serviceState.flows.delete.emailValue) serviceState.flows.delete.emailValue = bootstrap.email || ''; + if (!serviceState.refund.emailValue) serviceState.refund.emailValue = bootstrap.email || ''; + } + + function renderServiceRuntime() { + syncServiceStateFromBootstrap(); + renderOpenPanels(); + renderAllFlows(); + } + + renderServiceRuntime(); + document.addEventListener('pulse-account-render', renderServiceRuntime); + + document.addEventListener('click', function(event) { + var target = 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 = event.target; + if (!target) return; + var inputKind = target.getAttribute('data-account-service-input') || ''; + switch (inputKind) { + case 'manage-email': + serviceState.flows.manage.emailValue = target.value; + return; + case 'manage-code': + serviceState.flows.manage.codeValue = target.value; + return; + case 'retrieve-email': + serviceState.flows.retrieve.emailValue = target.value; + return; + case 'retrieve-code': + serviceState.flows.retrieve.codeValue = target.value; + return; + case 'refund-email': + serviceState.refund.emailValue = target.value; + return; + case 'refund-token': + serviceState.refund.tokenValue = target.value; + return; + case 'data-export-email': + serviceState.flows.export.emailValue = target.value; + return; + case 'data-export-code': + serviceState.flows.export.codeValue = target.value; + return; + case 'data-delete-email': + serviceState.flows.delete.emailValue = target.value; + return; + case 'data-delete-code': + serviceState.flows.delete.codeValue = target.value; + return; + default: + return; + } + }); + + document.addEventListener('change', function(event) { + var target = event.target; + if (!target || target.id !== 'data-delete-confirm-check') return; + serviceState.flows.delete.checkboxChecked = !!target.checked; + }); +})(); diff --git a/internal/cloudcp/portal/frontend/src/shell.ts b/internal/cloudcp/portal/frontend/src/shell.ts new file mode 100644 index 000000000..f2fa13562 --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/shell.ts @@ -0,0 +1,827 @@ +var bootstrapEl = document.getElementById('pulse-account-bootstrap'); +var portalBootstrap = {}; +if (bootstrapEl) { + try { + portalBootstrap = JSON.parse(bootstrapEl.textContent || '{}'); + } catch (_) { + portalBootstrap = {}; + } +} + +var LICENSE_API_BASE = portalBootstrap.commercial_api_base_url || ''; +var PORTAL_PATH = portalBootstrap.portal_path || '/portal'; +var BOOTSTRAP_PATH = portalBootstrap.bootstrap_path || '/api/portal/bootstrap'; +var MAGIC_LINK_REQUEST_PATH = portalBootstrap.magic_link_request_path || '/api/public/magic-link/request'; +var SIGNUP_PATH = portalBootstrap.signup_path || '/signup'; +var LOGOUT_PATH = portalBootstrap.logout_path || '/auth/logout'; +var ACCOUNT_API_BASE_PATH = portalBootstrap.account_api_base_path || '/api/accounts'; +var PORTAL_API_BASE_PATH = portalBootstrap.portal_api_base_path || '/api/portal'; + +var loginState = { + emailValue: '', + sending: false, + success: false, + error: '', +}; + +function escapeHTML(value) { + return String(value || '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +function escapeAttr(value) { + return escapeHTML(value); +} + +function formatWorkspaceDate(value) { + if (!value) return ''; + var date = new Date(value); + if (Number.isNaN(date.getTime())) return ''; + return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' }); +} + +function roleBadgeHTML(role) { + if (role === 'owner') return 'Owner'; + if (role === 'admin') return 'Admin'; + if (role === 'tech') return 'Tech'; + return ''; +} + +function anonymousBootstrap() { + return { + authenticated: false, + email: '', + public_site_url: portalBootstrap.public_site_url || 'https://pulserelay.pro', + support_email: portalBootstrap.support_email || 'support@pulserelay.pro', + commercial_api_base_url: LICENSE_API_BASE, + portal_path: PORTAL_PATH, + bootstrap_path: BOOTSTRAP_PATH, + magic_link_request_path: MAGIC_LINK_REQUEST_PATH, + signup_path: SIGNUP_PATH, + logout_path: LOGOUT_PATH, + account_api_base_path: ACCOUNT_API_BASE_PATH, + portal_api_base_path: PORTAL_API_BASE_PATH, + accounts: [], + }; +} + +function renderHeader() { + var userInfo = document.getElementById('portal-user-info'); + if (!userInfo) return; + if (portalBootstrap.authenticated) { + userInfo.innerHTML = + '' + escapeHTML(portalBootstrap.email || '') + '' + + ''; + return; + } + userInfo.innerHTML = + 'Create account'; +} + +function renderWorkspaceCard(account, workspace) { + var state = String(workspace.state || ''); + var safeState = escapeHTML(state); + var createdLabel = formatWorkspaceDate(workspace.created_at); + var openAction = ''; + if (state === 'active') { + openAction = + '
' + + '' + + '
'; + } else { + openAction = '' + safeState + ''; + } + + var manageAction = ''; + if (account.can_manage && (state === 'active' || state === 'suspended' || state === 'failed')) { + manageAction = + ''; + } + + var createdMeta = createdLabel ? 'Created ' + escapeHTML(createdLabel) + '' : ''; + return ( + '
' + + '
' + + '' + escapeHTML(workspace.display_name) + '' + + '
' + + (workspace.healthy + ? 'Healthy' + : 'Checking') + + '' + safeState + '' + + createdMeta + + '
' + + '
' + + '
' + + openAction + + manageAction + + '
' + + '
' + ); +} + +function renderAccountSection(account) { + var workspaces = Array.isArray(account.workspaces) ? account.workspaces : []; + var workspaceHTML = ''; + if (workspaces.length === 0) { + workspaceHTML = '

No workspaces yet. Create one to get started.

'; + } else { + workspaceHTML = + '
' + + workspaces.map(function(workspace) { + return renderWorkspaceCard(account, workspace); + }).join('') + + '
'; + } + + var actions = ''; + var teamSection = ''; + var addWorkspaceForm = ''; + if (account.can_manage) { + actions = + '
' + + (account.kind === 'msp' + ? '' + : '') + + (account.has_billing + ? '' + : '') + + '' + + '
'; + + teamSection = + '
' + + '

Team members

' + + '' + + '' + + '' + + '' + + '' + + '
EmailRole
Loading…
' + + '
' + + '
' + + '
' + + '' + + '
' + + '
'; + + if (account.kind === 'msp') { + addWorkspaceForm = + '
' + + '' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
'; + } + } + + return ( + '
' + + '
' + + '

' + escapeHTML(account.name) + '

' + + '' + escapeHTML(account.kind_label) + '' + + roleBadgeHTML(account.role) + + '
' + + workspaceHTML + + actions + + teamSection + + addWorkspaceForm + + '
' + ); +} + +function renderAccounts(accounts) { + var root = document.getElementById('accounts-root'); + if (!root) return; + var safeAccounts = Array.isArray(accounts) ? accounts : []; + if (safeAccounts.length === 0) { + root.innerHTML = + '
' + + '

No workspaces found. If you just signed up, check your email for setup instructions.

' + + '

Need help? Contact ' + + escapeHTML(portalBootstrap.support_email || '') + + '

' + + '
'; + return; + } + root.innerHTML = safeAccounts.map(renderAccountSection).join(''); +} + +function renderAuthenticatedPortal() { + return ( + '
' + + '

Pulse Account

' + + '

Manage Cloud workspaces, MSP access, and self-hosted commercial account services from one account surface. Hosted workspace lifecycle lives here today, and the self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell instead of staying fragmented across public utility pages.

' + + '
' + + '
' + + '
' + + '
' + + '

Other account services

' + + '
Self-hosted commercial account actions now live here. The public utility pages remain as compatibility entry points, not the primary account surface.
' + + '
' + + '
' + + '' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '

Data and privacy

' + + '

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

' + + '
' + + '
' + + '
Payment-card data stays with Stripe. For Stripe deletion support, contact ' + + escapeHTML(portalBootstrap.support_email || '') + + '.
' + + '
' + + '
' + ); +} + +function renderSignedOutPortal() { + var statusHTML = ''; + if (loginState.error) { + statusHTML = '
' + escapeHTML(loginState.error) + '
'; + } else if (loginState.success) { + statusHTML = + '
' + + 'Magic link sent. Check your inbox and click the link to sign in.' + + '

Don\'t see it? Send a new link.' + + '
'; + } + return ( + '
' + + '

Pulse Account

' + + '

Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.

' + + '
' + + '
' + + '
' + + '

Sign in

' + + '

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + 'Create an account' + + '
' + + statusHTML + + '
' + + '
' + ); +} + +function dispatchPortalRender() { + document.dispatchEvent(new CustomEvent('pulse-account-render')); +} + +function renderPortalApp() { + renderHeader(); + var root = document.getElementById('portal-app-root'); + if (!root) return; + root.innerHTML = portalBootstrap.authenticated ? renderAuthenticatedPortal() : renderSignedOutPortal(); + if (portalBootstrap.authenticated) { + renderAccounts(portalBootstrap.accounts || []); + } + dispatchPortalRender(); +} + +function applyBootstrap(data) { + portalBootstrap = data || anonymousBootstrap(); + LICENSE_API_BASE = portalBootstrap.commercial_api_base_url || LICENSE_API_BASE; + PORTAL_PATH = portalBootstrap.portal_path || PORTAL_PATH; + BOOTSTRAP_PATH = portalBootstrap.bootstrap_path || BOOTSTRAP_PATH; + MAGIC_LINK_REQUEST_PATH = portalBootstrap.magic_link_request_path || MAGIC_LINK_REQUEST_PATH; + SIGNUP_PATH = portalBootstrap.signup_path || SIGNUP_PATH; + LOGOUT_PATH = portalBootstrap.logout_path || LOGOUT_PATH; + ACCOUNT_API_BASE_PATH = portalBootstrap.account_api_base_path || ACCOUNT_API_BASE_PATH; + PORTAL_API_BASE_PATH = portalBootstrap.portal_api_base_path || PORTAL_API_BASE_PATH; + if (!portalBootstrap.authenticated && !loginState.emailValue) { + loginState.emailValue = portalBootstrap.email || ''; + } + renderPortalApp(); +} + +async function refreshBootstrap() { + if (!BOOTSTRAP_PATH) return false; + try { + var response = await fetch(BOOTSTRAP_PATH, { + headers: { 'Accept': 'application/json' } + }); + if (response.status === 401) { + applyBootstrap(anonymousBootstrap()); + return true; + } + if (!response.ok) return false; + var data = await response.json(); + applyBootstrap(data); + return true; + } catch (_) {} + return false; +} + +function showToast(msg, isError) { + var t = document.getElementById('toast'); + if (!t) return; + t.textContent = msg; + t.className = 'toast visible' + (isError ? ' error' : ''); + clearTimeout(t._timer); + t._timer = setTimeout(function() { t.className = 'toast'; }, 4000); +} + +function resetLoginState(options) { + loginState.sending = false; + loginState.error = ''; + loginState.success = false; + if (options && options.keepEmail) return; + loginState.emailValue = ''; +} + +async function sendMagicLink() { + var email = String(loginState.emailValue || '').trim(); + if (!email) { + var input = document.getElementById('portal-login-email'); + if (input) input.focus(); + return; + } + loginState.sending = true; + loginState.error = ''; + loginState.success = false; + renderPortalApp(); + try { + var response = await fetch(MAGIC_LINK_REQUEST_PATH, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email }) + }); + if (response.ok || response.status === 404) { + loginState.sending = false; + loginState.success = true; + renderPortalApp(); + return; + } + if (response.status === 429) { + loginState.error = 'Too many requests. Please wait a moment and try again.'; + } else { + loginState.error = 'Something went wrong. Please try again.'; + } + } catch (_) { + loginState.error = 'Network error. Please check your connection and try again.'; + } + loginState.sending = false; + renderPortalApp(); +} + +window.PulseAccountPortal = { + getBootstrap: function() { + return portalBootstrap; + }, + getCommercialAPIBaseURL: function() { + return LICENSE_API_BASE; + }, + getPortalPath: function() { + return PORTAL_PATH; + }, + getAccountAPIBasePath: function() { + return ACCOUNT_API_BASE_PATH; + }, + getPortalAPIBasePath: function() { + return PORTAL_API_BASE_PATH; + }, + refreshBootstrap: refreshBootstrap, + showToast: showToast, +}; + +document.addEventListener('click', function(event) { + var portalActionEl = event.target.closest('[data-portal-action]'); + if (portalActionEl) { + var portalAction = portalActionEl.getAttribute('data-portal-action') || ''; + switch (portalAction) { + case 'send-magic-link': + event.preventDefault(); + sendMagicLink(); + return; + case 'resend-magic-link': + event.preventDefault(); + loginState.success = false; + loginState.error = ''; + renderPortalApp(); + sendMagicLink(); + return; + default: + break; + } + } + + var logoutBtn = event.target.closest('#logout-btn'); + if (logoutBtn) { + event.preventDefault(); + logoutBtn.disabled = true; + logoutBtn.textContent = 'Signing out…'; + (async function() { + try { + await fetch(LOGOUT_PATH, { method: 'POST' }); + } catch (_) {} + window.location.href = PORTAL_PATH; + })(); + return; + } + + var actionEl = event.target.closest('[data-action]'); + if (!actionEl) return; + var action = actionEl.getAttribute('data-action') || ''; + var accountID = actionEl.getAttribute('data-account-id') || ''; + + switch (action) { + case 'toggle-add-workspace': + event.preventDefault(); + toggleAddWorkspace(accountID); + return; + case 'open-billing': + event.preventDefault(); + openBilling(accountID); + return; + case 'toggle-team': + event.preventDefault(); + toggleTeam(accountID); + return; + case 'invite-member': + event.preventDefault(); + inviteMember(accountID); + return; + case 'create-workspace': + event.preventDefault(); + createWorkspace(accountID); + return; + case 'workspace-manage': + event.preventDefault(); + suspendOrDelete( + event, + accountID, + actionEl.getAttribute('data-workspace-id') || '', + actionEl.getAttribute('data-workspace-state') || '', + actionEl.getAttribute('data-workspace-name') || '' + ); + return; + case 'remove-member': + event.preventDefault(); + removeMember( + accountID, + actionEl.getAttribute('data-user-id') || '', + actionEl.getAttribute('data-member-email') || '' + ); + return; + default: + return; + } +}); + +document.addEventListener('change', function(event) { + var target = event.target; + if (!target || target.getAttribute('data-action') !== 'change-role') return; + changeRole( + target.getAttribute('data-account-id') || '', + target.getAttribute('data-user-id') || '', + target.value + ); +}); + +document.addEventListener('input', function(event) { + var target = event.target; + if (!target) return; + if (target.getAttribute('data-portal-input') === 'login-email') { + loginState.emailValue = target.value; + } +}); + +function toggleAddWorkspace(accountID) { + var form = document.getElementById('add-ws-form-' + accountID); + if (!form) return; + var visible = form.classList.contains('visible'); + form.classList.toggle('visible', !visible); + if (!visible) { + var input = document.getElementById('ws-name-' + accountID); + if (input) input.focus(); + } +} + +async function createWorkspace(accountID) { + var nameEl = document.getElementById('ws-name-' + accountID); + if (!nameEl) return; + var name = nameEl.value.trim(); + if (!name) { nameEl.focus(); return; } + var spinner = document.getElementById('ws-spinner-' + accountID); + if (spinner) spinner.style.display = 'block'; + try { + var resp = await fetch(ACCOUNT_API_BASE_PATH + '/' + accountID + '/tenants', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ display_name: name }) + }); + if (!resp.ok) { + var err = await resp.json().catch(function() { return {}; }); + showToast((err && err.error) || 'Failed to create workspace', true); + return; + } + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return; + } + showToast('Workspace created!'); + } catch (_) { + showToast('Network error. Please try again.', true); + } finally { + if (spinner) spinner.style.display = 'none'; + } +} + +async function suspendOrDelete(evt, accountID, tenantID, state, name) { + evt.stopPropagation(); + var action = state === 'active' ? 'Suspend' : 'Delete'; + if (!confirm(action + ' workspace "' + name + '"?')) return; + var method = state === 'active' ? 'PATCH' : 'DELETE'; + var body = state === 'active' ? JSON.stringify({ state: 'suspended' }) : undefined; + try { + var response = await fetch(ACCOUNT_API_BASE_PATH + '/' + accountID + '/tenants/' + tenantID, { + method: method, + headers: body ? { 'Content-Type': 'application/json' } : {}, + body: body + }); + if (!response.ok) { + showToast('Failed to ' + action.toLowerCase() + ' workspace.', true); + return; + } + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return; + } + showToast(action + 'd workspace.'); + } catch (_) { + showToast('Network error.', true); + } +} + +async function openBilling(accountID) { + try { + var r = await fetch(PORTAL_API_BASE_PATH + '/billing?account_id=' + encodeURIComponent(accountID), { method: 'POST' }); + if (!r.ok) { + var err = await r.json().catch(function() { return {}; }); + showToast((err && err.error) || 'Failed to open billing portal.', true); + return; + } + var data = await r.json(); + if (data && data.url) { + window.location.href = data.url; + } else { + showToast('Failed to open billing portal.', true); + } + } catch (_) { + showToast('Network error.', true); + } +} + +function toggleTeam(accountID) { + var section = document.getElementById('team-section-' + accountID); + if (!section) return; + var visible = section.classList.contains('visible'); + section.classList.toggle('visible', !visible); + if (!visible) loadTeam(accountID); +} + +function setTbodyMessage(tbody, msg, isError) { + tbody.textContent = ''; + var tr = document.createElement('tr'); + var td = document.createElement('td'); + td.setAttribute('colspan', '3'); + td.style.cssText = 'text-align:center;padding:16px;color:' + (isError ? '#991b1b' : '#94a3b8'); + td.textContent = msg; + tr.appendChild(td); + tbody.appendChild(tr); +} + +async function loadTeam(accountID) { + var tbody = document.getElementById('team-list-' + accountID); + var section = document.getElementById('team-section-' + accountID); + if (!tbody || !section) return; + var actorRole = section.getAttribute('data-actor-role') || ''; + var isOwner = actorRole === 'owner'; + setTbodyMessage(tbody, 'Loading…', false); + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + '/' + encodeURIComponent(accountID) + '/members'); + if (!r.ok) { setTbodyMessage(tbody, 'Failed to load team.', true); return; } + var members = await r.json(); + if (!members || members.length === 0) { + setTbodyMessage(tbody, 'No team members.', false); + return; + } + var allRoles = ['owner', 'admin', 'tech', 'read_only']; + var nonOwnerRoles = ['admin', 'tech', 'read_only']; + tbody.textContent = ''; + for (var i = 0; i < members.length; i++) { + (function(m) { + var tr = document.createElement('tr'); + var tdEmail = document.createElement('td'); + tdEmail.textContent = m.email; + tr.appendChild(tdEmail); + var tdRole = document.createElement('td'); + if (m.role === 'owner' && !isOwner) { + tdRole.textContent = 'owner'; + } else { + var sel = document.createElement('select'); + var roles = isOwner ? allRoles : nonOwnerRoles; + for (var j = 0; j < roles.length; j++) { + var opt = document.createElement('option'); + opt.value = roles[j]; + opt.textContent = roles[j].replace('_', ' '); + if (m.role === roles[j]) opt.selected = true; + sel.appendChild(opt); + } + sel.setAttribute('data-action', 'change-role'); + sel.setAttribute('data-account-id', accountID); + sel.setAttribute('data-user-id', m.user_id); + tdRole.appendChild(sel); + } + tr.appendChild(tdRole); + var tdAction = document.createElement('td'); + if (!(m.role === 'owner' && !isOwner)) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn-remove'; + btn.textContent = 'Remove'; + btn.setAttribute('data-action', 'remove-member'); + btn.setAttribute('data-account-id', accountID); + btn.setAttribute('data-user-id', m.user_id); + btn.setAttribute('data-member-email', m.email); + tdAction.appendChild(btn); + } + tr.appendChild(tdAction); + tbody.appendChild(tr); + })(members[i]); + } + } catch (_) { + setTbodyMessage(tbody, 'Network error.', true); + } +} + +async function refreshAccountTeamSection(accountID) { + if (!await refreshBootstrap()) { + window.location.href = PORTAL_PATH; + return false; + } + var section = document.getElementById('team-section-' + accountID); + if (!section) { + return true; + } + section.classList.add('visible'); + await loadTeam(accountID); + return true; +} + +async function inviteMember(accountID) { + var emailEl = document.getElementById('invite-email-' + accountID); + var roleEl = document.getElementById('invite-role-' + accountID); + if (!emailEl || !roleEl) return; + var email = emailEl.value.trim(); + if (!email) { emailEl.focus(); return; } + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + '/' + encodeURIComponent(accountID) + '/members', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email: email, role: roleEl.value }) + }); + if (r.status === 409) { showToast('Member already exists.', true); return; } + if (!r.ok) { + var err = await r.text(); + showToast(err || 'Failed to invite member.', true); + return; + } + emailEl.value = ''; + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast('Member invited!'); + } catch (_) { + showToast('Network error.', true); + } +} + +async function changeRole(accountID, userID, newRole) { + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + '/' + encodeURIComponent(accountID) + '/members/' + encodeURIComponent(userID), { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ role: newRole }) + }); + if (r.status === 409) { showToast('Cannot demote last owner.', true); loadTeam(accountID); return; } + if (!r.ok) { showToast('Failed to update role.', true); loadTeam(accountID); return; } + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast('Role updated.'); + } catch (_) { + showToast('Network error.', true); + loadTeam(accountID); + } +} + +async function removeMember(accountID, userID, email) { + if (!confirm('Remove ' + email + ' from this account?')) return; + try { + var r = await fetch(ACCOUNT_API_BASE_PATH + '/' + encodeURIComponent(accountID) + '/members/' + encodeURIComponent(userID), { + method: 'DELETE' + }); + if (r.status === 409) { showToast('Cannot remove last owner.', true); return; } + if (!r.ok) { showToast('Failed to remove member.', true); return; } + if (!await refreshAccountTeamSection(accountID)) { + return; + } + showToast('Member removed.'); + } catch (_) { + showToast('Network error.', true); + } +} + +loginState.emailValue = portalBootstrap.email || ''; +applyBootstrap(portalBootstrap); +if (portalBootstrap.authenticated) { + refreshBootstrap(); +} diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css new file mode 100644 index 000000000..c923420f8 --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -0,0 +1,109 @@ + :root { color-scheme: light; } + * { box-sizing: border-box; } + body { margin: 0; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f1f5f9; color: #0f172a; } + header { background: #1e293b; color: #f8fafc; padding: 12px 24px; display: flex; align-items: center; justify-content: space-between; } + header .brand { font-weight: 700; font-size: 18px; letter-spacing: -0.3px; } + header .user-info { display: flex; align-items: center; gap: 16px; font-size: 13px; color: #94a3b8; } + header .logout-btn { background: none; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px; } + header .logout-btn:hover { border-color: #94a3b8; color: #f8fafc; } + .main { max-width: 860px; margin: 32px auto; padding: 0 16px 48px; } + .account-section { margin-bottom: 40px; } + .account-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; } + .account-header h2 { margin: 0; font-size: 20px; font-weight: 700; } + .badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.5px; } + .badge-msp { background: #dbeafe; color: #1e40af; } + .badge-cloud { background: #dcfce7; color: #166534; } + .badge-individual { background: #dcfce7; color: #166534; } + .badge-healthy { background: #dcfce7; color: #166534; } + .badge-unhealthy { background: #fee2e2; color: #991b1b; } + .badge-active { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } + .badge-suspended { background: #fef9c3; color: #854d0e; border: 1px solid #fef08a; } + .badge-failed { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; } + .badge-provisioning { background: #eff6ff; color: #1d4ed8; border: 1px solid #bfdbfe; } + .badge-canceled { background: #f1f5f9; color: #64748b; border: 1px solid #e2e8f0; } + .badge-deleting { background: #fef3c7; color: #92400e; border: 1px solid #fde68a; } + .workspace-list { display: flex; flex-direction: column; gap: 10px; } + .workspace-card { background: #fff; border: 1px solid #e2e8f0; border-radius: 10px; padding: 14px 18px; display: flex; align-items: center; justify-content: space-between; gap: 12px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } + .workspace-card:hover { border-color: #cbd5e1; } + .ws-info { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; } + .ws-name { font-weight: 600; font-size: 15px; } + .ws-meta { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } + .ws-created { font-size: 11px; color: #94a3b8; } + .ws-actions { display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .btn-primary { background: #1d4ed8; color: #fff; border: 0; border-radius: 8px; padding: 8px 18px; font-size: 14px; font-weight: 600; cursor: pointer; text-decoration: none; display: inline-block; } + .btn-primary:hover { background: #1e40af; } + .btn-secondary { background: #fff; color: #334155; border: 1px solid #cbd5e1; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; } + .btn-secondary:hover { background: #f8fafc; border-color: #94a3b8; } + .btn-danger { background: #fff; color: #dc2626; border: 1px solid #fca5a5; border-radius: 8px; padding: 7px 14px; font-size: 13px; cursor: pointer; } + .btn-danger:hover { background: #fef2f2; } + .account-actions { margin-top: 14px; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; } + .add-workspace-form { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } + .add-workspace-form.visible { display: block; } + .add-workspace-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } + .add-workspace-form input { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 10px; } + .add-workspace-form .form-actions { display: flex; gap: 8px; } + .team-section { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } + .team-section.visible { display: block; } + .team-section h3 { margin: 0 0 12px; font-size: 15px; font-weight: 700; } + .team-table { width: 100%; border-collapse: collapse; font-size: 14px; } + .team-table th { text-align: left; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid #e2e8f0; } + .team-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } + .team-table select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 4px 8px; font-size: 13px; background: #fff; } + .team-table .btn-remove { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 13px; padding: 4px 8px; } + .team-table .btn-remove:hover { text-decoration: underline; } + .team-invite { margin-top: 12px; display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } + .team-invite label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 4px; } + .team-invite input { border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; min-width: 200px; } + .team-invite select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 8px 10px; font-size: 14px; background: #fff; } + .intro-card { background: linear-gradient(145deg, #fff, #f8fafc); border: 1px solid #dbe4f0; border-radius: 16px; padding: 22px 24px; margin-bottom: 24px; box-shadow: 0 10px 24px rgba(15,23,42,.05); } + .intro-card h1 { margin: 0 0 8px; font-size: 26px; line-height: 1.1; } + .intro-card p { margin: 0; color: #475569; font-size: 15px; line-height: 1.6; max-width: 760px; } + .service-section { margin-top: 40px; } + .service-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; } + .service-header h2 { margin: 0; font-size: 20px; font-weight: 700; } + .service-note { font-size: 13px; color: #64748b; max-width: 680px; } + .service-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; } + .service-card { display: block; text-decoration: none; color: inherit; background: #fff; border: 1px solid #e2e8f0; border-radius: 12px; padding: 16px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } + .service-card:hover { border-color: #93c5fd; box-shadow: 0 6px 18px rgba(29,78,216,.08); } + .service-card-button { width: 100%; text-align: left; border: 1px solid #e2e8f0; cursor: pointer; font: inherit; } + .service-card h3 { margin: 0 0 6px; font-size: 16px; font-weight: 700; } + .service-card p { margin: 0; font-size: 14px; color: #64748b; line-height: 1.5; } + .service-panel { display: none; margin-top: 14px; background: #fff; border: 1px solid #dbe4f0; border-radius: 12px; padding: 18px; } + .service-panel.visible { display: block; } + .service-panel h3 { margin: 0 0 8px; font-size: 17px; } + .service-panel p { margin: 0 0 14px; font-size: 14px; color: #64748b; } + .service-panel label { display: block; margin-bottom: 6px; font-size: 13px; font-weight: 600; color: #475569; } + .service-panel input, .service-panel textarea { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 10px 12px; font: inherit; box-sizing: border-box; background: #fff; color: #0f172a; } + .service-panel input:focus, .service-panel textarea:focus { outline: 2px solid #93c5fd; border-color: #1d4ed8; } + .service-panel textarea { min-height: 120px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; } + .service-panel .form-group { margin-bottom: 14px; } + .service-panel .form-actions { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } + .service-panel .helper-text { margin-top: 10px; font-size: 13px; color: #64748b; } + .service-panel .helper-text a { color: #1d4ed8; } + .service-panel .result-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px; margin-top: 14px; } + .service-panel .result-meta-label { font-size: 12px; color: #64748b; margin-bottom: 4px; } + .service-panel .result-meta-value { font-size: 14px; color: #0f172a; word-break: break-word; } + .service-panel .subsection { margin-top: 18px; padding-top: 18px; border-top: 1px solid #e2e8f0; } + .service-panel .subsection:first-of-type { margin-top: 0; padding-top: 0; border-top: none; } + .service-panel .subsection h4 { margin: 0 0 8px; font-size: 15px; font-weight: 700; } + .service-panel .warning { background: #fef2f2; border: 1px solid #fecaca; color: #991b1b; border-radius: 8px; padding: 12px; font-size: 13px; line-height: 1.5; margin-bottom: 12px; } + .service-panel .checkbox-row { display: flex; align-items: flex-start; gap: 10px; margin: 12px 0; } + .service-panel .checkbox-row input[type="checkbox"] { width: 18px; height: 18px; margin-top: 2px; flex-shrink: 0; } + .service-panel .checkbox-row span { font-size: 13px; color: #475569; line-height: 1.5; } + .service-status { margin-top: 12px; padding: 10px 12px; border-radius: 8px; font-size: 13px; display: none; } + .service-status.visible { display: block; } + .service-status.error { background: #fef2f2; color: #991b1b; border: 1px solid #fecaca; } + .service-status.success { background: #f0fdf4; color: #166534; border: 1px solid #bbf7d0; } + .empty-state { background: #fff; border: 1px dashed #cbd5e1; border-radius: 10px; padding: 32px; text-align: center; color: #64748b; } + .empty-state p { margin: 0; font-size: 15px; } + .spinner { display: none; width: 18px; height: 18px; border: 2px solid #93c5fd; border-top-color: #1d4ed8; border-radius: 50%; animation: spin 0.6s linear infinite; } + @keyframes spin { to { transform: rotate(360deg); } } + .toast { position: fixed; bottom: 24px; right: 24px; background: #1e293b; color: #f8fafc; border-radius: 8px; padding: 12px 20px; font-size: 14px; display: none; z-index: 100; } + .toast.visible { display: block; animation: fadein 0.2s; } + @keyframes fadein { from { opacity: 0; transform: translateY(8px); } } + .toast.error { background: #991b1b; } + @media (max-width: 560px) { + .workspace-card { flex-direction: column; align-items: flex-start; } + .ws-actions { align-self: stretch; } + .ws-actions form, .ws-actions .btn-primary { width: 100%; text-align: center; } + } diff --git a/internal/cloudcp/portal/frontend_sync_test.go b/internal/cloudcp/portal/frontend_sync_test.go new file mode 100644 index 000000000..267f8a2b1 --- /dev/null +++ b/internal/cloudcp/portal/frontend_sync_test.go @@ -0,0 +1,55 @@ +package portal + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +type portalFrontendManifest struct { + SourceHash string `json:"source_hash"` + BuildInputs []string `json:"build_inputs"` +} + +func TestPulseAccountFrontendBundleStaysInSync(t *testing.T) { + manifestPath := filepath.Join("dist", "build_manifest.json") + manifestBytes, err := os.ReadFile(manifestPath) + if err != nil { + t.Fatalf("read portal frontend manifest: %v", err) + } + + var manifest portalFrontendManifest + if err := json.Unmarshal(manifestBytes, &manifest); err != nil { + t.Fatalf("decode portal frontend manifest: %v", err) + } + if manifest.SourceHash == "" { + t.Fatal("portal frontend manifest missing source_hash") + } + if len(manifest.BuildInputs) == 0 { + t.Fatal("portal frontend manifest missing build_inputs") + } + + hash := sha256.New() + for _, relativePath := range manifest.BuildInputs { + hash.Write([]byte(relativePath)) + hash.Write([]byte("\n")) + content, err := os.ReadFile(filepath.Join("frontend", relativePath)) + if err != nil { + t.Fatalf("read portal frontend source %s: %v", relativePath, err) + } + hash.Write(content) + hash.Write([]byte("\n")) + } + + actualHash := hex.EncodeToString(hash.Sum(nil)) + if actualHash != manifest.SourceHash { + t.Fatalf( + "portal frontend build drift detected; run `npm --prefix internal/cloudcp/portal/frontend run build`\nmanifest=%s\nactual=%s", + manifest.SourceHash, + actualHash, + ) + } +} diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index a3d45d8ea..77c114dd4 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -795,58 +795,6 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { fmt.Sprintf(`"portal_api_base_path":"%s"`, PortalAPIBasePath), `"email":"owner@example.com"`, `"accounts":[{"id":"a_test"`, - `renderAuthenticatedPortal()`, - `renderSignedOutPortal()`, - "Other account services", - `id="open-manage-service"`, - `id="open-retrieve-service"`, - `id="open-refund-service"`, - `id="open-data-service"`, - `data-account-service-action="open-service-panel"`, - `data-account-service-panel="manage-service-panel"`, - `data-account-service-focus="manage-inline-email"`, - `data-account-service-action="manage-inline-request"`, - `data-account-service-action="data-delete-confirm"`, - `id="manage-service-panel"`, - `id="manage-service-root"`, - `id="retrieve-service-panel"`, - `id="retrieve-service-root"`, - `id="refund-service-panel"`, - `id="refund-service-root"`, - `id="data-service-panel"`, - `id="data-export-root"`, - `id="data-delete-root"`, - `var verificationFlows = {`, - `var serviceState = {`, - `renderOpenPanels();`, - `renderAllFlows();`, - `data-account-service-input="manage-email"`, - `data-account-service-input="refund-email"`, - `data-account-service-input="data-export-email"`, - `data-account-service-input="data-delete-email"`, - `data-account-service-input="retrieve-email"`, - `data-account-service-input="retrieve-code"`, - `renderPanel: function(flowState) {`, - `requestVerificationCode('manage')`, - `confirmVerificationCode('retrieve')`, - `resendVerificationCode('export', event)`, - `/v1/manage/request`, - `/v1/retrieve-license/request`, - `/v1/self-refund`, - `/v1/gdpr/request-export`, - `/v1/gdpr/export`, - `/v1/gdpr/request-delete`, - `/v1/gdpr/confirm-delete`, - `if (!await refreshBootstrap())`, - `refreshAccountTeamSection(accountID)`, - `window.PulseAccountPortal = {`, - `document.addEventListener('pulse-account-render'`, - `document.addEventListener('click'`, - `document.addEventListener('change'`, - `data-action="open-billing"`, - `data-action="create-workspace"`, - `data-action="workspace-manage"`, - "commercial account actions now live here", } for _, needle := range mustContain { if !strings.Contains(html, needle) { @@ -865,8 +813,8 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { if strings.Contains(html, `onclick="`) { t.Errorf("expected portal shell interactions to be delegated through data-action attributes instead of inline onclick handlers") } - if strings.Contains(html, `assets/portal.js`) { - t.Errorf("expected portal runtime to be split into explicit embedded shell/services assets instead of the old monolithic asset") + if strings.Contains(html, `assets/portal_shell.js`) || strings.Contains(html, `assets/portal_services.js`) || strings.Contains(html, `assets/portal.css`) { + t.Errorf("expected portal runtime to load from the built dist bundle, not the old handwritten asset paths") } if strings.Contains(html, `await fetch('/auth/logout'`) { t.Errorf("expected portal paths to be renderer-owned, not hardcoded in the asset") diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index c0d29d29e..a9ca67fbe 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -35,11 +35,10 @@ type portalPageAccount struct { // portalPageData is passed to the portal HTML template. type portalPageData struct { - Nonce string - Styles template.CSS - ShellScript template.JS - ServicesScript template.JS - BootstrapJSON template.JS + Nonce string + Styles template.CSS + ShellScript template.JS + BootstrapJSON template.JS } const ( @@ -196,11 +195,10 @@ func renderPortalPage(w http.ResponseWriter, nonce string, bootstrapData Bootstr bootstrapJSON = template.JS(`{}`) } if err := portalPageTmpl.Execute(w, portalPageData{ - Nonce: nonce, - Styles: portalStyles, - ShellScript: portalShellScript, - ServicesScript: portalServicesScript, - BootstrapJSON: bootstrapJSON, + Nonce: nonce, + Styles: portalStyles, + ShellScript: portalShellScript, + BootstrapJSON: bootstrapJSON, }); err != nil { log.Error().Err(err).Msg("cloudcp.portal.page: render portal page") } diff --git a/internal/cloudcp/portal/page_templates.go b/internal/cloudcp/portal/page_templates.go index 81f274f03..bd835b1a9 100644 --- a/internal/cloudcp/portal/page_templates.go +++ b/internal/cloudcp/portal/page_templates.go @@ -6,14 +6,13 @@ import ( "io/fs" ) -//go:embed templates/portal.html assets/portal.css assets/portal_shell.js assets/portal_services.js +//go:embed templates/portal.html dist/portal_app.css dist/portal_app.js var portalTemplateFS embed.FS var ( - portalPageTmpl = template.Must(template.ParseFS(portalTemplateFS, "templates/portal.html")) - portalStyles = mustEmbeddedCSS("assets/portal.css") - portalShellScript = mustEmbeddedJS("assets/portal_shell.js") - portalServicesScript = mustEmbeddedJS("assets/portal_services.js") + portalPageTmpl = template.Must(template.ParseFS(portalTemplateFS, "templates/portal.html")) + portalStyles = mustEmbeddedCSS("dist/portal_app.css") + portalShellScript = mustEmbeddedJS("dist/portal_app.js") ) func mustEmbeddedCSS(path string) template.CSS { diff --git a/internal/cloudcp/portal/templates/portal.html b/internal/cloudcp/portal/templates/portal.html index 1b473fcd8..183550a36 100644 --- a/internal/cloudcp/portal/templates/portal.html +++ b/internal/cloudcp/portal/templates/portal.html @@ -21,6 +21,5 @@
-