mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-10-23 20:25:56 +00:00
feat(portal): add role-aware account overview
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6",
|
||||
"source_hash": "ade0e3202886602f0f46ca656041930d213b8dc6ea3d681664a6596f18da0c12",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+123
-13
@@ -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
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user