diff --git a/internal/cloudcp/portal/assets/portal.js b/internal/cloudcp/portal/assets/portal.js index 4f9131461..f21219800 100644 --- a/internal/cloudcp/portal/assets/portal.js +++ b/internal/cloudcp/portal/assets/portal.js @@ -8,6 +8,10 @@ if (bootstrapEl) { } } var LICENSE_API_BASE = portalBootstrap.commercial_api_base_url || ''; +var PORTAL_PATH = portalBootstrap.portal_path || '/portal'; +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'; function showToast(msg, isError) { var t = document.getElementById('toast'); @@ -21,9 +25,9 @@ document.getElementById('logout-btn').onclick = async function() { this.disabled = true; this.textContent = 'Signing out…'; try { - await fetch('/auth/logout', { method: 'POST' }); + await fetch(LOGOUT_PATH, { method: 'POST' }); } catch(_) {} - window.location.href = '/portal'; + window.location.href = PORTAL_PATH; }; function toggleServicePanel(panelID) { @@ -379,7 +383,7 @@ async function createWorkspace(accountID) { var spinner = document.getElementById('ws-spinner-' + accountID); spinner.style.display = 'block'; try { - var resp = await fetch('/api/accounts/' + accountID + '/tenants', { + var resp = await fetch(ACCOUNT_API_BASE_PATH + '/' + accountID + '/tenants', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ display_name: name }) @@ -404,7 +408,7 @@ function suspendOrDelete(evt, accountID, tenantID, state, name) { if (!confirm(action + ' workspace "' + name + '"?')) return; var method = state === 'active' ? 'PATCH' : 'DELETE'; var body = state === 'active' ? JSON.stringify({ state: 'suspended' }) : undefined; - fetch('/api/accounts/' + accountID + '/tenants/' + tenantID, { + fetch(ACCOUNT_API_BASE_PATH + '/' + accountID + '/tenants/' + tenantID, { method: method, headers: body ? { 'Content-Type': 'application/json' } : {}, body: body @@ -422,7 +426,7 @@ function suspendOrDelete(evt, accountID, tenantID, state, name) { async function openBilling(accountID) { try { - var r = await fetch('/api/portal/billing?account_id=' + encodeURIComponent(accountID), { method: 'POST' }); + 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); @@ -464,7 +468,7 @@ async function loadTeam(accountID) { var isOwner = actorRole === 'owner'; setTbodyMessage(tbody, 'Loading\u2026', false); try { - var r = await fetch('/api/accounts/' + encodeURIComponent(accountID) + '/members'); + 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) { @@ -520,7 +524,7 @@ async function inviteMember(accountID) { var email = emailEl.value.trim(); if (!email) { emailEl.focus(); return; } try { - var r = await fetch('/api/accounts/' + encodeURIComponent(accountID) + '/members', { + 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 }) @@ -541,7 +545,7 @@ async function inviteMember(accountID) { async function changeRole(accountID, userID, newRole) { try { - var r = await fetch('/api/accounts/' + encodeURIComponent(accountID) + '/members/' + encodeURIComponent(userID), { + 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 }) @@ -558,7 +562,7 @@ async function changeRole(accountID, userID, newRole) { async function removeMember(accountID, userID, email) { if (!confirm('Remove ' + email + ' from this account?')) return; try { - var r = await fetch('/api/accounts/' + encodeURIComponent(accountID) + '/members/' + encodeURIComponent(userID), { + 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; } diff --git a/internal/cloudcp/portal/handlers_test.go b/internal/cloudcp/portal/handlers_test.go index d5e88625d..d44257958 100644 --- a/internal/cloudcp/portal/handlers_test.go +++ b/internal/cloudcp/portal/handlers_test.go @@ -729,6 +729,10 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { "Pulse Account", `id="pulse-account-bootstrap"`, `"commercial_api_base_url":"https://license.pulserelay.pro"`, + `"portal_path":"/portal"`, + `"logout_path":"/auth/logout"`, + `"account_api_base_path":"/api/accounts"`, + `"portal_api_base_path":"/api/portal"`, `"email":"owner@example.com"`, `"accounts":[{"id":"a_test"`, "Other account services", @@ -758,6 +762,9 @@ func TestPortalPageTemplate_AccountServicesRendered(t *testing.T) { if strings.Contains(html, `const LICENSE_API_BASE = 'https://license.pulserelay.pro';`) { t.Errorf("expected commercial API base URL to be renderer-owned, not hardcoded in the asset") } + if strings.Contains(html, `await fetch('/auth/logout'`) { + t.Errorf("expected portal paths to be renderer-owned, not hardcoded in the asset") + } } func TestBuildPortalBootstrapJSON_Contract(t *testing.T) { @@ -795,6 +802,18 @@ func TestBuildPortalBootstrapJSON_Contract(t *testing.T) { if got := payload["commercial_api_base_url"]; got != "https://license.pulserelay.pro" { t.Fatalf("commercial_api_base_url = %#v", got) } + if got := payload["portal_path"]; got != "/portal" { + t.Fatalf("portal_path = %#v", got) + } + if got := payload["logout_path"]; got != "/auth/logout" { + t.Fatalf("logout_path = %#v", got) + } + if got := payload["account_api_base_path"]; got != "/api/accounts" { + t.Fatalf("account_api_base_path = %#v", got) + } + if got := payload["portal_api_base_path"]; got != "/api/portal" { + t.Fatalf("portal_api_base_path = %#v", got) + } accounts, ok := payload["accounts"].([]any) if !ok || len(accounts) != 1 { diff --git a/internal/cloudcp/portal/page.go b/internal/cloudcp/portal/page.go index 9295b5de6..4a78eb33c 100644 --- a/internal/cloudcp/portal/page.go +++ b/internal/cloudcp/portal/page.go @@ -40,6 +40,10 @@ type portalPageData struct { PublicSiteURL string SupportEmail string CommercialAPIBaseURL string + PortalPath string + LogoutPath string + AccountAPIBasePath string + PortalAPIBasePath string Accounts []portalPageAccount Styles template.CSS Script template.JS @@ -75,6 +79,10 @@ type portalBootstrapData struct { PublicSiteURL string `json:"public_site_url"` SupportEmail string `json:"support_email"` CommercialAPIBaseURL string `json:"commercial_api_base_url"` + PortalPath string `json:"portal_path"` + LogoutPath string `json:"logout_path"` + AccountAPIBasePath string `json:"account_api_base_path"` + PortalAPIBasePath string `json:"portal_api_base_path"` Accounts []portalBootstrapAccount `json:"accounts"` } @@ -82,6 +90,10 @@ const ( defaultPublicSiteURL = "https://pulserelay.pro" defaultSupportEmail = "support@pulserelay.pro" defaultCommercialAPIBaseURL = "https://license.pulserelay.pro" + defaultPortalPath = "/portal" + defaultLogoutPath = "/auth/logout" + defaultAccountAPIBasePath = "/api/accounts" + defaultPortalAPIBasePath = "/api/portal" ) // HandlePortalPage serves the MSP/Cloud portal dashboard (browser-facing HTML). @@ -212,6 +224,10 @@ func renderPortalPage(w http.ResponseWriter, nonce, email string, accounts []por PublicSiteURL: defaultPublicSiteURL, SupportEmail: defaultSupportEmail, CommercialAPIBaseURL: defaultCommercialAPIBaseURL, + PortalPath: defaultPortalPath, + LogoutPath: defaultLogoutPath, + AccountAPIBasePath: defaultAccountAPIBasePath, + PortalAPIBasePath: defaultPortalAPIBasePath, Accounts: accounts, Styles: portalStyles, Script: portalScript, @@ -262,6 +278,10 @@ func buildPortalBootstrapJSON(email string, accounts []portalPageAccount) (templ PublicSiteURL: defaultPublicSiteURL, SupportEmail: defaultSupportEmail, CommercialAPIBaseURL: defaultCommercialAPIBaseURL, + PortalPath: defaultPortalPath, + LogoutPath: defaultLogoutPath, + AccountAPIBasePath: defaultAccountAPIBasePath, + PortalAPIBasePath: defaultPortalAPIBasePath, Accounts: bootstrapAccounts, }) if err != nil { diff --git a/internal/cloudcp/portal/templates/portal.html b/internal/cloudcp/portal/templates/portal.html index 1acf0069c..98b6e5be5 100644 --- a/internal/cloudcp/portal/templates/portal.html +++ b/internal/cloudcp/portal/templates/portal.html @@ -57,7 +57,7 @@
{{if eq $ws.State "active"}} -
+
{{else}}