refactor(cloudcp): own Pulse Account route config in bootstrap

This commit is contained in:
rcourtman
2026-03-25 23:07:12 +00:00
parent 5a14ca5ed6
commit be28ac852d
4 changed files with 53 additions and 10 deletions
+13 -9
View File
@@ -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; }
+19
View File
@@ -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 {
+20
View File
@@ -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 {
@@ -57,7 +57,7 @@
</div>
<div class="ws-actions">
{{if eq $ws.State "active"}}
<form method="POST" action="/api/accounts/{{$account.ID}}/tenants/{{$ws.ID}}/handoff">
<form method="POST" action="{{$.AccountAPIBasePath}}/{{$account.ID}}/tenants/{{$ws.ID}}/handoff">
<button type="submit" class="btn-primary">Open →</button>
</form>
{{else}}