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 7f9fc2294..fa3343f34 100644
--- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md
+++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md
@@ -221,6 +221,15 @@ Core rules:
must describe what the current user can actually do on the account, not
restate the account's full hosted capability set when access or billing
changes require an owner or admin.
+31. Hosted `Support` must stay permission-honest too. View-only hosted users
+ may be sent back to `Workspaces`, `Access`, or `Billing` only as review
+ and owner/admin handoff paths; `Support` must not imply they can perform
+ hosted lifecycle, access-mutation, or hosted-billing changes themselves
+ before escalation.
+32. `Overview` must keep billing cues honest to account shape and permission.
+ Hosted-only accounts may not mention self-hosted billing utilities by
+ default, and hosted view-only roles must say when owner/admin authority is
+ still required to open hosted billing.
## Screen Model
@@ -261,7 +270,9 @@ Each signed-in state should render:
`Support` should collapse to failed-path routing plus the minimum escalation
packet needed for handoff. Self-hosted-only accounts must collapse further
to the self-hosted billing escalation path only and must not surface hosted
- workspace or access routes.
+ workspace or access routes. Hosted view-only accounts must keep those hosted
+ routes in review-plus-owner/admin-handoff language rather than implying live
+ mutation authority.
7. explicit action groups, not anonymous menu affordances
8. explicit unavailable-state panels for top-level tasks that are not active
for the current account shape
diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md
index 68fd7f12b..942f08a2c 100644
--- a/docs/release-control/v6/internal/subsystems/api-contracts.md
+++ b/docs/release-control/v6/internal/subsystems/api-contracts.md
@@ -312,6 +312,14 @@ honest for hosted view-only roles: when `can_manage` is false, `Workspaces`,
`Access`, and hosted `Billing` must stop advertising create, roster-mutation,
or hosted-billing actions and must instead state that an owner or admin is
required.
+The same permission contract must also drive hosted `Support`: when
+`can_manage` is false, the support shell may route the user back to
+`Workspaces`, `Access`, or `Billing` only as review and owner/admin handoff
+paths, not as live hosted mutation paths the current role can execute.
+The same typed bootstrap/runtime contract must also keep `Overview` billing
+copy honest to account shape: hosted-only accounts may not mention self-hosted
+billing utilities by default, and hosted view-only roles must say when hosted
+billing still needs owner/admin authority.
The same permission contract must also drive the compact account-context
summary: the strip may not describe full hosted access-control or billing
ownership when the current role can only review workspaces or roster state.
diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md
index 95e48ac04..d7677fd7f 100644
--- a/docs/release-control/v6/internal/subsystems/cloud-paid.md
+++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md
@@ -872,6 +872,15 @@ permissions: when the current role is view-only, `Workspaces`, `Access`, and
hosted `Billing` copy must not advertise create, roster-mutation, or hosted
billing actions that the runtime will not allow. Those surfaces must say that
an owner or admin is required instead of implying blocked jobs are live.
+`Support` follows that same permission rule: a hosted view-only user may be
+sent back to `Workspaces`, `Access`, or `Billing` only as review and
+owner/admin handoff paths, not as though the user can perform hosted
+lifecycle, access-mutation, or hosted-billing changes directly before
+escalation.
+`Overview` follows the same account-shape and permission rule: hosted-only
+accounts must not mention self-hosted billing utilities by default, and hosted
+view-only roles must say when hosted billing still needs owner/admin
+authority.
The same rule applies to the compact account-context strip: it must describe
the current user's effective hosted tasks, not restate full access-control and
billing capability when those actions are blocked behind owner/admin roles.
diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index d5f25d6bf..9a7cdad2c 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "f9e6d5b177cc9d390fdf659da4acb4335a14f618e32b0f31599072dbe6fe1d79",
+ "source_hash": "3db6522b355842bfc018e0dbeab09df9411de3475c6af9e2fff17a388b91a424",
"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 5ff4e84a2..6ef1bf87c 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -2080,9 +2080,17 @@
}
return "Self-hosted billing, licenses, refunds, and privacy.";
}
+ function supportNavCopy(hosted, canManageHostedTasks) {
+ if (!hosted) {
+ return "Escalation only after the billing path is exhausted.";
+ }
+ if (canManageHostedTasks) {
+ return "Escalation only after the workspace, access, or billing path is exhausted.";
+ }
+ return "Escalation only after the review, owner/admin, or billing path is exhausted.";
+ }
function renderShellNavigation(accounts, supportEmail, activeSection) {
var hosted = hasHostedAccounts(accounts);
- var selfHostedOnly = !hosted;
var workspaces = collectWorkspaces(accounts);
var totalWorkspaces = workspaces.length;
var readyWorkspaces = countReadyWorkspaces(workspaces);
@@ -2101,7 +2109,7 @@
}
}
}
- return '";
+ return '";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
@@ -2153,19 +2161,60 @@
if (!includeAccountName) return note;
return entry.account.name + " \xB7 " + note;
}
- function renderOverviewAttentionCard(entries, accountCount) {
+ function overviewBillingSeparationCopy(accounts, showSelfHostedCommercial) {
+ var hostedBillingCount = 0;
+ var canManageHostedBilling = false;
+ for (var i = 0; i < accounts.length; i += 1) {
+ if (accounts[i].has_billing) {
+ hostedBillingCount += 1;
+ if (accounts[i].can_manage) {
+ canManageHostedBilling = true;
+ }
+ }
+ }
+ if (!accounts.length) {
+ return {
+ title: "Billing stays separate",
+ copy: "Self-hosted billing, licenses, refunds, and privacy stay in Billing."
+ };
+ }
+ if (showSelfHostedCommercial) {
+ if (hostedBillingCount > 0) {
+ return {
+ title: "Billing stays separate",
+ copy: canManageHostedBilling ? "Hosted billing stays in Billing, and self-hosted tools appear there only when relevant." : "Hosted billing stays in Billing, an owner or admin opens it, and self-hosted tools appear there only when relevant."
+ };
+ }
+ return {
+ title: "Billing stays separate",
+ copy: "Self-hosted tools appear in Billing only when they are relevant to this account."
+ };
+ }
+ if (hostedBillingCount > 0) {
+ return {
+ title: "Hosted billing stays separate",
+ copy: canManageHostedBilling ? "Use Billing only for hosted invoices, payment methods, or subscription changes." : "Hosted billing stays in Billing, and an owner or admin must open it."
+ };
+ }
+ return {
+ title: "Billing stays separate",
+ copy: "Use Billing only when the task is commercial, not operational."
+ };
+ }
+ function renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) {
var attention = attentionOverviewEntries(entries);
- var includeAccountName = accountCount > 1;
+ var includeAccountName = accounts.length > 1;
var suspendedCount = countWorkspacesByState(entries.map(function(entry) {
return entry.workspace;
}), "suspended");
+ var billingSeparation = overviewBillingSeparationCopy(accounts, showSelfHostedCommercial);
if (!attention.length) {
return '
Needs attention
Nothing urgent
' + escapeHTML(
entries.length > 0 ? "No active workspace is currently asking for review." : "No hosted workspace is currently asking for review."
) + '
Healthy now' + escapeHTML(
entries.length > 0 ? "Active workspaces look clear for routine use." : "There is no hosted workspace waiting for review yet."
- ) + '
' + escapeHTML(suspendedCount > 0 ? "Suspended stays parked" : "Billing stays separate") + "" + escapeHTML(
- suspendedCount > 0 ? String(suspendedCount) + " suspended workspace" + (suspendedCount === 1 ? " stays" : "s stay") + " out of the way until you deliberately resume it." : "Self-hosted billing, licenses, refunds, and privacy stay in Billing."
+ ) + '
' + escapeHTML(suspendedCount > 0 ? "Suspended stays parked" : billingSeparation.title) + "" + escapeHTML(
+ suspendedCount > 0 ? String(suspendedCount) + " suspended workspace" + (suspendedCount === 1 ? " stays" : "s stay") + " out of the way until you deliberately resume it." : billingSeparation.copy
) + "
";
}
return '
Needs attention
Review these first
These workspaces still need a human check before you treat the account as settled.
' + attention.slice(0, 3).map(function(entry) {
@@ -2240,6 +2289,7 @@
function renderShellOverviewSection(context) {
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var entries = collectOverviewWorkspaceEntries(accounts);
+ var showSelfHostedCommercial = hasSelfHostedCommercial(context.bootstrap);
var totalCount = entries.length;
var readyCount = readyOverviewEntries(entries).length;
var attentionCount = attentionOverviewEntries(entries).length;
@@ -2251,7 +2301,7 @@
attentionCount > 0 ? String(attentionCount) + " attention" : "Nothing urgent",
suspendedCount > 0 ? String(suspendedCount) + " suspended" : "No suspended"
] : ["No hosted account", "Billing available", "Support only on escalation"];
- return '
Use Billing for self-hosted subscriptions, licenses, refunds, or privacy requests.
';
@@ -2314,13 +2364,22 @@
return '
Billing task
' + escapeHTML(title) + "
" + escapeHTML(copy) + '
' + bodyHTML + "
";
}
function renderSupportSection(context) {
- var hasHostedAccounts2 = !!(context.bootstrap.accounts || []).length;
+ var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
+ var hasHostedAccounts2 = accounts.length > 0;
var showSelfHostedCommercial = hasSelfHostedCommercial(context.bootstrap);
var supportEmail = context.bootstrap.support_email || "";
- var supportLead = hasHostedAccounts2 ? showSelfHostedCommercial ? "Use support only when the Workspaces, Access, or Billing path has already stopped you." : "Use support only when the Workspaces, Access, or hosted Billing path has already stopped you." : "Use support only when the Billing path has already stopped you.";
- var supportChips = hasHostedAccounts2 ? ["Escalation only", showSelfHostedCommercial ? "Bring context" : "Hosted only", supportEmail ? "Email" : "Support"] : ["Escalation only", "Billing only", supportEmail ? "Email" : "Support"];
- var routeCards = hasHostedAccounts2 ? '
Hosted path
Workspace or access path failed
Go back to the hosted task first. Escalate only when the same workspace or access path still cannot finish the job.
Start from the same taskUse Workspaces for lifecycle issues and Access for roster issues before you escalate.
Keep the hosted context intactInclude the account, workspace, and failed action so support inherits the same request.
" + (showSelfHostedCommercial ? "Use this route only after hosted billing or one self-hosted billing job has failed to complete cleanly." : "Use this route only after hosted billing has failed to complete cleanly.") + '
Name the billing job' + (showSelfHostedCommercial ? "Say whether the failed path was hosted billing, licenses, refunds, or privacy." : "Say whether the failed path was hosted billing.") + '
Keep the request intact' + (showSelfHostedCommercial ? "Bring the same account or billing email and the failed action instead of reopening the story." : "Bring the same hosted account and the failed billing action instead of reopening the story.") + '
1. Failed path' + (showSelfHostedCommercial ? "Say whether the blocked path was Workspaces, Access, hosted billing, licenses, refunds, or privacy." : "Say whether the blocked path was Workspaces, Access, or hosted billing.") + '
2. Account or email' + (showSelfHostedCommercial ? "Include the hosted account and workspace when relevant, or the commercial billing email for self-hosted work." : "Include the hosted account and workspace or hosted billing account that the failed path belongs to.") + '
3. Failed actionName the exact button, form, or billing step that failed and what happened next.
' : '
1. Billing jobSay whether the blocked path was billing, licenses, refunds, or privacy.
2. Purchase emailInclude the commercial billing email used for the self-hosted purchase.
3. Failed actionName the exact button, form, or billing step that failed and what happened next.
';
+ var canManageHostedTasks = false;
+ for (var i = 0; i < accounts.length; i += 1) {
+ if (accounts[i].can_manage) {
+ canManageHostedTasks = true;
+ break;
+ }
+ }
+ var hostedViewOnly = hasHostedAccounts2 && !canManageHostedTasks;
+ var supportLead = hasHostedAccounts2 ? hostedViewOnly ? showSelfHostedCommercial ? "Use support only when the same Workspaces review, Access review, owner/admin, or Billing path has already stopped you." : "Use support only when the same Workspaces review, Access review, owner/admin, or hosted Billing path has already stopped you." : showSelfHostedCommercial ? "Use support only when the Workspaces, Access, or Billing path has already stopped you." : "Use support only when the Workspaces, Access, or hosted Billing path has already stopped you." : "Use support only when the Billing path has already stopped you.";
+ var supportChips = hasHostedAccounts2 ? ["Escalation only", hostedViewOnly ? "Owner/admin first" : showSelfHostedCommercial ? "Bring context" : "Hosted only", supportEmail ? "Email" : "Support"] : ["Escalation only", "Billing only", supportEmail ? "Email" : "Support"];
+ var routeCards = hasHostedAccounts2 ? '
Hosted path
' + (hostedViewOnly ? "Hosted review or owner/admin path failed" : "Workspace or access path failed") + "
" + (hostedViewOnly ? "Go back to the hosted task first. Review the same workspace or roster here, then have an owner or admin run the blocked change before you escalate." : "Go back to the hosted task first. Escalate only when the same workspace or access path still cannot finish the job.") + '
' + (hostedViewOnly ? "Review the same task" : "Start from the same task") + "" + (hostedViewOnly ? "Use Workspaces to confirm workspace state and Access to confirm the current roster before you escalate." : "Use Workspaces for lifecycle issues and Access for roster issues before you escalate.") + '
' + (hostedViewOnly ? "Name the blocked owner/admin action" : "Keep the hosted context intact") + "" + (hostedViewOnly ? "Include the account, workspace, and the lifecycle or access change that still needs an owner or admin." : "Include the account, workspace, and failed action so support inherits the same request.") + '
" + (hostedViewOnly ? showSelfHostedCommercial ? "Use this route only after the relevant billing job has failed, or the affected hosted account still needs an owner or admin to finish hosted billing." : "Use this route only after the affected hosted account still needs an owner or admin to finish hosted billing and that path still cannot complete cleanly." : showSelfHostedCommercial ? "Use this route only after hosted billing or one self-hosted billing job has failed to complete cleanly." : "Use this route only after hosted billing has failed to complete cleanly.") + '
Name the billing job' + (hostedViewOnly ? showSelfHostedCommercial ? "Say whether the failed path was hosted billing, licenses, refunds, or privacy, and whether hosted billing still needed an owner or admin." : "Say whether the failed path was hosted billing and whether the account still needed an owner or admin to open it." : showSelfHostedCommercial ? "Say whether the failed path was hosted billing, licenses, refunds, or privacy." : "Say whether the failed path was hosted billing.") + '
Keep the request intact' + (hostedViewOnly ? showSelfHostedCommercial ? "Bring the same account or billing email and the failed owner/admin or billing step instead of reopening the story." : "Bring the same hosted account and the failed billing or owner/admin step instead of reopening the story." : showSelfHostedCommercial ? "Bring the same account or billing email and the failed action instead of reopening the story." : "Bring the same hosted account and the failed billing action instead of reopening the story.") + '
1. Failed path' + (hostedViewOnly ? showSelfHostedCommercial ? "Say whether the blocked path was Workspaces review, Access review, owner/admin hosted change, hosted billing, licenses, refunds, or privacy." : "Say whether the blocked path was Workspaces review, Access review, owner/admin hosted change, or hosted billing." : showSelfHostedCommercial ? "Say whether the blocked path was Workspaces, Access, hosted billing, licenses, refunds, or privacy." : "Say whether the blocked path was Workspaces, Access, or hosted billing.") + '
2. Account or email' + (hostedViewOnly ? showSelfHostedCommercial ? "Include the hosted account and workspace for the blocked review or owner/admin path, or the commercial billing email for self-hosted work." : "Include the hosted account and workspace or hosted billing account that still needed owner/admin action." : showSelfHostedCommercial ? "Include the hosted account and workspace when relevant, or the commercial billing email for self-hosted work." : "Include the hosted account and workspace or hosted billing account that the failed path belongs to.") + '
3. Failed actionName the exact button, form, or billing step that failed and what happened next.
' : '
1. Billing jobSay whether the blocked path was billing, licenses, refunds, or privacy.
2. Purchase emailInclude the commercial billing email used for the self-hosted purchase.
3. Failed actionName the exact button, form, or billing step that failed and what happened next.
';
return '
Support
Escalation only
' + escapeHTML(supportLead) + "
" + renderSectionContextChips(supportChips) + '
' + routeCards + '
What to send
Keep the escalation short
Support should inherit the same request, not reconstruct it from scratch.
' + runbookSteps + "
";
}
function renderHeaderHTML(context) {
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
index c5a6a3ade..7f0bf6d32 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
@@ -382,17 +382,39 @@ describe('shell view', function() {
expect(html).toContain('Hosted account where you can open workspaces and review who already has access. An owner or admin handles access changes and billing.');
expect(html).toContain('View only');
expect(html).toContain('Owner/admin required');
+ expect(html).toContain('Hosted billing stays separate');
+ expect(html).toContain('Hosted billing stays in Billing, and an owner or admin must open it.');
expect(html).toContain('Review access');
expect(html).toContain('Owner or admin required');
expect(html).toContain('Review who already has access to this hosted account. An owner or admin must make changes.');
expect(html).toContain('Review the hosted roster here. An owner or admin must make changes.');
expect(html).toContain('Hosted billing is attached here, but an owner or admin must open it.');
+ expect(html).toContain('Escalation only after the review, owner/admin, or billing path is exhausted.');
+ expect(html).toContain('Use support only when the same Workspaces review, Access review, owner/admin, or hosted Billing path has already stopped you.');
+ expect(html).toContain('Owner/admin first');
+ expect(html).toContain('Hosted review or owner/admin path failed');
+ expect(html).toContain('Review the same workspace or roster here, then have an owner or admin run the blocked change before you escalate.');
+ expect(html).toContain('Review the same task');
+ expect(html).toContain('Use Workspaces to confirm workspace state and Access to confirm the current roster before you escalate.');
+ expect(html).toContain('Name the blocked owner/admin action');
+ expect(html).toContain('Include the account, workspace, and the lifecycle or access change that still needs an owner or admin.');
+ expect(html).toContain('Review workspaces');
+ expect(html).toContain('Review access');
+ expect(html).toContain('Hosted billing or owner/admin path failed');
+ expect(html).toContain('Use this route only after the affected hosted account still needs an owner or admin to finish hosted billing and that path still cannot complete cleanly.');
+ expect(html).toContain('Say whether the failed path was hosted billing and whether the account still needed an owner or admin to open it.');
+ expect(html).toContain('Bring the same hosted account and the failed billing or owner/admin step instead of reopening the story.');
+ expect(html).toContain('Say whether the blocked path was Workspaces review, Access review, owner/admin hosted change, or hosted billing.');
+ expect(html).toContain('Include the hosted account and workspace or hosted billing account that still needed owner/admin action.');
expect(html).toContain('data-can-manage="false"');
expect(html).not.toContain('Invite people, change roles, and remove account access.');
expect(html).not.toContain('Open a workspace, review lifecycle state, or create one.');
expect(html).not.toContain('Open a workspace, review lifecycle state, or create a new one without mixing in access or billing work.');
expect(html).not.toContain('data-action="invite-member"');
expect(html).not.toContain('data-action="set-access-job"');
+ expect(html).not.toContain('Self-hosted billing, licenses, refunds, and privacy stay in Billing.');
+ expect(html).not.toContain('Use support only when the Workspaces, Access, or hosted Billing path has already stopped you.');
+ expect(html).not.toContain('Workspace or access path failed');
});
it('renders self-hosted overview copy when no hosted accounts are attached', function() {
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts
index d2d6780e8..56154e3f0 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.ts
@@ -413,9 +413,18 @@ function billingNavCopy(hostedBillingCount: number, canManageHostedBilling: bool
return 'Self-hosted billing, licenses, refunds, and privacy.';
}
+function supportNavCopy(hosted: boolean, canManageHostedTasks: boolean): string {
+ if (!hosted) {
+ return 'Escalation only after the billing path is exhausted.';
+ }
+ if (canManageHostedTasks) {
+ return 'Escalation only after the workspace, access, or billing path is exhausted.';
+ }
+ return 'Escalation only after the review, owner/admin, or billing path is exhausted.';
+}
+
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);
@@ -446,7 +455,7 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s
shellSectionButton('workspaces', activeSection, '02', 'Workspaces', workspaceNavCopy(hosted, canManage), hosted ? String(readyWorkspaces) + ' ready' : 'Unavailable') +
shellSectionButton('access', activeSection, '03', 'Access', accessNavCopy(hosted, canManage), hosted ? (canManage ? 'Manage' : 'View') : 'Unavailable') +
shellSectionButton('billing', activeSection, '04', 'Billing', billingNavCopy(hostedBillingCount, canManageHostedBilling), hostedBillingCount > 0 ? (hostedBillingCount > 1 ? 'Hosted +' : 'Hosted') : 'Self-hosted') +
- 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') +
+ shellSectionButton('support', activeSection, '05', 'Support', supportNavCopy(hosted, canManage), supportEmail ? 'Email' : 'Help') +
'
' +
''
);
@@ -556,12 +565,69 @@ function overviewWorkspaceContext(entry: OverviewWorkspaceEntry, includeAccountN
return entry.account.name + ' ยท ' + note;
}
-function renderOverviewAttentionCard(entries: OverviewWorkspaceEntry[], accountCount: number): string {
+function overviewBillingSeparationCopy(
+ accounts: PortalAccountSummary[],
+ showSelfHostedCommercial: boolean
+): { title: string; copy: string } {
+ var hostedBillingCount = 0;
+ var canManageHostedBilling = false;
+ for (var i = 0; i < accounts.length; i += 1) {
+ if (accounts[i].has_billing) {
+ hostedBillingCount += 1;
+ if (accounts[i].can_manage) {
+ canManageHostedBilling = true;
+ }
+ }
+ }
+
+ if (!accounts.length) {
+ return {
+ title: 'Billing stays separate',
+ copy: 'Self-hosted billing, licenses, refunds, and privacy stay in Billing.',
+ };
+ }
+
+ if (showSelfHostedCommercial) {
+ if (hostedBillingCount > 0) {
+ return {
+ title: 'Billing stays separate',
+ copy: canManageHostedBilling
+ ? 'Hosted billing stays in Billing, and self-hosted tools appear there only when relevant.'
+ : 'Hosted billing stays in Billing, an owner or admin opens it, and self-hosted tools appear there only when relevant.',
+ };
+ }
+ return {
+ title: 'Billing stays separate',
+ copy: 'Self-hosted tools appear in Billing only when they are relevant to this account.',
+ };
+ }
+
+ if (hostedBillingCount > 0) {
+ return {
+ title: 'Hosted billing stays separate',
+ copy: canManageHostedBilling
+ ? 'Use Billing only for hosted invoices, payment methods, or subscription changes.'
+ : 'Hosted billing stays in Billing, and an owner or admin must open it.',
+ };
+ }
+
+ return {
+ title: 'Billing stays separate',
+ copy: 'Use Billing only when the task is commercial, not operational.',
+ };
+}
+
+function renderOverviewAttentionCard(
+ accounts: PortalAccountSummary[],
+ entries: OverviewWorkspaceEntry[],
+ showSelfHostedCommercial: boolean
+): string {
var attention = attentionOverviewEntries(entries);
- var includeAccountName = accountCount > 1;
+ var includeAccountName = accounts.length > 1;
var suspendedCount = countWorkspacesByState(entries.map(function(entry) {
return entry.workspace;
}), 'suspended');
+ var billingSeparation = overviewBillingSeparationCopy(accounts, showSelfHostedCommercial);
if (!attention.length) {
return (
'' +
@@ -576,9 +642,9 @@ function renderOverviewAttentionCard(entries: OverviewWorkspaceEntry[], accountC
? 'Active workspaces look clear for routine use.'
: 'There is no hosted workspace waiting for review yet.'
) + '' +
- '
' +
@@ -1160,46 +1227,79 @@ function renderBillingTaskPanel(title: string, copy: string, panelID: string, bo
}
function renderSupportSection(context: ShellViewContext): string {
- var hasHostedAccounts = !!((context.bootstrap.accounts || []).length);
+ var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
+ var hasHostedAccounts = accounts.length > 0;
var showSelfHostedCommercial = hasSelfHostedCommercial(context.bootstrap);
var supportEmail = context.bootstrap.support_email || '';
+ var canManageHostedTasks = false;
+ for (var i = 0; i < accounts.length; i += 1) {
+ if (accounts[i].can_manage) {
+ canManageHostedTasks = true;
+ break;
+ }
+ }
+ var hostedViewOnly = hasHostedAccounts && !canManageHostedTasks;
var supportLead = hasHostedAccounts
- ? (showSelfHostedCommercial
- ? 'Use support only when the Workspaces, Access, or Billing path has already stopped you.'
- : 'Use support only when the Workspaces, Access, or hosted Billing path has already stopped you.')
+ ? (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Use support only when the same Workspaces review, Access review, owner/admin, or Billing path has already stopped you.'
+ : 'Use support only when the same Workspaces review, Access review, owner/admin, or hosted Billing path has already stopped you.')
+ : (showSelfHostedCommercial
+ ? 'Use support only when the Workspaces, Access, or Billing path has already stopped you.'
+ : 'Use support only when the Workspaces, Access, or hosted Billing path has already stopped you.'))
: 'Use support only when the Billing path has already stopped you.';
var supportChips = hasHostedAccounts
- ? ['Escalation only', showSelfHostedCommercial ? 'Bring context' : 'Hosted only', supportEmail ? 'Email' : 'Support']
+ ? ['Escalation only', hostedViewOnly ? 'Owner/admin first' : (showSelfHostedCommercial ? 'Bring context' : 'Hosted only'), supportEmail ? 'Email' : 'Support']
: ['Escalation only', 'Billing only', supportEmail ? 'Email' : 'Support'];
var routeCards = hasHostedAccounts
? (
'
' +
'
Hosted path
' +
- '
Workspace or access path failed
' +
- '
Go back to the hosted task first. Escalate only when the same workspace or access path still cannot finish the job.
' +
+ '
' + (hostedViewOnly ? 'Hosted review or owner/admin path failed' : 'Workspace or access path failed') + '
' +
+ '
' + (hostedViewOnly
+ ? 'Go back to the hosted task first. Review the same workspace or roster here, then have an owner or admin run the blocked change before you escalate.'
+ : 'Go back to the hosted task first. Escalate only when the same workspace or access path still cannot finish the job.') + '
' +
'
' +
- '
Start from the same taskUse Workspaces for lifecycle issues and Access for roster issues before you escalate.
' +
- '
Keep the hosted context intactInclude the account, workspace, and failed action so support inherits the same request.
' +
+ '
' + (hostedViewOnly ? 'Review the same task' : 'Start from the same task') + '' + (hostedViewOnly
+ ? 'Use Workspaces to confirm workspace state and Access to confirm the current roster before you escalate.'
+ : 'Use Workspaces for lifecycle issues and Access for roster issues before you escalate.') + '
' +
+ '
' + (hostedViewOnly ? 'Name the blocked owner/admin action' : 'Keep the hosted context intact') + '' + (hostedViewOnly
+ ? 'Include the account, workspace, and the lifecycle or access change that still needs an owner or admin.'
+ : 'Include the account, workspace, and failed action so support inherits the same request.') + '
' + (showSelfHostedCommercial
- ? 'Use this route only after hosted billing or one self-hosted billing job has failed to complete cleanly.'
- : 'Use this route only after hosted billing has failed to complete cleanly.') + '
' + (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Use this route only after the relevant billing job has failed, or the affected hosted account still needs an owner or admin to finish hosted billing.'
+ : 'Use this route only after the affected hosted account still needs an owner or admin to finish hosted billing and that path still cannot complete cleanly.')
+ : (showSelfHostedCommercial
+ ? 'Use this route only after hosted billing or one self-hosted billing job has failed to complete cleanly.'
+ : 'Use this route only after hosted billing has failed to complete cleanly.')) + '
' +
'
' +
- '
Name the billing job' + (showSelfHostedCommercial
- ? 'Say whether the failed path was hosted billing, licenses, refunds, or privacy.'
- : 'Say whether the failed path was hosted billing.') + '
' +
- '
Keep the request intact' + (showSelfHostedCommercial
- ? 'Bring the same account or billing email and the failed action instead of reopening the story.'
- : 'Bring the same hosted account and the failed billing action instead of reopening the story.') + '
' +
+ '
Name the billing job' + (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Say whether the failed path was hosted billing, licenses, refunds, or privacy, and whether hosted billing still needed an owner or admin.'
+ : 'Say whether the failed path was hosted billing and whether the account still needed an owner or admin to open it.')
+ : (showSelfHostedCommercial
+ ? 'Say whether the failed path was hosted billing, licenses, refunds, or privacy.'
+ : 'Say whether the failed path was hosted billing.')) + '
' +
+ '
Keep the request intact' + (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Bring the same account or billing email and the failed owner/admin or billing step instead of reopening the story.'
+ : 'Bring the same hosted account and the failed billing or owner/admin step instead of reopening the story.')
+ : (showSelfHostedCommercial
+ ? 'Bring the same account or billing email and the failed action instead of reopening the story.'
+ : 'Bring the same hosted account and the failed billing action instead of reopening the story.')) + '
1. Failed path' + (showSelfHostedCommercial
- ? 'Say whether the blocked path was Workspaces, Access, hosted billing, licenses, refunds, or privacy.'
- : 'Say whether the blocked path was Workspaces, Access, or hosted billing.') + '
' +
- '
2. Account or email' + (showSelfHostedCommercial
- ? 'Include the hosted account and workspace when relevant, or the commercial billing email for self-hosted work.'
- : 'Include the hosted account and workspace or hosted billing account that the failed path belongs to.') + '
' +
+ '
1. Failed path' + (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Say whether the blocked path was Workspaces review, Access review, owner/admin hosted change, hosted billing, licenses, refunds, or privacy.'
+ : 'Say whether the blocked path was Workspaces review, Access review, owner/admin hosted change, or hosted billing.')
+ : (showSelfHostedCommercial
+ ? 'Say whether the blocked path was Workspaces, Access, hosted billing, licenses, refunds, or privacy.'
+ : 'Say whether the blocked path was Workspaces, Access, or hosted billing.')) + '
' +
+ '
2. Account or email' + (hostedViewOnly
+ ? (showSelfHostedCommercial
+ ? 'Include the hosted account and workspace for the blocked review or owner/admin path, or the commercial billing email for self-hosted work.'
+ : 'Include the hosted account and workspace or hosted billing account that still needed owner/admin action.')
+ : (showSelfHostedCommercial
+ ? 'Include the hosted account and workspace when relevant, or the commercial billing email for self-hosted work.'
+ : 'Include the hosted account and workspace or hosted billing account that the failed path belongs to.')) + '
' +
'
3. Failed actionName the exact button, form, or billing step that failed and what happened next.