feat(portal): simplify hosted operations shell

This commit is contained in:
rcourtman
2026-03-27 16:16:24 +00:00
parent 7bc9bb414d
commit e2e835b9b3
6 changed files with 190 additions and 246 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "c60aecd13aa823a23de54658d2f067e93ce5d5d05b80cee353495dccbb3c07d2",
"source_hash": "2cbfa509f8e18522b02bce02cea0af8db454746381d6e7cae0e1b5a0eefcad5d",
"build_inputs": [
"package.json",
"tsconfig.json",
+77 -101
View File
@@ -232,18 +232,7 @@ header .logout-btn:hover,
line-height: 1.05;
letter-spacing: -0.04em;
}
.portal-shell-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: stretch;
}
.portal-section-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.portal-section-link,
.portal-command-strip,
.intro-card,
.service-panel,
.account-surface,
@@ -252,28 +241,31 @@ header .logout-btn:hover,
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-sm);
}
.portal-section-link {
.portal-command-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
border-radius: var(--radius-md);
}
.portal-command-link {
display: block;
padding: 16px 18px;
border-radius: var(--radius-md);
text-decoration: none;
transition:
transform 140ms ease,
border-color 140ms ease,
box-shadow 140ms ease;
transition: background 140ms ease;
}
.portal-section-link:hover {
transform: translateY(-1px);
border-color: var(--accent);
box-shadow: var(--shadow-md);
.portal-command-link + .portal-command-link {
border-left: 1px solid var(--line);
}
.portal-section-link-label {
.portal-command-link:hover {
background: #f7fafc;
}
.portal-command-label {
display: block;
font-size: 14px;
font-weight: 700;
margin-bottom: 5px;
}
.portal-section-link-copy {
.portal-command-copy {
display: block;
font-size: 13px;
line-height: 1.5;
@@ -285,35 +277,8 @@ header .logout-btn:hover,
color: var(--accent-strong);
font-weight: 600;
}
.portal-support-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 340px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-sm);
}
.portal-support-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.portal-support-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-muted);
}
.portal-support-text {
max-width: 280px;
font-size: 13px;
line-height: 1.5;
color: var(--ink-soft);
.portal-command-link-support {
text-align: right;
}
.portal-stage {
display: flex;
@@ -391,15 +356,9 @@ header .logout-btn:hover,
gap: 18px;
margin-top: 22px;
}
.account-overview-rail {
display: grid;
grid-template-columns: minmax(280px, 0.95fr) minmax(0, 1.15fr);
gap: 14px;
align-items: start;
}
.account-command-deck,
.account-overview-card,
.account-stat-card,
.account-operations-panel,
.workspace-row,
.workspace-management-panel,
.team-management-panel,
@@ -424,7 +383,6 @@ header .logout-btn:hover,
}
.account-overview-card h3,
.account-stage-header h3,
.account-panel-copy h3,
.workspace-management-header h3,
.team-management-header h3,
.service-card h3,
@@ -437,7 +395,6 @@ header .logout-btn:hover,
}
.account-overview-card p,
.account-stage-header p,
.account-panel-copy p,
.workspace-management-header p,
.team-management-header p,
.team-panel-heading p,
@@ -449,21 +406,21 @@ header .logout-btn:hover,
line-height: 1.65;
color: var(--ink-soft);
}
.account-status-grid,
.team-management-stats {
.account-command-deck {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(280px, 1.05fr) minmax(0, 1.35fr);
gap: 14px;
align-items: stretch;
}
.account-metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.account-overview-card {
grid-column: 1;
}
.account-status-grid {
grid-column: 2;
grid-row: 1 / span 2;
}
.account-stat-card {
min-height: 108px;
.team-management-stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.account-stat-label,
.team-stat-label {
@@ -492,12 +449,23 @@ header .logout-btn:hover,
.team-stat-error {
color: var(--danger);
}
.account-operations-panel {
.account-action-strip {
display: flex;
flex-direction: column;
gap: 16px;
background: var(--panel);
grid-column: 1;
align-items: start;
justify-content: space-between;
gap: 18px;
grid-column: 1 / -1;
padding: 16px 18px 0;
border-top: 1px solid var(--line);
}
.account-action-copy {
max-width: 720px;
}
.account-action-copy p {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--ink-soft);
}
.add-workspace-form {
grid-column: 1 / -1;
@@ -508,7 +476,7 @@ header .logout-btn:hover,
gap: 16px;
}
.account-stage-header {
padding: 6px 4px 0;
padding: 2px 4px 0;
}
.workspace-list {
display: flex;
@@ -520,7 +488,7 @@ header .logout-btn:hover,
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 18px;
padding: 20px;
}
.workspace-row-main {
display: grid;
@@ -577,7 +545,7 @@ header .logout-btn:hover,
display: flex;
flex-direction: column;
gap: 8px;
min-height: 84px;
min-height: 74px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
@@ -685,13 +653,18 @@ header .logout-btn:hover,
outline: 2px solid rgba(15, 109, 115, 0.18);
border-color: var(--accent);
}
.team-stat-card {
min-width: 140px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--panel-muted);
}
.team-management-stats {
margin: 16px 0;
}
.team-stat-card {
padding: 16px;
border-radius: var(--radius-sm);
background: var(--panel-muted);
.team-stat-value {
margin-top: 6px;
font-size: 22px;
}
.team-management-grid {
display: grid;
@@ -713,11 +686,11 @@ header .logout-btn:hover,
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
border-bottom: 1px solid rgba(203, 183, 153, 0.7);
border-bottom: 1px solid var(--line);
}
.team-table td {
padding: 12px 8px;
border-bottom: 1px solid rgba(223, 210, 189, 0.55);
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
.team-message-cell {
@@ -925,18 +898,12 @@ header .logout-btn:hover,
.team-management-grid {
grid-template-columns: 1fr;
}
.portal-shell-bar {
.portal-command-strip,
.account-command-deck {
grid-template-columns: 1fr;
}
.portal-support-strip {
min-width: 0;
}
.account-overview-rail {
grid-template-columns: 1fr;
}
.account-status-grid {
grid-column: auto;
grid-row: auto;
.account-metric-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-management-content {
grid-template-columns: 1fr;
@@ -968,9 +935,7 @@ header .logout-btn:hover,
.main {
padding: 18px 16px 40px;
}
.portal-section-nav,
.account-status-grid,
.team-management-stats {
.portal-command-strip {
grid-template-columns: 1fr;
}
.account-surface,
@@ -986,6 +951,17 @@ header .logout-btn:hover,
flex-direction: column;
align-items: flex-start;
}
.portal-command-link + .portal-command-link {
border-left: none;
border-top: 1px solid var(--line);
}
.portal-command-link-support {
text-align: left;
}
.account-action-strip {
flex-direction: column;
align-items: stretch;
}
.workspace-actions,
.workspace-actions form,
.workspace-actions .btn-primary,
+4 -4
View File
@@ -1600,7 +1600,7 @@
}
function renderShellNavigation(accounts, supportEmail) {
var hosted = hasHostedAccounts(accounts);
return '<nav class="portal-section-nav" aria-label="Pulse Account sections"><a class="portal-section-link" href="#hosted-operations-section"><span class="portal-section-link-label">' + (hosted ? "Hosted operations" : "Hosted access") + '</span><span class="portal-section-link-copy">' + (hosted ? "Workspaces, teams, and hosted billing" : "No hosted workspaces are attached yet") + '</span></a><a class="portal-section-link" href="#account-services-section"><span class="portal-section-link-label">Account services</span><span class="portal-section-link-copy">Licenses, billing, refunds, and privacy</span></a><a class="portal-section-link" href="mailto:' + escapeAttr(supportEmail || "") + '"><span class="portal-section-link-label">Support</span><span class="portal-section-link-copy">' + escapeHTML(supportEmail || "") + "</span></a></nav>";
return '<nav class="portal-command-strip" aria-label="Pulse Account sections"><a class="portal-command-link" href="#hosted-operations-section"><span class="portal-command-label">' + (hosted ? "Hosted operations" : "Hosted access") + '</span><span class="portal-command-copy">' + (hosted ? "Workspaces, teams, and hosted billing" : "No hosted workspaces are attached yet") + '</span></a><a class="portal-command-link" href="#account-services-section"><span class="portal-command-label">Account services</span><span class="portal-command-copy">Licenses, billing, refunds, and privacy</span></a><a class="portal-command-link portal-command-link-support" href="mailto:' + escapeAttr(supportEmail || "") + '"><span class="portal-command-label">Support</span><span class="portal-command-copy">' + escapeHTML(supportEmail || "") + "</span></a></nav>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
@@ -1640,7 +1640,7 @@
var addWorkspaceForm = "";
var workspaceManagement = "";
if (account.can_manage) {
actions = '<div class="account-operations-panel"><div class="account-panel-copy"><div class="account-panel-kicker">Account operations</div><h3>Run the hosted side from here</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
actions = '<div class="account-action-strip"><div class="account-action-copy"><div class="account-panel-kicker">Account operations</div><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
teamSection = '<section class="team-management-panel team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><div class="team-management-header"><div><div class="account-panel-kicker">Team management</div><h3>Control who can operate this account</h3><p>Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.</p></div><button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.</p></div><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table></div><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div></div></section>';
workspaceManagement = '<section class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><div class="account-panel-kicker">Workspace management</div><h3>Review one workspace at a time</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></section>';
if (account.kind === "msp") {
@@ -1650,7 +1650,7 @@
var workspaceHTML = workspaces.length ? '<div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join("") + "</div>" : '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
return '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-surface-body"><aside class="account-overview-rail"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(accountKindLabel(account)) + "</h3><p>" + escapeHTML(summaryText) + '</p></div><div class="account-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + '</aside><div class="account-main-stage"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspace fleet</div><h3>Open hosted Pulse workspaces</h3><p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p></div></div>' + workspaceHTML + '<div class="account-management-grid">' + workspaceManagement + teamSection + "</div></div></div></section>";
return '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-surface-body"><div class="account-command-deck"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(account.name) + "</h3><p>" + escapeHTML(summaryText) + '</p></div><div class="account-metric-strip"><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + '</div><div class="account-main-stage"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspace fleet</div><h3>Open hosted Pulse workspaces</h3><p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p></div></div>' + workspaceHTML + '<div class="account-management-grid">' + workspaceManagement + teamSection + "</div></div></div></section>";
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
@@ -1672,7 +1672,7 @@
var hosted = hasHostedAccounts(accounts);
var serviceHeading = hosted ? "Self-hosted licenses and billing" : "Account services";
var serviceNote = hosted ? "Hosted operations live above. Use these commercial tools for self-hosted licenses, billing, refunds, and privacy actions." : "Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.";
return '<div class="portal-shell">' + renderOverviewBand(accounts) + '<div class="portal-shell-bar">' + renderShellNavigation(accounts, context.bootstrap.support_email || "") + '<div class="portal-support-strip"><div class="portal-support-copy"><span class="portal-support-label">Support</span><span class="portal-support-text">Hosted operations and commercial account tools now share one account surface.</span></div><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></div><section class="portal-top-section" id="hosted-operations-section"><div class="portal-top-section-header"><div class="account-panel-kicker">' + (hosted ? "Hosted operations" : "Hosted access") + "</div><h2>" + (hosted ? "Run hosted accounts from one place" : "No hosted workspaces are attached yet") + "</h2><p>" + (hosted ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '</p></div><div id="accounts-root">' + renderAccountsHTML(context) + '</div></section><section class="service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-grid"><button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email"><span class="service-card-kicker">Billing</span><h3>Manage subscriptions</h3><p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p></button><button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email"><span class="service-card-kicker">Licenses</span><h3>Retrieve licenses</h3><p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p></button><button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email"><span class="service-card-kicker">Refunds</span><h3>Refund requests</h3><p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p></button><button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email"><span class="service-card-kicker">Privacy</span><h3>Data and privacy</h3><p>Request commercial data export or deletion without leaving the account shell.</p></button></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + "</a>.</div></div></section></div>";
return '<div class="portal-shell">' + renderOverviewBand(accounts) + renderShellNavigation(accounts, context.bootstrap.support_email || "") + '<section class="portal-top-section" id="hosted-operations-section"><div class="portal-top-section-header"><div class="account-panel-kicker">' + (hosted ? "Hosted operations" : "Hosted access") + "</div><h2>" + (hosted ? "Run hosted accounts from one place" : "No hosted workspaces are attached yet") + "</h2><p>" + (hosted ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '</p></div><div id="accounts-root">' + renderAccountsHTML(context) + '</div></section><section class="service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-grid"><button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email"><span class="service-card-kicker">Billing</span><h3>Manage subscriptions</h3><p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p></button><button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email"><span class="service-card-kicker">Licenses</span><h3>Retrieve licenses</h3><p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p></button><button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email"><span class="service-card-kicker">Refunds</span><h3>Refund requests</h3><p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p></button><button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email"><span class="service-card-kicker">Privacy</span><h3>Data and privacy</h3><p>Request commercial data export or deletion without leaving the account shell.</p></button></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + "</a>.</div></div></section></div>";
}
function renderSignedOutPortalHTML(context) {
var statusHTML = "";
@@ -127,7 +127,7 @@ describe('shell view', function() {
expect(html).toContain('<h1>Pulse Account</h1>');
expect(html).toContain('Hosted access is active on this account.');
expect(html).toContain('portal-section-nav');
expect(html).toContain('portal-command-strip');
expect(html).toContain('Hosted operations');
expect(html).toContain('Account services');
expect(html).toContain('Support');
@@ -143,18 +143,18 @@ function renderOverviewBand(accounts: PortalAccountSummary[]): string {
function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: string): string {
var hosted = hasHostedAccounts(accounts);
return (
'<nav class="portal-section-nav" aria-label="Pulse Account sections">' +
'<a class="portal-section-link" href="#hosted-operations-section">' +
'<span class="portal-section-link-label">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</span>' +
'<span class="portal-section-link-copy">' + (hosted ? 'Workspaces, teams, and hosted billing' : 'No hosted workspaces are attached yet') + '</span>' +
'<nav class="portal-command-strip" aria-label="Pulse Account sections">' +
'<a class="portal-command-link" href="#hosted-operations-section">' +
'<span class="portal-command-label">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</span>' +
'<span class="portal-command-copy">' + (hosted ? 'Workspaces, teams, and hosted billing' : 'No hosted workspaces are attached yet') + '</span>' +
'</a>' +
'<a class="portal-section-link" href="#account-services-section">' +
'<span class="portal-section-link-label">Account services</span>' +
'<span class="portal-section-link-copy">Licenses, billing, refunds, and privacy</span>' +
'<a class="portal-command-link" href="#account-services-section">' +
'<span class="portal-command-label">Account services</span>' +
'<span class="portal-command-copy">Licenses, billing, refunds, and privacy</span>' +
'</a>' +
'<a class="portal-section-link" href="mailto:' + escapeAttr(supportEmail || '') + '">' +
'<span class="portal-section-link-label">Support</span>' +
'<span class="portal-section-link-copy">' + escapeHTML(supportEmail || '') + '</span>' +
'<a class="portal-command-link portal-command-link-support" href="mailto:' + escapeAttr(supportEmail || '') + '">' +
'<span class="portal-command-label">Support</span>' +
'<span class="portal-command-copy">' + escapeHTML(supportEmail || '') + '</span>' +
'</a>' +
'</nav>'
);
@@ -242,10 +242,9 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
var workspaceManagement = '';
if (account.can_manage) {
actions =
'<div class="account-operations-panel">' +
'<div class="account-panel-copy">' +
'<div class="account-action-strip">' +
'<div class="account-action-copy">' +
'<div class="account-panel-kicker">Account operations</div>' +
'<h3>Run the hosted side from here</h3>' +
'<p>' + escapeHTML(operationsCopy) + '</p>' +
'</div>' +
'<div class="account-actions">' +
@@ -414,33 +413,33 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
'</div>' +
'</div>' +
'<div class="account-surface-body">' +
'<aside class="account-overview-rail">' +
'<div class="account-command-deck">' +
'<div class="account-overview-card">' +
'<div class="account-panel-kicker">Hosted posture</div>' +
'<h3>' + escapeHTML(accountKindLabel(account)) + '</h3>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p>' + escapeHTML(summaryText) + '</p>' +
'</div>' +
'<div class="account-status-grid">' +
'<div class="account-stat-card">' +
'<div class="account-metric-strip">' +
'<div class="account-stat-card account-stat-card-inline">' +
'<span class="account-stat-label">Active workspaces</span>' +
'<span class="account-stat-value">' + String(activeCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<div class="account-stat-card account-stat-card-inline">' +
'<span class="account-stat-label">Healthy</span>' +
'<span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<div class="account-stat-card account-stat-card-inline">' +
'<span class="account-stat-label">Checking</span>' +
'<span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<div class="account-stat-card account-stat-card-inline">' +
'<span class="account-stat-label">Needs attention</span>' +
'<span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + '</span>' +
'</div>' +
'</div>' +
actions +
addWorkspaceForm +
'</aside>' +
'</div>' +
'<div class="account-main-stage">' +
'<div class="account-stage-header">' +
'<div>' +
@@ -503,16 +502,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
return (
'<div class="portal-shell">' +
renderOverviewBand(accounts) +
'<div class="portal-shell-bar">' +
renderShellNavigation(accounts, context.bootstrap.support_email || '') +
'<div class="portal-support-strip">' +
'<div class="portal-support-copy">' +
'<span class="portal-support-label">Support</span>' +
'<span class="portal-support-text">Hosted operations and commercial account tools now share one account surface.</span>' +
'</div>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || '') + '">' + escapeHTML(context.bootstrap.support_email || '') + '</a>' +
'</div>' +
'</div>' +
renderShellNavigation(accounts, context.bootstrap.support_email || '') +
'<section class="portal-top-section" id="hosted-operations-section">' +
'<div class="portal-top-section-header">' +
'<div class="account-panel-kicker">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</div>' +
+86 -108
View File
@@ -243,20 +243,7 @@ header .logout-btn:hover,
letter-spacing: -0.04em;
}
.portal-shell-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: stretch;
}
.portal-section-nav {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.portal-section-link,
.portal-command-strip,
.intro-card,
.service-panel,
.account-surface,
@@ -266,28 +253,36 @@ header .logout-btn:hover,
box-shadow: var(--shadow-sm);
}
.portal-section-link {
.portal-command-strip {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0;
border-radius: var(--radius-md);
}
.portal-command-link {
display: block;
padding: 16px 18px;
border-radius: var(--radius-md);
text-decoration: none;
transition: transform 140ms ease, border-color 140ms ease, box-shadow 140ms ease;
transition: background 140ms ease;
}
.portal-section-link:hover {
transform: translateY(-1px);
border-color: var(--accent);
box-shadow: var(--shadow-md);
.portal-command-link + .portal-command-link {
border-left: 1px solid var(--line);
}
.portal-section-link-label {
.portal-command-link:hover {
background: #f7fafc;
}
.portal-command-label {
display: block;
font-size: 14px;
font-weight: 700;
margin-bottom: 5px;
}
.portal-section-link-copy {
.portal-command-copy {
display: block;
font-size: 13px;
line-height: 1.5;
@@ -301,38 +296,8 @@ header .logout-btn:hover,
font-weight: 600;
}
.portal-support-strip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
min-width: 340px;
padding: 16px 18px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-sm);
}
.portal-support-copy {
display: flex;
flex-direction: column;
gap: 4px;
}
.portal-support-label {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--ink-muted);
}
.portal-support-text {
max-width: 280px;
font-size: 13px;
line-height: 1.5;
color: var(--ink-soft);
.portal-command-link-support {
text-align: right;
}
.portal-stage {
@@ -423,16 +388,9 @@ header .logout-btn:hover,
margin-top: 22px;
}
.account-overview-rail {
display: grid;
grid-template-columns: minmax(280px, 0.95fr) minmax(0, 1.15fr);
gap: 14px;
align-items: start;
}
.account-command-deck,
.account-overview-card,
.account-stat-card,
.account-operations-panel,
.workspace-row,
.workspace-management-panel,
.team-management-panel,
@@ -459,7 +417,6 @@ header .logout-btn:hover,
.account-overview-card h3,
.account-stage-header h3,
.account-panel-copy h3,
.workspace-management-header h3,
.team-management-header h3,
.service-card h3,
@@ -473,7 +430,6 @@ header .logout-btn:hover,
.account-overview-card p,
.account-stage-header p,
.account-panel-copy p,
.workspace-management-header p,
.team-management-header p,
.team-panel-heading p,
@@ -486,24 +442,23 @@ header .logout-btn:hover,
color: var(--ink-soft);
}
.account-status-grid,
.team-management-stats {
.account-command-deck {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-columns: minmax(280px, 1.05fr) minmax(0, 1.35fr);
gap: 14px;
align-items: stretch;
}
.account-metric-strip {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.account-overview-card {
grid-column: 1;
}
.account-status-grid {
grid-column: 2;
grid-row: 1 / span 2;
}
.account-stat-card {
min-height: 108px;
.team-management-stats {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.account-stat-label,
@@ -530,12 +485,25 @@ header .logout-btn:hover,
.account-stat-unhealthy,
.team-stat-error { color: var(--danger); }
.account-operations-panel {
.account-action-strip {
display: flex;
flex-direction: column;
gap: 16px;
background: var(--panel);
grid-column: 1;
align-items: start;
justify-content: space-between;
gap: 18px;
grid-column: 1 / -1;
padding: 16px 18px 0;
border-top: 1px solid var(--line);
}
.account-action-copy {
max-width: 720px;
}
.account-action-copy p {
margin: 8px 0 0;
font-size: 14px;
line-height: 1.6;
color: var(--ink-soft);
}
.add-workspace-form {
@@ -549,7 +517,7 @@ header .logout-btn:hover,
}
.account-stage-header {
padding: 6px 4px 0;
padding: 2px 4px 0;
}
.workspace-list {
@@ -563,7 +531,7 @@ header .logout-btn:hover,
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
align-items: center;
padding: 18px;
padding: 20px;
}
.workspace-row-main {
@@ -629,7 +597,7 @@ header .logout-btn:hover,
display: flex;
flex-direction: column;
gap: 8px;
min-height: 84px;
min-height: 74px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
@@ -754,14 +722,20 @@ header .logout-btn:hover,
border-color: var(--accent);
}
.team-stat-card {
min-width: 140px;
padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--panel-muted);
}
.team-management-stats {
margin: 16px 0;
}
.team-stat-card {
padding: 16px;
border-radius: var(--radius-sm);
background: var(--panel-muted);
.team-stat-value {
margin-top: 6px;
font-size: 22px;
}
.team-management-grid {
@@ -787,12 +761,12 @@ header .logout-btn:hover,
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink-muted);
border-bottom: 1px solid rgba(203, 183, 153, 0.7);
border-bottom: 1px solid var(--line);
}
.team-table td {
padding: 12px 8px;
border-bottom: 1px solid rgba(223, 210, 189, 0.55);
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
@@ -1034,21 +1008,13 @@ header .logout-btn:hover,
grid-template-columns: 1fr;
}
.portal-shell-bar {
.portal-command-strip,
.account-command-deck {
grid-template-columns: 1fr;
}
.portal-support-strip {
min-width: 0;
}
.account-overview-rail {
grid-template-columns: 1fr;
}
.account-status-grid {
grid-column: auto;
grid-row: auto;
.account-metric-strip {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.workspace-management-content {
@@ -1088,9 +1054,7 @@ header .logout-btn:hover,
padding: 18px 16px 40px;
}
.portal-section-nav,
.account-status-grid,
.team-management-stats {
.portal-command-strip {
grid-template-columns: 1fr;
}
@@ -1109,6 +1073,20 @@ header .logout-btn:hover,
align-items: flex-start;
}
.portal-command-link + .portal-command-link {
border-left: none;
border-top: 1px solid var(--line);
}
.portal-command-link-support {
text-align: left;
}
.account-action-strip {
flex-direction: column;
align-items: stretch;
}
.workspace-actions,
.workspace-actions form,
.workspace-actions .btn-primary,