diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 3683c41fa..03da68f07 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "cee75c53fdd6a5b26d949440b84ab98240517e04cd1e40aaefacc0cc9d20f20a", + "source_hash": "633edb73d1996633e9ccd36ec746a2ea4eb295989063af03743102265c9d3cab", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index b9598bb57..4b63bda62 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1,4 +1,43 @@ (() => { + // src/store.ts + function createAnonymousBootstrap(bootstrapDefaults2, overrides = {}) { + return { + authenticated: false, + email: "", + ...bootstrapDefaults2, + ...overrides, + accounts: normalizeAccounts(overrides.accounts) + }; + } + function normalizeBootstrap(bootstrapDefaults2, raw) { + return createAnonymousBootstrap(bootstrapDefaults2, raw || {}); + } + function createPortalStore(bootstrapDefaults2, initialBootstrap) { + var bootstrapState = normalizeBootstrap(bootstrapDefaults2, initialBootstrap); + var subscribers = /* @__PURE__ */ new Set(); + return { + getBootstrap: function() { + return bootstrapState; + }, + setBootstrap: function(nextBootstrap) { + bootstrapState = normalizeBootstrap(bootstrapDefaults2, nextBootstrap); + subscribers.forEach(function(listener) { + listener(); + }); + return bootstrapState; + }, + subscribe: function(listener) { + subscribers.add(listener); + return function() { + subscribers.delete(listener); + }; + } + }; + } + function normalizeAccounts(accounts) { + return Array.isArray(accounts) ? accounts : []; + } + // src/runtime.ts function readEmbeddedBootstrap() { const bootstrapEl = document.getElementById("pulse-account-bootstrap"); @@ -24,65 +63,7 @@ account_api_base_path: embeddedBootstrap.account_api_base_path || "/api/accounts", portal_api_base_path: embeddedBootstrap.portal_api_base_path || "/api/portal" }; - function normalizeAccounts(accounts) { - return Array.isArray(accounts) ? accounts : []; - } - function createAnonymousBootstrap(overrides = {}) { - return { - authenticated: false, - email: "", - ...bootstrapDefaults, - ...overrides, - accounts: normalizeAccounts(overrides.accounts) - }; - } - function normalizeBootstrap(raw) { - return createAnonymousBootstrap(raw || {}); - } - var bootstrapState = normalizeBootstrap(embeddedBootstrap); - var renderSubscribers = /* @__PURE__ */ new Set(); - function getBootstrap() { - return bootstrapState; - } - function setBootstrap(nextBootstrap) { - bootstrapState = normalizeBootstrap(nextBootstrap); - return bootstrapState; - } - function getCommercialAPIBaseURL() { - return bootstrapState.commercial_api_base_url; - } - function getPortalPath() { - return bootstrapState.portal_path; - } - function getBootstrapPath() { - return bootstrapState.bootstrap_path; - } - function getMagicLinkRequestPath() { - return bootstrapState.magic_link_request_path; - } - function getSignupPath() { - return bootstrapState.signup_path; - } - function getLogoutPath() { - return bootstrapState.logout_path; - } - function getAccountAPIBasePath() { - return bootstrapState.account_api_base_path; - } - function getPortalAPIBasePath() { - return bootstrapState.portal_api_base_path; - } - function notifyPortalRender() { - renderSubscribers.forEach((listener) => { - listener(); - }); - } - function subscribePortalRender(listener) { - renderSubscribers.add(listener); - return () => { - renderSubscribers.delete(listener); - }; - } + var portalStore = createPortalStore(bootstrapDefaults, embeddedBootstrap); // src/account_controller.ts function getElement(id) { @@ -724,61 +705,45 @@ } // src/shell.ts - var portalBootstrap = getBootstrap(); - var LICENSE_API_BASE = getCommercialAPIBaseURL(); - var PORTAL_PATH = getPortalPath(); - var BOOTSTRAP_PATH = getBootstrapPath(); - var MAGIC_LINK_REQUEST_PATH = getMagicLinkRequestPath(); - var SIGNUP_PATH = getSignupPath(); - var LOGOUT_PATH = getLogoutPath(); - var ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); - var PORTAL_API_BASE_PATH = getPortalAPIBasePath(); function renderHeader() { var userInfo = document.getElementById("portal-user-info"); if (!userInfo) return; + var portalBootstrap = portalStore.getBootstrap(); userInfo.innerHTML = renderHeaderHTML({ bootstrap: portalBootstrap, loginState: authController.getLoginState(), - signupPath: SIGNUP_PATH, - accountAPIBasePath: ACCOUNT_API_BASE_PATH + signupPath: portalBootstrap.signup_path, + accountAPIBasePath: portalBootstrap.account_api_base_path }); } function renderPortalApp() { renderHeader(); var root = document.getElementById("portal-app-root"); if (!root) return; + var portalBootstrap = portalStore.getBootstrap(); var context = { bootstrap: portalBootstrap, loginState: authController.getLoginState(), - signupPath: SIGNUP_PATH, - accountAPIBasePath: ACCOUNT_API_BASE_PATH + signupPath: portalBootstrap.signup_path, + accountAPIBasePath: portalBootstrap.account_api_base_path }; root.innerHTML = portalBootstrap.authenticated ? renderAuthenticatedPortalHTML(context) : renderSignedOutPortalHTML(context); - notifyPortalRender(); } function applyBootstrap(data) { - portalBootstrap = setBootstrap(data || createAnonymousBootstrap()); - LICENSE_API_BASE = getCommercialAPIBaseURL(); - PORTAL_PATH = getPortalPath(); - BOOTSTRAP_PATH = getBootstrapPath(); - MAGIC_LINK_REQUEST_PATH = getMagicLinkRequestPath(); - SIGNUP_PATH = getSignupPath(); - LOGOUT_PATH = getLogoutPath(); - ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); - PORTAL_API_BASE_PATH = getPortalAPIBasePath(); + var portalBootstrap = portalStore.setBootstrap(data || createAnonymousBootstrap(bootstrapDefaults)); if (!portalBootstrap.authenticated) { authController.syncBootstrapEmail(portalBootstrap.email || ""); } - renderPortalApp(); } async function refreshBootstrap() { - if (!BOOTSTRAP_PATH) return false; + var bootstrap = portalStore.getBootstrap(); + if (!bootstrap.bootstrap_path) return false; try { - var response = await fetch(BOOTSTRAP_PATH, { + var response = await fetch(bootstrap.bootstrap_path, { headers: { "Accept": "application/json" } }); if (response.status === 401) { - applyBootstrap(createAnonymousBootstrap()); + applyBootstrap(createAnonymousBootstrap(bootstrapDefaults)); return true; } if (!response.ok) return false; @@ -801,31 +766,34 @@ } var authController = installAuthController({ getMagicLinkRequestPath: function() { - return MAGIC_LINK_REQUEST_PATH; + return portalStore.getBootstrap().magic_link_request_path; }, getLogoutPath: function() { - return LOGOUT_PATH; + return portalStore.getBootstrap().logout_path; }, getPortalPath: function() { - return PORTAL_PATH; + return portalStore.getBootstrap().portal_path; }, renderPortal: renderPortalApp }); installAccountController({ getAccountAPIBasePath: function() { - return ACCOUNT_API_BASE_PATH; + return portalStore.getBootstrap().account_api_base_path; }, getPortalAPIBasePath: function() { - return PORTAL_API_BASE_PATH; + return portalStore.getBootstrap().portal_api_base_path; }, getPortalPath: function() { - return PORTAL_PATH; + return portalStore.getBootstrap().portal_path; }, refreshBootstrap, showToast }); - applyBootstrap(portalBootstrap); - if (portalBootstrap.authenticated) { + portalStore.subscribe(function() { + renderPortalApp(); + }); + applyBootstrap(portalStore.getBootstrap()); + if (portalStore.getBootstrap().authenticated) { refreshBootstrap(); } @@ -1008,11 +976,8 @@ // src/services.ts var serviceState = createPortalServiceState(); - function getCommercialAPIBaseURL2() { - return getCommercialAPIBaseURL(); - } function serviceFetch(path, body) { - return fetch(getCommercialAPIBaseURL2() + path, { + return fetch(portalStore.getBootstrap().commercial_api_base_url + path, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) @@ -1047,7 +1012,7 @@ renderRefund(); } function renderRefund() { - renderRefundPanel(serviceState.refund, getBootstrap()); + renderRefundPanel(serviceState.refund, portalStore.getBootstrap()); renderButton("refund-inline-submit", serviceState.refund.submitting, serviceState.refund.submitting ? "Processing..." : "Process Refund"); renderStatus("refund-inline-status", serviceState.refund.status); } @@ -1306,7 +1271,7 @@ } } function syncServiceStateFromBootstrap() { - var bootstrap = getBootstrap(); + var bootstrap = portalStore.getBootstrap(); if (!bootstrap.authenticated) { return; } @@ -1318,7 +1283,7 @@ renderAllFlows(); } renderServiceRuntime(); - subscribePortalRender(renderServiceRuntime); + portalStore.subscribe(renderServiceRuntime); installServicesController({ toggleServicePanel, focusElement, diff --git a/internal/cloudcp/portal/frontend/src/runtime.ts b/internal/cloudcp/portal/frontend/src/runtime.ts index 057f44e1a..aa8289722 100644 --- a/internal/cloudcp/portal/frontend/src/runtime.ts +++ b/internal/cloudcp/portal/frontend/src/runtime.ts @@ -1,3 +1,4 @@ +import { createPortalStore } from './store'; import type { PortalBootstrapData } from './types'; function readEmbeddedBootstrap(): Partial { @@ -14,7 +15,7 @@ function readEmbeddedBootstrap(): Partial { const embeddedBootstrap = readEmbeddedBootstrap(); -const bootstrapDefaults: Omit = { +export const bootstrapDefaults: Omit = { public_site_url: embeddedBootstrap.public_site_url || 'https://pulserelay.pro', support_email: embeddedBootstrap.support_email || 'support@pulserelay.pro', commercial_api_base_url: embeddedBootstrap.commercial_api_base_url || '', @@ -27,77 +28,4 @@ const bootstrapDefaults: Omit['accounts']): PortalBootstrapData['accounts'] { - return Array.isArray(accounts) ? accounts : []; -} - -export function createAnonymousBootstrap(overrides: Partial = {}): PortalBootstrapData { - return { - authenticated: false, - email: '', - ...bootstrapDefaults, - ...overrides, - accounts: normalizeAccounts(overrides.accounts), - }; -} - -export function normalizeBootstrap(raw: Partial | null | undefined): PortalBootstrapData { - return createAnonymousBootstrap(raw || {}); -} - -let bootstrapState: PortalBootstrapData = normalizeBootstrap(embeddedBootstrap); -const renderSubscribers = new Set<() => void>(); - -export function getBootstrap(): PortalBootstrapData { - return bootstrapState; -} - -export function setBootstrap(nextBootstrap: Partial | PortalBootstrapData): PortalBootstrapData { - bootstrapState = normalizeBootstrap(nextBootstrap); - return bootstrapState; -} - -export function getCommercialAPIBaseURL(): string { - return bootstrapState.commercial_api_base_url; -} - -export function getPortalPath(): string { - return bootstrapState.portal_path; -} - -export function getBootstrapPath(): string { - return bootstrapState.bootstrap_path; -} - -export function getMagicLinkRequestPath(): string { - return bootstrapState.magic_link_request_path; -} - -export function getSignupPath(): string { - return bootstrapState.signup_path; -} - -export function getLogoutPath(): string { - return bootstrapState.logout_path; -} - -export function getAccountAPIBasePath(): string { - return bootstrapState.account_api_base_path; -} - -export function getPortalAPIBasePath(): string { - return bootstrapState.portal_api_base_path; -} - -export function notifyPortalRender(): void { - renderSubscribers.forEach((listener) => { - listener(); - }); -} - -export function subscribePortalRender(listener: () => void): () => void { - renderSubscribers.add(listener); - return () => { - renderSubscribers.delete(listener); - }; -} +export const portalStore = createPortalStore(bootstrapDefaults, embeddedBootstrap); diff --git a/internal/cloudcp/portal/frontend/src/services.ts b/internal/cloudcp/portal/frontend/src/services.ts index 5ceb8e600..972b8e632 100644 --- a/internal/cloudcp/portal/frontend/src/services.ts +++ b/internal/cloudcp/portal/frontend/src/services.ts @@ -1,4 +1,4 @@ -import { getBootstrap, getCommercialAPIBaseURL as readCommercialAPIBaseURL, subscribePortalRender } from './runtime'; +import { portalStore } from './runtime'; import { installServicesController } from './services_controller'; import { clearFlowStatus, @@ -59,12 +59,8 @@ interface VerificationFlowDefinition { var serviceState = createPortalServiceState(); - function getCommercialAPIBaseURL() { - return readCommercialAPIBaseURL(); - } - function serviceFetch(path, body) { - return fetch(getCommercialAPIBaseURL() + path, { + return fetch(portalStore.getBootstrap().commercial_api_base_url + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) @@ -103,7 +99,7 @@ interface VerificationFlowDefinition { } function renderRefund() { - renderRefundPanel(serviceState.refund, getBootstrap()); + renderRefundPanel(serviceState.refund, portalStore.getBootstrap()); renderButton('refund-inline-submit', serviceState.refund.submitting, serviceState.refund.submitting ? 'Processing...' : 'Process Refund'); renderStatus('refund-inline-status', serviceState.refund.status); } @@ -369,7 +365,7 @@ interface VerificationFlowDefinition { } function syncServiceStateFromBootstrap() { - var bootstrap = getBootstrap(); + var bootstrap = portalStore.getBootstrap(); if (!bootstrap.authenticated) { return; } @@ -383,7 +379,7 @@ interface VerificationFlowDefinition { } renderServiceRuntime(); - subscribePortalRender(renderServiceRuntime); + portalStore.subscribe(renderServiceRuntime); installServicesController({ toggleServicePanel, diff --git a/internal/cloudcp/portal/frontend/src/shell.ts b/internal/cloudcp/portal/frontend/src/shell.ts index d06ccbeb0..88a4d8ba9 100644 --- a/internal/cloudcp/portal/frontend/src/shell.ts +++ b/internal/cloudcp/portal/frontend/src/shell.ts @@ -1,17 +1,8 @@ import { - createAnonymousBootstrap, - getAccountAPIBasePath, - getBootstrap, - getBootstrapPath, - getCommercialAPIBaseURL, - getLogoutPath, - getMagicLinkRequestPath, - getPortalAPIBasePath, - getPortalPath, - getSignupPath, - notifyPortalRender, - setBootstrap, + bootstrapDefaults, + portalStore, } from './runtime'; +import { createAnonymousBootstrap } from './store'; import { installAccountController } from './account_controller'; import { installAuthController } from './auth_controller'; import { @@ -19,28 +10,18 @@ import { renderHeaderHTML, renderSignedOutPortalHTML, } from './shell_view'; -import type { PortalBootstrapData } from './types'; - -var portalBootstrap: PortalBootstrapData = getBootstrap(); -var LICENSE_API_BASE = getCommercialAPIBaseURL(); -var PORTAL_PATH = getPortalPath(); -var BOOTSTRAP_PATH = getBootstrapPath(); -var MAGIC_LINK_REQUEST_PATH = getMagicLinkRequestPath(); -var SIGNUP_PATH = getSignupPath(); -var LOGOUT_PATH = getLogoutPath(); -var ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); -var PORTAL_API_BASE_PATH = getPortalAPIBasePath(); type ToastElement = HTMLElement & { _timer?: ReturnType }; function renderHeader() { var userInfo = document.getElementById('portal-user-info'); if (!userInfo) return; + var portalBootstrap = portalStore.getBootstrap(); userInfo.innerHTML = renderHeaderHTML({ bootstrap: portalBootstrap, loginState: authController.getLoginState(), - signupPath: SIGNUP_PATH, - accountAPIBasePath: ACCOUNT_API_BASE_PATH, + signupPath: portalBootstrap.signup_path, + accountAPIBasePath: portalBootstrap.account_api_base_path, }); } @@ -48,42 +29,34 @@ function renderPortalApp() { renderHeader(); var root = document.getElementById('portal-app-root'); if (!root) return; + var portalBootstrap = portalStore.getBootstrap(); var context = { bootstrap: portalBootstrap, loginState: authController.getLoginState(), - signupPath: SIGNUP_PATH, - accountAPIBasePath: ACCOUNT_API_BASE_PATH, + signupPath: portalBootstrap.signup_path, + accountAPIBasePath: portalBootstrap.account_api_base_path, }; root.innerHTML = portalBootstrap.authenticated ? renderAuthenticatedPortalHTML(context) : renderSignedOutPortalHTML(context); - notifyPortalRender(); } function applyBootstrap(data) { - portalBootstrap = setBootstrap(data || createAnonymousBootstrap()); - LICENSE_API_BASE = getCommercialAPIBaseURL(); - PORTAL_PATH = getPortalPath(); - BOOTSTRAP_PATH = getBootstrapPath(); - MAGIC_LINK_REQUEST_PATH = getMagicLinkRequestPath(); - SIGNUP_PATH = getSignupPath(); - LOGOUT_PATH = getLogoutPath(); - ACCOUNT_API_BASE_PATH = getAccountAPIBasePath(); - PORTAL_API_BASE_PATH = getPortalAPIBasePath(); + var portalBootstrap = portalStore.setBootstrap(data || createAnonymousBootstrap(bootstrapDefaults)); if (!portalBootstrap.authenticated) { authController.syncBootstrapEmail(portalBootstrap.email || ''); } - renderPortalApp(); } async function refreshBootstrap() { - if (!BOOTSTRAP_PATH) return false; + var bootstrap = portalStore.getBootstrap(); + if (!bootstrap.bootstrap_path) return false; try { - var response = await fetch(BOOTSTRAP_PATH, { + var response = await fetch(bootstrap.bootstrap_path, { headers: { 'Accept': 'application/json' } }); if (response.status === 401) { - applyBootstrap(createAnonymousBootstrap()); + applyBootstrap(createAnonymousBootstrap(bootstrapDefaults)); return true; } if (!response.ok) return false; @@ -105,32 +78,36 @@ function showToast(msg, isError = false) { var authController = installAuthController({ getMagicLinkRequestPath: function() { - return MAGIC_LINK_REQUEST_PATH; + return portalStore.getBootstrap().magic_link_request_path; }, getLogoutPath: function() { - return LOGOUT_PATH; + return portalStore.getBootstrap().logout_path; }, getPortalPath: function() { - return PORTAL_PATH; + return portalStore.getBootstrap().portal_path; }, renderPortal: renderPortalApp, }); installAccountController({ getAccountAPIBasePath: function() { - return ACCOUNT_API_BASE_PATH; + return portalStore.getBootstrap().account_api_base_path; }, getPortalAPIBasePath: function() { - return PORTAL_API_BASE_PATH; + return portalStore.getBootstrap().portal_api_base_path; }, getPortalPath: function() { - return PORTAL_PATH; + return portalStore.getBootstrap().portal_path; }, refreshBootstrap: refreshBootstrap, showToast: showToast }); -applyBootstrap(portalBootstrap); -if (portalBootstrap.authenticated) { +portalStore.subscribe(function() { + renderPortalApp(); +}); + +applyBootstrap(portalStore.getBootstrap()); +if (portalStore.getBootstrap().authenticated) { refreshBootstrap(); } diff --git a/internal/cloudcp/portal/frontend/src/store.test.ts b/internal/cloudcp/portal/frontend/src/store.test.ts new file mode 100644 index 000000000..aaee4d09f --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/store.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createAnonymousBootstrap, createPortalStore, normalizeBootstrap } from './store'; +import type { PortalBootstrapData } from './types'; + +const bootstrapDefaults: Omit = { + public_site_url: 'https://pulserelay.pro', + support_email: 'support@pulserelay.pro', + commercial_api_base_url: 'https://license.pulserelay.pro', + portal_path: '/portal', + bootstrap_path: '/api/portal/bootstrap', + magic_link_request_path: '/api/public/magic-link/request', + signup_path: '/signup', + logout_path: '/auth/logout', + account_api_base_path: '/api/accounts', + portal_api_base_path: '/api/portal', +}; + +describe('portal store', function() { + it('normalizes anonymous bootstrap state from defaults', function() { + var bootstrap = createAnonymousBootstrap(bootstrapDefaults, { + signup_path: '/join', + }); + + expect(bootstrap.authenticated).toBe(false); + expect(bootstrap.email).toBe(''); + expect(bootstrap.signup_path).toBe('/join'); + expect(bootstrap.accounts).toEqual([]); + }); + + it('normalizes partial bootstrap payloads into tracked account state', function() { + var bootstrap = normalizeBootstrap(bootstrapDefaults, { + authenticated: true, + email: 'owner@example.com', + accounts: [ + { + id: 'acct_1', + name: 'Acme MSP', + kind: 'msp', + kind_label: 'MSP', + role: 'owner', + can_manage: true, + has_billing: true, + workspaces: [], + }, + ], + }); + + expect(bootstrap.authenticated).toBe(true); + expect(bootstrap.email).toBe('owner@example.com'); + expect(bootstrap.accounts).toHaveLength(1); + expect(bootstrap.portal_path).toBe('/portal'); + }); + + it('publishes bootstrap changes through a subscription boundary', function() { + var store = createPortalStore(bootstrapDefaults, null); + var listener = vi.fn(); + var unsubscribe = store.subscribe(listener); + + store.setBootstrap({ + authenticated: true, + email: 'owner@example.com', + }); + + expect(listener).toHaveBeenCalledTimes(1); + expect(store.getBootstrap().authenticated).toBe(true); + expect(store.getBootstrap().email).toBe('owner@example.com'); + + unsubscribe(); + store.setBootstrap({ + authenticated: false, + email: '', + }); + + expect(listener).toHaveBeenCalledTimes(1); + }); +}); diff --git a/internal/cloudcp/portal/frontend/src/store.ts b/internal/cloudcp/portal/frontend/src/store.ts new file mode 100644 index 000000000..ec2ba6733 --- /dev/null +++ b/internal/cloudcp/portal/frontend/src/store.ts @@ -0,0 +1,58 @@ +import type { PortalBootstrapData } from './types'; + +export interface PortalStore { + getBootstrap(): PortalBootstrapData; + setBootstrap(nextBootstrap: Partial | PortalBootstrapData): PortalBootstrapData; + subscribe(listener: () => void): () => void; +} + +export function createAnonymousBootstrap( + bootstrapDefaults: Omit, + overrides: Partial = {} +): PortalBootstrapData { + return { + authenticated: false, + email: '', + ...bootstrapDefaults, + ...overrides, + accounts: normalizeAccounts(overrides.accounts), + }; +} + +export function normalizeBootstrap( + bootstrapDefaults: Omit, + raw: Partial | null | undefined +): PortalBootstrapData { + return createAnonymousBootstrap(bootstrapDefaults, raw || {}); +} + +export function createPortalStore( + bootstrapDefaults: Omit, + initialBootstrap: Partial | null | undefined +): PortalStore { + var bootstrapState = normalizeBootstrap(bootstrapDefaults, initialBootstrap); + var subscribers = new Set<() => void>(); + + return { + getBootstrap: function() { + return bootstrapState; + }, + setBootstrap: function(nextBootstrap) { + bootstrapState = normalizeBootstrap(bootstrapDefaults, nextBootstrap); + subscribers.forEach(function(listener) { + listener(); + }); + return bootstrapState; + }, + subscribe: function(listener) { + subscribers.add(listener); + return function() { + subscribers.delete(listener); + }; + }, + }; +} + +function normalizeAccounts(accounts: Partial['accounts']): PortalBootstrapData['accounts'] { + return Array.isArray(accounts) ? accounts : []; +}