feat(portal): add role-aware account overview

This commit is contained in:
rcourtman
2026-03-27 13:51:12 +00:00
parent 27507a47ad
commit 8074221f1c
7 changed files with 354 additions and 36 deletions
@@ -113,6 +113,56 @@ Conditional areas:
2. `Organization / Tenant Admin`
Only for hosted accounts that need browser-side workspace lifecycle actions.
## Experience Principles
The portal must feel like one deliberate product, not a stitched-together set
of utilities.
Core rules:
1. The first screen must explain what this account can do right now.
2. Hosted and self-hosted users must not land on the same undifferentiated
wall of copy.
3. A self-hosted-only user should immediately see that hosted workspaces are
absent by entitlement, not broken or missing.
4. A hosted Cloud or MSP user should immediately see their workspace access,
role, and account-management surface before lower-priority self-hosted
utility actions.
5. Generic overflow icons are forbidden when the only behavior behind them is
a destructive action.
6. Primary actions must be labeled with the real outcome, for example
`Open workspace`, `Manage team`, or `Suspend workspace`.
7. Self-hosted billing/license tools belong in the same account shell, but
they must present as a secondary account-services area when hosted access
is active.
## Screen Model
The signed-in shell should be treated as four first-class states:
1. `Self-hosted account`
No hosted workspaces. The page should lead with account services,
subscription/license state, and clear messaging that no hosted workspace
access is attached to this account.
2. `Hosted customer account`
One or more hosted workspaces. The page should lead with workspace access,
hosted billing, and team/admin actions.
3. `MSP operator account`
Multi-workspace hosted account. The page should lead with the workspace
fleet, management actions, and operator/team controls.
4. `Mixed account`
Hosted access plus self-hosted commercial history. The page should still
lead with hosted access, while the self-hosted commercial tools remain
available in a secondary area.
Each signed-in state should render:
1. a concise overview band that summarizes the account posture
2. account cards with role, account kind, and workspace totals
3. explicit action groups, not anonymous menu affordances
4. a clearly separated `Self-hosted licenses and billing` section when those
tools are present below hosted content
## Product-Specific Boundaries
### Self-hosted Pulse
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6",
"source_hash": "ade0e3202886602f0f46ca656041930d213b8dc6ea3d681664a6596f18da0c12",
"build_inputs": [
"package.json",
"tsconfig.json",
+123 -13
View File
@@ -49,7 +49,7 @@ header .logout-btn:hover {
color: #f8fafc;
}
.main {
max-width: 860px;
max-width: 980px;
margin: 32px auto;
padding: 0 16px 48px;
}
@@ -58,15 +58,58 @@ header .logout-btn:hover {
}
.account-header {
display: flex;
align-items: center;
gap: 12px;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.account-header h2 {
margin: 0;
font-size: 20px;
font-size: 22px;
font-weight: 700;
}
.account-heading {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.account-eyebrow {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #64748b;
}
.account-summary {
font-size: 14px;
color: #475569;
}
.account-badges {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
justify-content: flex-end;
}
.account-subsection-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 16px;
margin: 0 0 14px;
}
.account-subsection-header h3 {
margin: 0;
font-size: 15px;
font-weight: 700;
}
.account-subsection-header p {
margin: 0;
font-size: 13px;
color: #64748b;
max-width: 520px;
}
.badge {
font-size: 11px;
font-weight: 600;
@@ -387,29 +430,76 @@ header .logout-btn:hover {
font-size: 14px;
background: #fff;
}
.intro-card {
.overview-band {
background:
linear-gradient(
145deg,
#fff,
#f8fafc);
border: 1px solid #dbe4f0;
border-radius: 16px;
padding: 22px 24px;
border-radius: 18px;
padding: 24px;
margin-bottom: 24px;
box-shadow: 0 10px 24px rgba(15, 23, 42, .05);
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr);
gap: 20px;
}
.intro-card h1 {
margin: 0 0 8px;
font-size: 26px;
line-height: 1.1;
.overview-copy h1 {
margin: 4px 0 8px;
font-size: 30px;
line-height: 1.05;
letter-spacing: -0.03em;
}
.intro-card p {
.overview-copy p {
margin: 0;
color: #475569;
font-size: 15px;
line-height: 1.6;
max-width: 760px;
max-width: 680px;
}
.overview-kicker {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #1d4ed8;
}
.overview-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
align-self: stretch;
}
.overview-stat-card {
background: rgba(248, 250, 252, 0.9);
border: 1px solid #dbe4f0;
border-radius: 14px;
padding: 16px;
display: flex;
flex-direction: column;
gap: 6px;
justify-content: space-between;
min-height: 110px;
}
.overview-stat-label {
font-size: 12px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #64748b;
}
.overview-stat-value {
font-size: 20px;
font-weight: 800;
letter-spacing: -0.03em;
color: #0f172a;
}
.overview-stat-positive {
color: #166534;
}
.overview-stat-muted {
color: #475569;
}
.service-section {
margin-top: 40px;
@@ -692,6 +782,26 @@ header .logout-btn:hover {
.toast.error {
background: #991b1b;
}
@media (max-width: 900px) {
.overview-band {
grid-template-columns: 1fr;
}
}
@media (max-width: 720px) {
.account-header {
flex-direction: column;
}
.account-badges {
justify-content: flex-start;
}
.account-subsection-header {
flex-direction: column;
align-items: flex-start;
}
.overview-stats {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.workspace-card {
flex-direction: column;
+41 -4
View File
@@ -1441,6 +1441,39 @@
}
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceCountLabel(count) {
return count === 1 ? "1 workspace" : String(count) + " workspaces";
}
function accountKindLabel(account) {
if (account.kind === "msp") return "MSP account";
if (account.kind === "cloud") return "Cloud account";
if (account.kind === "individual") return "Hosted account";
return account.kind_label ? account.kind_label + " account" : "Account";
}
function titleCase(value) {
if (!value) return "";
return value.charAt(0).toUpperCase() + value.slice(1);
}
function countWorkspaces(accounts) {
var total = 0;
for (var i = 0; i < accounts.length; i += 1) {
total += Array.isArray(accounts[i].workspaces) ? accounts[i].workspaces.length : 0;
}
return total;
}
function hasHostedAccounts(accounts) {
return accounts.length > 0;
}
function renderOverviewBand(context, accounts) {
var hosted = hasHostedAccounts(accounts);
var workspaceTotal = countWorkspaces(accounts);
var title = hosted ? "Pulse Account" : "Self-hosted Pulse Account";
var summary = hosted ? "Open hosted workspaces, manage account access, and handle commercial account services from one place." : "This account currently has no hosted workspaces. Self-hosted licenses, billing, refunds, and data requests stay available below.";
var hostedStateLabel = hosted ? "Active" : "Not attached";
var hostedStateClass = hosted ? "overview-stat-positive" : "overview-stat-muted";
var accountsLabel = accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts";
return '<section class="overview-band"><div class="overview-copy"><div class="overview-kicker">' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + "</div><h1>" + title + "</h1><p>" + summary + '</p></div><div class="overview-stats"><div class="overview-stat-card"><span class="overview-stat-label">Hosted access</span><span class="overview-stat-value ' + hostedStateClass + '">' + hostedStateLabel + '</span></div><div class="overview-stat-card"><span class="overview-stat-label">Accounts</span><span class="overview-stat-value">' + accountsLabel + '</span></div><div class="overview-stat-card"><span class="overview-stat-label">Workspaces</span><span class="overview-stat-value">' + String(workspaceTotal) + "</span></div></div></section>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var state = String(workspace.state || "");
var safeState = escapeHTML(state);
@@ -1462,9 +1495,10 @@
}
function renderAccountSection(account, accountAPIBasePath) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
var summaryText = accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + workspaceCountLabel(workspaces.length);
var workspaceHTML = "";
if (workspaces.length === 0) {
workspaceHTML = '<div class="empty-state"><p>No workspaces yet. Create one to get started.</p></div>';
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
} else {
workspaceHTML = '<div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
@@ -1480,7 +1514,7 @@
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (e.g. client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
}
}
return '<section class="account-section"><div class="account-header"><h2>' + escapeHTML(account.name) + '</h2><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + "</div>" + workspaceHTML + actions + teamSection + addWorkspaceForm + "</section>";
return '<section class="account-section"><div class="account-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-subsection-header"><h3>Workspaces</h3><p>Open hosted Pulse workspaces and manage lifecycle actions for this account.</p></div>' + workspaceHTML + actions + teamSection + addWorkspaceForm + "</section>";
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
@@ -1491,14 +1525,17 @@
function renderAccountsHTML(context) {
var safeAccounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
if (safeAccounts.length === 0) {
return '<div class="empty-state empty-state-spaced"><p>No workspaces found. If you just signed up, check your email for setup instructions.</p><p class="support-copy">Need help? Contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '" class="support-link">' + escapeHTML(context.bootstrap.support_email || "") + "</a></p></div>";
return '<div class="empty-state empty-state-spaced"><p>No hosted workspaces are attached to this account.</p><p class="support-copy">You can still use the self-hosted licensing and billing tools below.</p><p class="support-copy">Need help? Contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '" class="support-link">' + escapeHTML(context.bootstrap.support_email || "") + "</a></p></div>";
}
return safeAccounts.map(function(account) {
return renderAccountSection(account, context.accountAPIBasePath);
}).join("");
}
function renderAuthenticatedPortalHTML(context) {
return '<section class="intro-card"><h1>Pulse Account</h1><p>Manage Cloud workspaces, MSP access, and self-hosted commercial account services from one account surface. Hosted workspace lifecycle lives here today, and the self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell instead of staying fragmented across public utility pages.</p></section><div id="accounts-root">' + renderAccountsHTML(context) + '</div><section class="service-section"><div class="service-header"><h2>Other account services</h2><div class="service-note">Self-hosted commercial account actions now live here. The public utility pages remain as compatibility entry points, not the primary account surface.</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"><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"><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"><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"><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>";
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var serviceHeading = hasHostedAccounts(accounts) ? "Self-hosted licenses and billing" : "Account services";
var serviceNote = hasHostedAccounts(accounts) ? "Hosted access lives above. The self-hosted commercial tools remain available here for licenses, billing, refunds, and privacy actions." : "Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.";
return renderOverviewBand(context, accounts) + '<div id="accounts-root">' + renderAccountsHTML(context) + '</div><section class="service-section"><div class="service-header"><h2>' + serviceHeading + '</h2><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"><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"><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"><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"><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>";
}
function renderSignedOutPortalHTML(context) {
var statusHTML = "";
@@ -74,7 +74,8 @@ describe('shell view', function() {
it('renders empty accounts state with support contact', function() {
var html = renderAccountsHTML(createContext());
expect(html).toContain('No workspaces found.');
expect(html).toContain('No hosted workspaces are attached to this account.');
expect(html).toContain('self-hosted licensing and billing tools below');
expect(html).toContain('mailto:support@pulserelay.pro');
expect(html).toContain('support@pulserelay.pro');
});
@@ -124,7 +125,11 @@ describe('shell view', function() {
);
expect(html).toContain('<h1>Pulse Account</h1>');
expect(html).toContain('Hosted access is active on this account.');
expect(html).toContain('Self-hosted licenses and billing');
expect(html).toContain('id="accounts-root"');
expect(html).toContain('MSP account');
expect(html).toContain('MSP account · Owner · 3 workspaces');
expect(html).toContain('Acme MSP');
expect(html).toContain('Alpha Workspace');
expect(html).toContain('Beta Workspace');
@@ -141,6 +146,21 @@ describe('shell view', function() {
expect(html).toContain('id="data-service-panel"');
});
it('renders self-hosted overview copy when no hosted accounts are attached', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
accounts: [],
}),
})
);
expect(html).toContain('<h1>Self-hosted Pulse Account</h1>');
expect(html).toContain('No hosted workspace access is attached to this account yet.');
expect(html).toContain('Account services');
expect(html).not.toContain('Self-hosted licenses and billing');
});
it('renders signed-out portal with error and success login states', function() {
var errorHTML = renderSignedOutPortalHTML(
createContext({
@@ -49,6 +49,70 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceCountLabel(count: number): string {
return count === 1 ? '1 workspace' : String(count) + ' workspaces';
}
function accountKindLabel(account: PortalAccountSummary): string {
if (account.kind === 'msp') return 'MSP account';
if (account.kind === 'cloud') return 'Cloud account';
if (account.kind === 'individual') return 'Hosted account';
return account.kind_label ? account.kind_label + ' account' : 'Account';
}
function titleCase(value: string): string {
if (!value) return '';
return value.charAt(0).toUpperCase() + value.slice(1);
}
function countWorkspaces(accounts: PortalAccountSummary[]): number {
var total = 0;
for (var i = 0; i < accounts.length; i += 1) {
total += Array.isArray(accounts[i].workspaces) ? accounts[i].workspaces.length : 0;
}
return total;
}
function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
return accounts.length > 0;
}
function renderOverviewBand(context: ShellViewContext, accounts: PortalAccountSummary[]): string {
var hosted = hasHostedAccounts(accounts);
var workspaceTotal = countWorkspaces(accounts);
var title = hosted ? 'Pulse Account' : 'Self-hosted Pulse Account';
var summary = hosted
? 'Open hosted workspaces, manage account access, and handle commercial account services from one place.'
: 'This account currently has no hosted workspaces. Self-hosted licenses, billing, refunds, and data requests stay available below.';
var hostedStateLabel = hosted ? 'Active' : 'Not attached';
var hostedStateClass = hosted ? 'overview-stat-positive' : 'overview-stat-muted';
var accountsLabel = accounts.length === 1 ? '1 account' : String(accounts.length) + ' accounts';
return (
'<section class="overview-band">' +
'<div class="overview-copy">' +
'<div class="overview-kicker">' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '</div>' +
'<h1>' + title + '</h1>' +
'<p>' + summary + '</p>' +
'</div>' +
'<div class="overview-stats">' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Hosted access</span>' +
'<span class="overview-stat-value ' + hostedStateClass + '">' + hostedStateLabel + '</span>' +
'</div>' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Accounts</span>' +
'<span class="overview-stat-value">' + accountsLabel + '</span>' +
'</div>' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Workspaces</span>' +
'<span class="overview-stat-value">' + String(workspaceTotal) + '</span>' +
'</div>' +
'</div>' +
'</section>'
);
}
function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWorkspaceSummary, accountAPIBasePath: string): string {
var state = String(workspace.state || '');
var safeState = escapeHTML(state);
@@ -125,9 +189,10 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: string): string {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
var summaryText = accountKindLabel(account) + ' · ' + titleCase(account.role) + ' · ' + workspaceCountLabel(workspaces.length);
var workspaceHTML = '';
if (workspaces.length === 0) {
workspaceHTML = '<div class="empty-state"><p>No workspaces yet. Create one to get started.</p></div>';
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
} else {
workspaceHTML =
'<div class="workspace-list">' +
@@ -223,9 +288,19 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
return (
'<section class="account-section">' +
'<div class="account-header">' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
'<span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + '</span>' +
roleBadgeHTML(account.role) +
'<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-subsection-header">' +
'<h3>Workspaces</h3>' +
'<p>Open hosted Pulse workspaces and manage lifecycle actions for this account.</p>' +
'</div>' +
workspaceHTML +
actions +
@@ -250,7 +325,8 @@ export function renderAccountsHTML(context: ShellViewContext): string {
if (safeAccounts.length === 0) {
return (
'<div class="empty-state empty-state-spaced">' +
'<p>No workspaces found. If you just signed up, check your email for setup instructions.</p>' +
'<p>No hosted workspaces are attached to this account.</p>' +
'<p class="support-copy">You can still use the self-hosted licensing and billing tools below.</p>' +
'<p class="support-copy">Need help? Contact <a href="mailto:' +
escapeAttr(context.bootstrap.support_email || '') +
'" class="support-link">' +
@@ -265,16 +341,18 @@ export function renderAccountsHTML(context: ShellViewContext): string {
}
export function renderAuthenticatedPortalHTML(context: ShellViewContext): string {
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var serviceHeading = hasHostedAccounts(accounts) ? 'Self-hosted licenses and billing' : 'Account services';
var serviceNote = hasHostedAccounts(accounts)
? 'Hosted access lives above. The self-hosted commercial tools remain available here for licenses, billing, refunds, and privacy actions.'
: 'Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.';
return (
'<section class="intro-card">' +
'<h1>Pulse Account</h1>' +
'<p>Manage Cloud workspaces, MSP access, and self-hosted commercial account services from one account surface. Hosted workspace lifecycle lives here today, and the self-hosted billing, license recovery, refund, and privacy tools below now share the same Pulse Account shell instead of staying fragmented across public utility pages.</p>' +
'</section>' +
renderOverviewBand(context, accounts) +
'<div id="accounts-root">' + renderAccountsHTML(context) + '</div>' +
'<section class="service-section">' +
'<div class="service-header">' +
'<h2>Other account services</h2>' +
'<div class="service-note">Self-hosted commercial account actions now live here. The public utility pages remain as compatibility entry points, not the primary account surface.</div>' +
'<h2>' + serviceHeading + '</h2>' +
'<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">' +
@@ -6,10 +6,17 @@
header .user-info { display: flex; align-items: center; gap: 16px; font-size: 13px; color: #94a3b8; }
header .logout-btn { background: none; border: 1px solid #475569; color: #94a3b8; border-radius: 6px; padding: 5px 12px; cursor: pointer; font-size: 13px; }
header .logout-btn:hover { border-color: #94a3b8; color: #f8fafc; }
.main { max-width: 860px; margin: 32px auto; padding: 0 16px 48px; }
.main { max-width: 980px; margin: 32px auto; padding: 0 16px 48px; }
.account-section { margin-bottom: 40px; }
.account-header { display: flex; align-items: center; gap: 12px; margin-bottom: 16px; }
.account-header h2 { margin: 0; font-size: 20px; font-weight: 700; }
.account-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.account-header h2 { margin: 0; font-size: 22px; font-weight: 700; }
.account-heading { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.account-eyebrow { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #64748b; }
.account-summary { font-size: 14px; color: #475569; }
.account-badges { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; justify-content: flex-end; }
.account-subsection-header { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin: 0 0 14px; }
.account-subsection-header h3 { margin: 0; font-size: 15px; font-weight: 700; }
.account-subsection-header p { margin: 0; font-size: 13px; color: #64748b; max-width: 520px; }
.badge { font-size: 11px; font-weight: 600; padding: 2px 8px; border-radius: 999px; text-transform: uppercase; letter-spacing: 0.5px; }
.badge-msp { background: #dbeafe; color: #1e40af; }
.badge-cloud { background: #dcfce7; color: #166534; }
@@ -67,9 +74,16 @@
.team-invite label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 4px; }
.team-invite input { border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; min-width: 200px; }
.team-invite select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 8px 10px; font-size: 14px; background: #fff; }
.intro-card { background: linear-gradient(145deg, #fff, #f8fafc); border: 1px solid #dbe4f0; border-radius: 16px; padding: 22px 24px; margin-bottom: 24px; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
.intro-card h1 { margin: 0 0 8px; font-size: 26px; line-height: 1.1; }
.intro-card p { margin: 0; color: #475569; font-size: 15px; line-height: 1.6; max-width: 760px; }
.overview-band { background: linear-gradient(145deg, #fff, #f8fafc); border: 1px solid #dbe4f0; border-radius: 18px; padding: 24px; margin-bottom: 24px; box-shadow: 0 10px 24px rgba(15,23,42,.05); display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(280px, 1fr); gap: 20px; }
.overview-copy h1 { margin: 4px 0 8px; font-size: 30px; line-height: 1.05; letter-spacing: -0.03em; }
.overview-copy p { margin: 0; color: #475569; font-size: 15px; line-height: 1.6; max-width: 680px; }
.overview-kicker { font-size: 12px; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; color: #1d4ed8; }
.overview-stats { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; align-self: stretch; }
.overview-stat-card { background: rgba(248, 250, 252, 0.9); border: 1px solid #dbe4f0; border-radius: 14px; padding: 16px; display: flex; flex-direction: column; gap: 6px; justify-content: space-between; min-height: 110px; }
.overview-stat-label { font-size: 12px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: #64748b; }
.overview-stat-value { font-size: 20px; font-weight: 800; letter-spacing: -0.03em; color: #0f172a; }
.overview-stat-positive { color: #166534; }
.overview-stat-muted { color: #475569; }
.service-section { margin-top: 40px; }
.service-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 14px; flex-wrap: wrap; }
.service-header h2 { margin: 0; font-size: 20px; font-weight: 700; }
@@ -121,6 +135,15 @@
.toast.visible { display: block; animation: fadein 0.2s; }
@keyframes fadein { from { opacity: 0; transform: translateY(8px); } }
.toast.error { background: #991b1b; }
@media (max-width: 900px) {
.overview-band { grid-template-columns: 1fr; }
}
@media (max-width: 720px) {
.account-header { flex-direction: column; }
.account-badges { justify-content: flex-start; }
.account-subsection-header { flex-direction: column; align-items: flex-start; }
.overview-stats { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
.workspace-card { flex-direction: column; align-items: flex-start; }
.ws-actions { align-self: stretch; }