From 55f030d11e96309958e373a2711b1841cd51195b Mon Sep 17 00:00:00 2001 From: rcourtman Date: Sat, 28 Mar 2026 19:49:05 +0000 Subject: [PATCH] Make self-hosted portal nav honest --- .../v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md | 3 ++- .../v6/internal/subsystems/api-contracts.md | 4 +++- .../release-control/v6/internal/subsystems/cloud-paid.md | 4 +++- internal/cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.js | 3 ++- internal/cloudcp/portal/frontend/src/shell_view.test.ts | 5 +++++ internal/cloudcp/portal/frontend/src/shell_view.ts | 9 +++++---- 7 files changed, 21 insertions(+), 9 deletions(-) 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 bb7f8c637..2ecd97f33 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -159,7 +159,8 @@ Core rules: current account set, not a repeated per-account dashboard. 17. When `Workspaces` or `Access` are unavailable, the tab must show an explicit unavailable state that points to the right live task instead of - rendering blank space. + rendering blank space. The shell navigation must stay honest too: it must + not badge or describe unavailable hosted tasks as though they are live. 18. `Access` must stay action-first: roster, invite, role change, and remove access are the job; view-only users may review the roster but must never see live controls that imply they can mutate it. diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 58b6bdbcf..8ecc7f251 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -293,7 +293,9 @@ self-hosted commercial history is relevant to the signed-in account, so hosted-only accounts do not render self-hosted license, refund, privacy, or self-hosted escalation paths by default, and self-hosted-only accounts do not front-load an empty hosted-billing block before the real self-hosted jobs. -`Support` follows the same +The same account-shape runtime contract must also keep the shell navigation +honest: unavailable hosted `Workspaces` or `Access` tasks must read as +unavailable in the nav itself rather than implying live hosted work. `Support` follows the same account-shape runtime contract: self-hosted-only accounts expose only the billing escalation path and billing-specific handoff packet, and hosted workspace/access escalation controls must not render when no hosted account diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index d03a640c1..8a175ec22 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -827,7 +827,9 @@ standalone Relay portal. That shell now also owns two product rules explicitly: `Overview` is one shell-level triage surface across the current account set rather than a repeated per-account dashboard, and unavailable top-level `Workspaces` or `Access` tasks must render an explicit unavailable -state instead of blank space. The same shell also owns action-first task +state instead of blank space. The shell navigation must stay honest to that +same account shape rather than labelling unavailable hosted tasks as though +they are still live. The same shell also owns action-first task surfaces for `Access`, `Billing`, and `Support`: access mutations must be permission-honest and roster-led, billing must reduce to one obvious job at a time with hosted billing first when relevant, support must stay a failed-path diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index df73e4eb2..631792236 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "f417480ba4d79a766c8bb7511a50e02f825cb2199eb6961c618b5a9429d63975", + "source_hash": "176432adedcb3aa8f1e151678f7e93e3db683ef6029b8d6b60a04264e2bff8fd", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 5ba82c77c..25722f272 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -2038,6 +2038,7 @@ } function renderShellNavigation(accounts, supportEmail, activeSection) { var hosted = hasHostedAccounts(accounts); + var selfHostedOnly = !hosted; var workspaces = collectWorkspaces(accounts); var totalWorkspaces = workspaces.length; var readyWorkspaces = countReadyWorkspaces(workspaces); @@ -2052,7 +2053,7 @@ hostedBillingCount += 1; } } - return '"; + return '"; } function renderWorkspaceCard(account, workspace, accountAPIBasePath) { var status = workspaceHealthState(workspace); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index a6ea467fe..df79e3683 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -397,6 +397,11 @@ describe('shell view', function() { expect(html).toContain('Account tasks'); expect(html).toContain('Self-hosted'); expect(html).toContain('Account triage'); + expect(html).toMatch(/Workspaces[\s\S]*Unavailable on this account\. Hosted workspaces are not attached here\./); + expect(html).toContain('Unavailable on this account. Hosted workspaces are not attached here.'); + expect(html).toMatch(/Access[\s\S]*Unavailable on this account\. Hosted roster and role controls live only on hosted workspace accounts\./); + expect(html).toContain('Unavailable on this account. Hosted roster and role controls live only on hosted workspace accounts.'); + expect(html).toMatch(/Support[\s\S]*Escalation only after the billing path is exhausted\./); expect(html).toContain('No hosted account'); expect(html).toContain('Billing tools are ready'); expect(html).toContain('There is nothing to open or manage here yet.'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index bcf248af1..450917076 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -360,6 +360,7 @@ function shellSectionButton(section: PortalShellSection, activeSection: PortalSh function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: string, activeSection: PortalShellSection): string { var hosted = hasHostedAccounts(accounts); + var selfHostedOnly = !hosted; var workspaces = collectWorkspaces(accounts); var totalWorkspaces = workspaces.length; var readyWorkspaces = countReadyWorkspaces(workspaces); @@ -379,14 +380,14 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s '
' + '
Pulse Account
' + '
Account tasks
' + - '
' + (hosted ? 'Start with the job you need to finish: workspace work, access, billing, then escalation.' : 'Use billing tools first and escalate only when the self-serve path stops.' ) + '
' + + '
' + (hosted ? 'Start with the job you need to finish: workspace work, access, billing, then escalation.' : 'Use billing tools first and escalate only when the self-serve path stops.') + '
' + '
' + '
' + shellSectionButton('overview', activeSection, '01', 'Overview', 'What needs attention, what is ready, and the next obvious action.', attentionCount > 0 ? String(attentionCount) + ' review' : (hosted ? String(readyWorkspaces) + ' ready' : 'Summary')) + - shellSectionButton('workspaces', activeSection, '02', 'Workspaces', hosted ? 'Open a workspace, review lifecycle state, or create one.' : 'No hosted workspaces are attached yet.', hosted ? String(readyWorkspaces) + ' ready' : 'None') + - shellSectionButton('access', activeSection, '03', 'Access', hosted ? 'Invite people, change roles, and remove account access.' : 'Account access and membership controls.', canManage ? 'Manage' : 'View') + + shellSectionButton('workspaces', activeSection, '02', 'Workspaces', hosted ? 'Open a workspace, review lifecycle state, or create one.' : 'Unavailable on this account. Hosted workspaces are not attached here.', hosted ? String(readyWorkspaces) + ' ready' : 'Unavailable') + + shellSectionButton('access', activeSection, '03', 'Access', hosted ? 'Invite people, change roles, and remove account access.' : 'Unavailable on this account. Hosted roster and role controls live only on hosted workspace accounts.', hosted ? (canManage ? 'Manage' : 'View') : 'Unavailable') + shellSectionButton('billing', activeSection, '04', 'Billing', hostedBillingCount > 0 ? 'Hosted billing first, then self-hosted licenses, refunds, and privacy only when relevant.' : 'Self-hosted billing, licenses, refunds, and privacy.', hostedBillingCount > 0 ? (hostedBillingCount > 1 ? 'Hosted +' : 'Hosted') : 'Self-hosted') + - shellSectionButton('support', activeSection, '05', 'Support', 'Escalation only after the workspace, access, or billing path is exhausted.', supportEmail ? 'Email' : 'Help') + + shellSectionButton('support', activeSection, '05', 'Support', selfHostedOnly ? 'Escalation only after the billing path is exhausted.' : 'Escalation only after the workspace, access, or billing path is exhausted.', supportEmail ? 'Email' : 'Help') + '
' + '' );