diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index 87bacdae7..d35c7ba72 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "f26a39d5beef5f6fc0649b14f45a48c68df81e299b579087430cd05e42c810bd",
+ "source_hash": "07cc157c9e51171f54b181dc953c685e2687e14af66407867c678e0bcd370540",
"build_inputs": [
"package.json",
"tsconfig.json",
diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css
index b731ad6f3..88c320544 100644
--- a/internal/cloudcp/portal/dist/portal_app.css
+++ b/internal/cloudcp/portal/dist/portal_app.css
@@ -213,6 +213,13 @@ header .logout-btn:hover {
.btn-danger:hover {
background: #fef2f2;
}
+.btn-compact {
+ padding: 8px 14px;
+ font-size: 13px;
+}
+.link-button {
+ text-decoration: none;
+}
.account-actions {
margin-top: 14px;
display: flex;
@@ -285,6 +292,14 @@ header .logout-btn:hover {
border-bottom: 1px solid #f1f5f9;
vertical-align: middle;
}
+.team-message-cell {
+ text-align: center;
+ padding: 16px;
+ color: #94a3b8;
+}
+.team-message-cell.error {
+ color: #991b1b;
+}
.team-table select {
border: 1px solid #cbd5e1;
border-radius: 6px;
@@ -498,6 +513,9 @@ header .logout-btn:hover {
color: #0f172a;
word-break: break-word;
}
+.service-result {
+ margin-top: 14px;
+}
.service-panel .subsection {
margin-top: 18px;
padding-top: 18px;
@@ -572,8 +590,26 @@ header .logout-btn:hover {
margin: 0;
font-size: 15px;
}
+.empty-state-spaced {
+ margin-top: 48px;
+}
+.support-copy {
+ margin-top: 12px;
+ font-size: 13px;
+}
+.support-link {
+ color: #1d4ed8;
+}
+.badge-role {
+ background: #f1f5f9;
+ color: #64748b;
+}
+.workspace-state-label {
+ font-size: 13px;
+ color: #94a3b8;
+}
.spinner {
- display: none;
+ display: inline-block;
width: 18px;
height: 18px;
border: 2px solid #93c5fd;
@@ -581,6 +617,9 @@ header .logout-btn:hover {
border-radius: 50%;
animation: spin 0.6s linear infinite;
}
+[hidden] {
+ display: none !important;
+}
@keyframes spin {
to {
transform: rotate(360deg);
diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js
index ef993df0a..88c380ebb 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -15,7 +15,7 @@
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.className = "team-message-cell" + (isError ? " error" : "");
td.textContent = msg;
tr.appendChild(td);
tbody.appendChild(tr);
@@ -63,7 +63,7 @@
if (!form) return;
form.classList.toggle("visible", entry.addWorkspaceOpen);
if (spinner) {
- spinner.style.display = entry.createWorkspace.pending ? "block" : "none";
+ spinner.hidden = !entry.createWorkspace.pending;
}
}
function renderTeamSection(accountID, entry) {
@@ -832,7 +832,7 @@
function setVisible(id, visible) {
var el = getElement3(id);
if (el) {
- el.style.display = visible ? "block" : "none";
+ el.hidden = !visible;
}
}
function setValue(id, value) {
@@ -876,23 +876,19 @@
function renderManagePanel(flowState) {
var root = getElement3("manage-service-root");
if (!root) return;
- root.innerHTML = '
Manage subscriptions Request a verification code for the commercial email, then open the Stripe customer portal for billing changes, invoices, and subscription actions.
Verification code
Open Customer Portal
Need a new code?
Send again
';
+ 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.
Verification code
Open Customer Portal
Need a new code?
Send again
';
}
function renderRetrievePanel(flowState) {
var root = getElement3("retrieve-service-root");
if (!root) return;
var result = flowState.result;
var invoiceURL = result && result.invoice_url ? result.invoice_url : "#";
- var invoiceDisplay = result && result.invoice_url ? "inline-block" : "none";
- var copyDisplay = result ? "inline-block" : "none";
- var resultDisplay = result ? "block" : "none";
- root.innerHTML = 'Retrieve licenses Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.
License key 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 : "") + "
";
+ root.innerHTML = 'Retrieve licenses Request a verification code for the commercial email, then reveal the current active self-hosted license without leaving Pulse Account.
License key Plan
' + escapeText(result ? result.tier : "") + '
Issued
' + escapeText(result ? new Date(result.issued_at).toLocaleString() : "") + '
Expires
' + escapeText(result ? result.expires_at ? new Date(result.expires_at).toLocaleString() : "Does not expire" : "") + '
Purchase Email
' + escapeText(result ? result.email : "") + "
";
}
function renderExportPanel(flowState) {
var root = getElement3("data-export-root");
if (!root) return;
- var resultDisplay = flowState.result ? "block" : "none";
- root.innerHTML = 'Export My Data Verification code
Export My Data
Need a new code?
Send again
Export payload
";
+ root.innerHTML = 'Export My Data Verification code
Export My Data
Need a new code?
Send again
Export payload
";
}
function renderExportResult(result) {
setVisible("data-export-result", !!result);
@@ -901,7 +897,7 @@
function renderDeletePanel(flowState) {
var root = getElement3("data-delete-root");
if (!root) return;
- root.innerHTML = 'Delete My Data Warning: deleting commercial data also revokes license records and cannot be undone.
';
+ root.innerHTML = 'Delete My Data Warning: deleting commercial data also revokes license records and cannot be undone.
';
}
// src/services_controller.ts
@@ -1378,9 +1374,9 @@
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 ';
+ if (role === "owner") return 'Owner ';
+ if (role === "admin") return 'Admin ';
+ if (role === "tech") return 'Tech ';
return "";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
@@ -1391,7 +1387,7 @@
if (state === "active") {
openAction = '';
} else {
- openAction = '' + safeState + " ";
+ openAction = '' + safeState + " ";
}
var manageAction = "";
if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) {
@@ -1415,9 +1411,9 @@
var addWorkspaceForm = "";
if (account.can_manage) {
actions = '' + (account.kind === "msp" ? '+ Add workspace ' : "") + (account.has_billing ? 'Manage billing ' : "") + 'Manage team
';
- teamSection = '';
+ teamSection = '';
if (account.kind === "msp") {
- addWorkspaceForm = '';
+ addWorkspaceForm = '';
}
}
return '" + workspaceHTML + actions + teamSection + addWorkspaceForm + " ";
@@ -1426,12 +1422,12 @@
if (context.bootstrap.authenticated) {
return "" + escapeHTML(context.bootstrap.email || "") + ' Sign out ';
}
- return 'Create account ';
+ return 'Create account ';
}
function renderAccountsHTML(context) {
var safeAccounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
if (safeAccounts.length === 0) {
- return '";
+ return '";
}
return safeAccounts.map(function(account) {
return renderAccountSection(account, context.accountAPIBasePath);
@@ -1447,7 +1443,7 @@
} else if (context.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.
Email address
' + statusHTML + "
";
+ 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.
Email address
' + statusHTML + "
";
}
// src/shell.ts
diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts
index 397e21d0a..6b3152633 100644
--- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts
+++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts
@@ -80,7 +80,7 @@ describe('account runtime', function() {
document.body.innerHTML =
'';
runtime.toggleAddWorkspace('acct_1');
@@ -103,7 +103,7 @@ describe('account runtime', function() {
expect(deps.showToast).toHaveBeenCalledWith('Workspace created!');
expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false);
expect(deps.store.getAccountState().byAccountID.acct_1.createWorkspace.pending).toBe(false);
- expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('none');
+ expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true);
});
it('loads and updates team membership from runtime actions', async function() {
diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts
index 8c326e73e..44b086d83 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts
@@ -28,17 +28,17 @@ describe('account view', function() {
it('renders add-workspace visibility from account UI state', function() {
document.body.innerHTML =
'
' +
- '
';
+ '
';
renderAddWorkspaceSection('acct_1', createEntry({ addWorkspaceOpen: true }));
expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(true);
- expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('none');
+ expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(true);
renderAddWorkspaceSection('acct_1', createEntry({
addWorkspaceOpen: true,
createWorkspace: { pending: true, error: '' },
}));
- expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).style.display).toBe('block');
+ expect((document.getElementById('ws-spinner-acct_1') as HTMLElement).hidden).toBe(false);
renderAddWorkspaceSection('acct_1', createEntry({ addWorkspaceOpen: false }));
expect(document.getElementById('add-ws-form-acct_1')?.classList.contains('visible')).toBe(false);
diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts
index ae1b5f596..4dcebd577 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.ts
@@ -20,7 +20,7 @@ function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): voi
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.className = 'team-message-cell' + (isError ? ' error' : '');
td.textContent = msg;
tr.appendChild(td);
tbody.appendChild(tr);
@@ -73,7 +73,7 @@ export function renderAddWorkspaceSection(accountID: string, entry: PortalAccoun
if (!form) return;
form.classList.toggle('visible', entry.addWorkspaceOpen);
if (spinner) {
- spinner.style.display = entry.createWorkspace.pending ? 'block' : 'none';
+ spinner.hidden = !entry.createWorkspace.pending;
}
}
diff --git a/internal/cloudcp/portal/frontend/src/app.test.ts b/internal/cloudcp/portal/frontend/src/app.test.ts
index aad76ea69..179d4f483 100644
--- a/internal/cloudcp/portal/frontend/src/app.test.ts
+++ b/internal/cloudcp/portal/frontend/src/app.test.ts
@@ -295,8 +295,8 @@ describe('portal app', function() {
}),
]);
expect((store.getServiceState().flows.retrieve.result as { token?: string } | null)?.token).toBe('pulse_token_123');
- expect((document.getElementById('retrieve-inline-result') as HTMLElement | null)?.style.display).toBe('block');
- expect((document.getElementById('retrieve-inline-copy') as HTMLButtonElement | null)?.style.display).toBe('inline-block');
+ expect((document.getElementById('retrieve-inline-result') as HTMLElement | null)?.hidden).toBe(false);
+ expect((document.getElementById('retrieve-inline-copy') as HTMLButtonElement | null)?.hidden).toBe(false);
expect(document.getElementById('retrieve-inline-status')?.textContent).toContain('License retrieved successfully.');
});
diff --git a/internal/cloudcp/portal/frontend/src/services_view.test.ts b/internal/cloudcp/portal/frontend/src/services_view.test.ts
index a9a3796ef..5087a2086 100644
--- a/internal/cloudcp/portal/frontend/src/services_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/services_view.test.ts
@@ -115,12 +115,12 @@ describe('services view', function() {
var invoiceLink = document.getElementById('retrieve-inline-invoice') as HTMLAnchorElement;
var tokenArea = document.getElementById('retrieve-inline-token') as HTMLTextAreaElement;
- expect(copyButton.style.display).toBe('inline-block');
+ expect(copyButton.hidden).toBe(false);
expect(invoiceLink.href).toBe('https://license.pulserelay.pro/invoices/inv_123');
- expect(invoiceLink.style.display).toBe('inline-block');
+ expect(invoiceLink.hidden).toBe(false);
expect(tokenArea.value).toBe('pulse_key_123');
expect(document.getElementById('retrieve-inline-email-value')?.textContent).toBe('buyer@example.com');
- expect(document.getElementById('retrieve-inline-result')?.style.display).toBe('block');
+ expect((document.getElementById('retrieve-inline-result') as HTMLElement).hidden).toBe(false);
});
it('renders refund and delete panels from owned state', function() {
@@ -152,13 +152,13 @@ describe('services view', function() {
expect(refundToken.value).toBe('pulse_token');
expect(document.getElementById('refund-service-root')?.innerHTML).toContain('/refund.html?email=owner%40example.com');
expect(deleteCheck.checked).toBe(true);
- expect(document.getElementById('data-delete-step2')?.style.display).toBe('block');
+ expect((document.getElementById('data-delete-step2') as HTMLElement).hidden).toBe(false);
});
it('renders export panel and updates export result payload visibility', function() {
document.body.innerHTML =
'
' +
- '
' +
+ '
' +
'';
renderExportPanel(
@@ -175,8 +175,8 @@ describe('services view', function() {
renderExportResult(payload);
expect((document.getElementById('data-export-email') as HTMLInputElement).value).toBe('owner@example.com');
- expect(document.getElementById('data-export-step2')?.style.display).toBe('block');
- expect(document.getElementById('data-export-result')?.style.display).toBe('block');
+ expect((document.getElementById('data-export-step2') as HTMLElement).hidden).toBe(false);
+ expect((document.getElementById('data-export-result') as HTMLElement).hidden).toBe(false);
expect((document.getElementById('data-export-payload') as HTMLTextAreaElement).value).toBe(
JSON.stringify(payload, null, 2)
);
@@ -202,7 +202,7 @@ describe('services view', function() {
expect((document.getElementById('manage-inline-email') as HTMLInputElement).value).toBe('owner@example.com');
expect((document.getElementById('manage-inline-code') as HTMLInputElement).value).toBe('123456');
- expect(document.getElementById('manage-inline-step2')?.style.display).toBe('block');
+ expect((document.getElementById('manage-inline-step2') as HTMLElement).hidden).toBe(false);
expect(document.getElementById('manage-inline-status')?.className).toContain('success');
expect(document.getElementById('manage-inline-status')?.textContent).toBe('Code sent.');
});
diff --git a/internal/cloudcp/portal/frontend/src/services_view.ts b/internal/cloudcp/portal/frontend/src/services_view.ts
index b0d6141bb..934c4a10f 100644
--- a/internal/cloudcp/portal/frontend/src/services_view.ts
+++ b/internal/cloudcp/portal/frontend/src/services_view.ts
@@ -36,7 +36,7 @@ export function focusElement(id: string): void {
export function setVisible(id: string, visible: boolean): void {
var el = getElement(id);
if (el) {
- el.style.display = visible ? 'block' : 'none';
+ el.hidden = !visible;
}
}
@@ -114,7 +114,7 @@ export function renderManagePanel(flowState: VerificationFlowState): void {
'Send Verification Code ' +
'' +
'' +
- '' +
+ '
' +
- '
' +
+ '
' +
'
' +
'Verification code ' +
' ' +
'
' +
'
' +
'
Use the latest active self-hosted license for this commercial email.
' +
'
' +
'
' +
- '
' +
+ '
' +
'
License key ' +
'
' +
'
' +
@@ -182,7 +179,6 @@ export function renderRetrievePanel(flowState: VerificationFlowState): void {
export function renderExportPanel(flowState: VerificationFlowState): void {
var root = getElement('data-export-root');
if (!root) return;
- var resultDisplay = flowState.result ? 'block' : 'none';
root.innerHTML = '' +
'
Export My Data ' +
'
' +
@@ -194,7 +190,7 @@ export function renderExportPanel(flowState: VerificationFlowState): void {
'Send Verification Code ' +
'
' +
'
' +
- '
' +
+ '
' +
'
' +
'
' +
- '
' +
+ '
' +
'Export payload ' +
'' +
'
';
@@ -231,7 +227,7 @@ export function renderDeletePanel(flowState: VerificationFlowState): void {
'
Send Verification Code ' +
'
' +
'
' +
- '
' +
+ '
' +
'
' +
@@ -181,7 +181,7 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'">Cancel' +
'
' +
+ '" hidden>
' +
'
' +
'
';
}
@@ -209,18 +209,18 @@ export function renderHeaderHTML(context: ShellViewContext): string {
'
Sign out '
);
}
- return '
Create account ';
+ return '
Create account ';
}
export function renderAccountsHTML(context: ShellViewContext): string {
var safeAccounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
if (safeAccounts.length === 0) {
return (
- '
' +
+ '
'
@@ -309,7 +309,7 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string {
'
' +
(context.loginState.request.pending ? 'Sending…' : 'Send magic link') +
' ' +
- '
Create an account ' +
+ '
Create an account ' +
'
' +
statusHTML +
'
' +
diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css
index c923420f8..d7cf5dbd4 100644
--- a/internal/cloudcp/portal/frontend/src/styles.css
+++ b/internal/cloudcp/portal/frontend/src/styles.css
@@ -36,6 +36,8 @@
.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; }
+ .btn-compact { padding: 8px 14px; font-size: 13px; }
+ .link-button { text-decoration: none; }
.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; }
@@ -48,6 +50,8 @@
.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-message-cell { text-align: center; padding: 16px; color: #94a3b8; }
+ .team-message-cell.error { color: #991b1b; }
.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; }
@@ -83,6 +87,7 @@
.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-result { margin-top: 14px; }
.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; }
@@ -96,7 +101,13 @@
.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; }
+ .empty-state-spaced { margin-top: 48px; }
+ .support-copy { margin-top: 12px; font-size: 13px; }
+ .support-link { color: #1d4ed8; }
+ .badge-role { background: #f1f5f9; color: #64748b; }
+ .workspace-state-label { font-size: 13px; color: #94a3b8; }
+ .spinner { display: inline-block; width: 18px; height: 18px; border: 2px solid #93c5fd; border-top-color: #1d4ed8; border-radius: 50%; animation: spin 0.6s linear infinite; }
+ [hidden] { display: none !important; }
@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; }