diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index f0238d9029..85b07804d3 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -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 diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index c3bd792abf..9c37602b83 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "1c0e03c2843117f80f2e4854bdc42da2385c33944b2fef1653a5f8bbbbccbab6", + "source_hash": "ade0e3202886602f0f46ca656041930d213b8dc6ea3d681664a6596f18da0c12", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index 46337e8dc0..5b687d41c9 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -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; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 29871c090e..a047a76689 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -1441,6 +1441,39 @@ } return 'Checking'; } + 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 '
' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + "

" + title + "

" + summary + '

Hosted access' + hostedStateLabel + '
Accounts' + accountsLabel + '
Workspaces' + String(workspaceTotal) + "
"; + } 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 = '

No workspaces yet. Create one to get started.

'; + workspaceHTML = '

No hosted workspaces yet. Create one to get started.

'; } else { workspaceHTML = '
' + workspaces.map(function(workspace) { return renderWorkspaceCard(account, workspace, accountAPIBasePath); @@ -1480,7 +1514,7 @@ addWorkspaceForm = '
'; } } - return '
" + workspaceHTML + actions + teamSection + addWorkspaceForm + "
"; + return '
' + workspaceHTML + actions + teamSection + addWorkspaceForm + "
"; } 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 '

No workspaces found. If you just signed up, check your email for setup instructions.

Need help? Contact ' + escapeHTML(context.bootstrap.support_email || "") + "

"; + return '

No hosted workspaces are attached to this account.

You can still use the self-hosted licensing and billing tools below.

Need help? Contact ' + escapeHTML(context.bootstrap.support_email || "") + "

"; } return safeAccounts.map(function(account) { return renderAccountSection(account, context.accountAPIBasePath); }).join(""); } function renderAuthenticatedPortalHTML(context) { - return '

Pulse Account

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.

' + renderAccountsHTML(context) + '

Other account services

Self-hosted commercial account actions now live here. The public utility pages remain as compatibility entry points, not the primary account surface.

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + ".
"; + 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) + '
' + renderAccountsHTML(context) + '

' + serviceHeading + '

' + serviceNote + '

Data and privacy

Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.

Payment-card data stays with Stripe. For Stripe deletion support, contact ' + escapeHTML(context.bootstrap.support_email || "") + ".
"; } function renderSignedOutPortalHTML(context) { var statusHTML = ""; diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 458885471f..7315a4a674 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -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('

Pulse Account

'); + 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('

Self-hosted Pulse Account

'); + 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({ diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 80adb94e78..e9df631429 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -49,6 +49,70 @@ function healthBadgeHTML(workspace: PortalWorkspaceSummary): string { return 'Checking'; } +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 ( + '
' + + '
' + + '
' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '
' + + '

' + title + '

' + + '

' + summary + '

' + + '
' + + '
' + + '
' + + 'Hosted access' + + '' + hostedStateLabel + '' + + '
' + + '
' + + 'Accounts' + + '' + accountsLabel + '' + + '
' + + '
' + + 'Workspaces' + + '' + String(workspaceTotal) + '' + + '
' + + '
' + + '
' + ); +} + 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 = '

No workspaces yet. Create one to get started.

'; + workspaceHTML = '

No hosted workspaces yet. Create one to get started.

'; } else { workspaceHTML = '
' + @@ -223,9 +288,19 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: return ( '