diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index a6122f6d4..8c99bcccd 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "76d42d67fcfa327e662a150fb9ae74fe5ebc1cc020771aa889191ffb6e2c9be0", + "source_hash": "59170a8df52cfe51c3e646dd1968ca1e89f250a1d12286ed374dacab67128d3c", "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 449d63d16..ed72b2dc6 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -23,13 +23,13 @@ function roleCapabilityCopy(role) { switch (normalizedTeamRole(role)) { case "owner": - return "Full account control, including billing, team access, and hosted workspace control."; + return "Full account control, including billing, team access, and workspace control."; case "admin": - return "Can manage hosted workspaces and billing for this account."; + return "Can manage workspaces and billing for this account."; case "tech": - return "Can manage hosted workspaces without billing ownership."; + return "Can manage workspaces without billing ownership."; case "read_only": - return "Can review hosted state without making control-plane changes."; + return "Can review workspace status without making control-plane changes."; case "member": return "Has access through the account roster."; default: @@ -70,10 +70,10 @@ return "This workspace looks ready for normal use. Use the fleet table to open it, or suspend it here if you are intentionally taking it out of service."; } if (workspace.state === "active" && workspace.health_status === "checking") { - return "This workspace is active but still waiting on a completed health check. Review it before you treat the hosted posture as settled."; + return "This workspace is active but still waiting on a completed health check. Review it before you treat the account status as settled."; } if (workspace.health_status === "unhealthy") { - return "This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the hosted fleet."; + return "This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the workspace list."; } if (workspace.state === "suspended") { return "This workspace is already suspended. The remaining lifecycle action here is deletion, so treat it as a deliberate irreversible step."; @@ -312,7 +312,7 @@ } if (!entry.teamQuery.data.length) { if (rosterPanel) rosterPanel.classList.add("state-only"); - setContainerMessage(roster, "No one added yet", "Invite someone new when this hosted account needs shared access.", false); + setContainerMessage(roster, "No one added yet", "Invite someone new when this account needs shared access.", false); return; } roster.textContent = ""; @@ -1819,9 +1819,9 @@ var suspendedCount = countWorkspacesByState(workspaces, "suspended"); if (!attention.length) { return '
Attention

' + escapeHTML(suspendedCount > 0 ? "No active blockers" : "Fleet is clear") + "

" + escapeHTML( - suspendedCount > 0 ? "Active hosted workspaces are healthy. Suspended workspaces stay parked until you resume them." : "Every active hosted workspace currently reports a healthy status." + suspendedCount > 0 ? "Active workspaces are healthy. Suspended workspaces stay parked until you resume them." : "Every active workspace currently reports a healthy status." ) + '

Healthy now' + escapeHTML( - suspendedCount > 0 ? "Active hosted workspaces are clear for routine use." : "All active hosted workspaces are clear for routine use." + suspendedCount > 0 ? "Active workspaces are clear for routine use." : "All active workspaces are clear for routine use." ) + "
" + (suspendedCount > 0 ? '
Suspended stays parked' + escapeHTML(String(suspendedCount) + " workspace" + (suspendedCount === 1 ? " is" : "s are") + " suspended and intentionally out of day-to-day use.") + "
" : "") + '
Use Team only for changeKeep roster edits explicit instead of mixing them into normal workspace work.
Keep billing separateUse account services only when the task is commercial, not operational.
'; } var items = attention.slice(0, 3).map(function(workspace) { @@ -1853,7 +1853,7 @@ break; } } - return '"; + return '"; } function renderWorkspaceCard(account, workspace, accountAPIBasePath) { var status = workspaceHealthState(workspace); @@ -1887,15 +1887,15 @@ var postureTitle = unhealthyCount > 0 ? "Needs review" : checkingCount > 0 ? "Still settling" : suspendedCount > 0 ? "Active workspaces are stable" : "Workspaces are stable"; var postureCopy = unhealthyCount > 0 ? "One or more workspaces still need attention before this account looks healthy." : checkingCount > 0 ? "Most workspaces look healthy, but some are still waiting on a completed health check." : suspendedCount > 0 ? "Active workspaces are healthy while suspended workspaces stay parked until you resume them." : "All workspaces are healthy and ready for routine use."; var nextStepTitle = unhealthyCount > 0 ? "Start in workspaces" : checkingCount > 0 ? "Review pending checks" : suspendedCount > 0 ? "Next step" : "Next step"; - var nextStepCopy = unhealthyCount > 0 ? "One or more workspaces need review before you treat this account as healthy." : checkingCount > 0 ? "The fleet is mostly healthy, but there are still workspaces waiting on a completed health check." : suspendedCount > 0 ? "Active hosted workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use." : "Everything looks stable. Move into Team or Account services only if you need to change access or billing."; - var nextStepChecklist = unhealthyCount > 0 ? '
1. Review attention itemsOpen the fleet and inspect any workspace marked as checking or needs attention.
2. Resolve access blockersUse Team if the right people are not already attached to the hosted account.
3. Escalate billing separatelyKeep account billing or self-hosted license work out of the workspace review flow.
' : checkingCount > 0 ? '
1. Verify pending checksOpen the workspaces still settling and confirm they are safe to operate.
2. Keep access deliberateChange Team only if a pending workspace needs a different mix of access.
3. Keep commercial work separateUse account services or billing only when the workspace state is already understood.
' : '
1. Open a workspace for the next operational taskMove into Workspaces when you are ready to do hosted work.
2. Change access in Team onlyKeep roster changes explicit instead of mixing them into routine workspace work.
3. Keep billing and privacy separateLicenses, refunds, privacy, and self-hosted billing stay in their own section.
'; + var nextStepCopy = unhealthyCount > 0 ? "One or more workspaces need review before you treat this account as healthy." : checkingCount > 0 ? "The fleet is mostly healthy, but there are still workspaces waiting on a completed health check." : suspendedCount > 0 ? "Active workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use." : "Everything looks stable. Move into Team or Account services only if you need to change access or billing."; + var nextStepChecklist = unhealthyCount > 0 ? '
1. Review attention itemsOpen the fleet and inspect any workspace marked as checking or needs attention.
2. Resolve access blockersUse Team if the right people are not already attached to the hosted account.
3. Escalate billing separatelyKeep account billing or self-hosted license work out of the workspace review flow.
' : checkingCount > 0 ? '
1. Verify pending checksOpen the workspaces still settling and confirm they are safe to operate.
2. Keep access deliberateChange Team only if a pending workspace needs a different mix of access.
3. Keep commercial work separateUse account services or billing only when the workspace state is already understood.
' : '
1. Open a workspace for the next operational taskMove into Workspaces when you are ready to do workspace work.
2. Change access in Team onlyKeep roster changes explicit instead of mixing them into routine workspace work.
3. Keep billing and privacy separateLicenses, refunds, privacy, and self-hosted billing stay in their own section.
'; var nextStepActions = '
"; - var accountScopeCopy = account.kind === "msp" ? "Manage client workspaces, billing, and team access from one place." : "Manage hosted workspaces, billing, and team access from one place."; - var overviewBriefStrip = '
Account scope' + escapeHTML(accountScopeCopy) + '
Hosted pathUse Workspaces for tenant access and lifecycle changes, and Team only when access needs to change.
Commercial pathKeep licenses, refunds, privacy, and self-hosted billing in Account services instead of mixing them into hosted work.
'; + var accountScopeCopy = account.kind === "msp" ? "Manage client workspaces, billing, and team access from one place." : "Manage workspaces, billing, and team access from one place."; + var overviewBriefStrip = '
Account scope' + escapeHTML(accountScopeCopy) + '
Workspace pathUse Workspaces for tenant access and lifecycle changes, and Team only when access needs to change.
Commercial pathKeep licenses, refunds, privacy, and self-hosted billing in Account services instead of mixing them into workspace work.
'; return '

Workspace status

Review workspace status first, then move into the next section.

' + renderSectionContextChips([ String(totalCount) + " total", String(readyCount) + " ready", - suspendedCount > 0 ? String(suspendedCount) + " suspended" : "Active fleet" + suspendedCount > 0 ? String(suspendedCount) + " suspended" : "All active" ]) + renderOverviewMetricStrip(totalCount, readyCount, checkingCount, unhealthyCount, suspendedCount) + '
"; } function renderAccountWorkspaceSection(account, accountAPIBasePath) { @@ -1935,11 +1935,11 @@ } function renderAccountTeamSection(account) { var accessPolicy = '

Access model

Assign the smallest role that still lets someone do the work they own on this account.

OwnerBilling, team access, and full hosted control.
AdminHosted workspace control plus billing for the account.
TechWorkspace control without billing ownership.
Read-onlyState review and verification without control-plane changes.
'; - var reviewDesk = '

Keep access disciplined

Use the roster as a controlled access list, not a dumping ground for vague shared access.

Owners stay rareReserve Owner for billing, team, and full hosted control. Default to Admin, Tech, or Read-only first.
Keep access narrowUse Tech for workspace control and Read-only for verification instead of handing out broader access.
Remove stale access fastIf someone no longer owns the work, remove them instead of leaving dormant access attached to the account.
'; - return '

Account access

Owners govern billing and access. Admins and techs keep hosted work moving day to day.

' + renderSectionContextChips([ + var reviewDesk = '

Keep access disciplined

Use the roster as a controlled access list, not a dumping ground for vague shared access.

Owners stay rareReserve Owner for billing, team, and full account control. Default to Admin, Tech, or Read-only first.
Keep access narrowUse Tech for workspace control and Read-only for verification instead of handing out broader access.
Remove stale access fastIf someone no longer owns the work, remove them instead of leaving dormant access attached to the account.
'; + return '"; } function renderSupportSection(context) { @@ -1963,7 +1963,7 @@ return '
' + renderShellNavigation(accounts, context.bootstrap.support_email || "", activeSection) + '
' + (accounts.length === 1 ? renderAccountContextStrip(accounts[0]) : "") + '
' + hostedContent + '

' + serviceHeading + "

" + renderSectionContextChips([ hosted ? "Self-hosted only" : "Commercial", "4 tools" - ]) + '
' + serviceNote + '

Choose a service to begin

Use one self-hosted request at a time. Each service verifies commercial identity first, then keeps billing, license, refund, or privacy work contained in one place.

What each tool does
BillingStripe customer portal access for invoices, payment methods, and plan changes.
LicensesRecover the latest active self-hosted license and the matching invoice link.
RefundsCheck eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
Before you start
One requestKeep a single commercial task active instead of bouncing across sections.
Identity firstVerification happens before any sensitive account action opens.
Stay focusedKeep one commercial request in flight instead of bouncing between services.
Escalation
Escalate quicklyIf billing, licenses, refunds, or privacy do not behave as expected, escalate from this section.
Commercial packetBring the service name, commercial email, and the exact failed action so support starts with the same request state.
Need help with billing, refund, privacy, or license requests? ' + escapeHTML(context.bootstrap.support_email || "") + '

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 || "") + '.
' + renderSupportSection(context) + "
"; + ]) + '
' + serviceNote + '

Choose a service to begin

Use one self-hosted request at a time. Each service verifies commercial identity first, then keeps billing, license, refund, or privacy work contained in one place.

What each tool does
BillingStripe customer portal access for invoices, payment methods, and plan changes.
LicensesRecover the latest active self-hosted license and the matching invoice link.
RefundsCheck eligibility before revoking active commercial access.
PrivacyRequest export or deletion without leaving Pulse Account.
Before you start
One requestKeep a single commercial task active instead of bouncing across sections.
Identity firstVerification happens before any sensitive account action opens.
Stay focusedKeep one commercial request in flight instead of bouncing between services.
Escalation
Escalate quicklyIf billing, licenses, refunds, or privacy do not behave as expected, escalate from this section.
Commercial packetBring the service name, commercial email, and the exact failed action so support starts with the same request state.
Need help with billing, refund, privacy, or license requests? ' + escapeHTML(context.bootstrap.support_email || "") + '

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 || "") + '.
' + renderSupportSection(context) + "
"; } function renderSignedOutPortalHTML(context) { var statusHTML = ""; @@ -1973,7 +1973,7 @@ var successMessage = context.loginState.successMessage || "If that email is registered, a magic link is on the way."; statusHTML = '
' + escapeHTML(successMessage) + `

Don't see it? Send a new link.
`; } - return '

Sign in to Pulse Account

Use one commercial email address to get into hosted workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; + return '

Sign in to Pulse Account

Use one commercial email address to get into workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

Sign in

Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.

Create an account
' + statusHTML + "
"; } // src/shell.ts diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index d3b8ed432..42c6566c1 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -33,13 +33,13 @@ function roleLabel(role: string): string { function roleCapabilityCopy(role: string): string { switch (normalizedTeamRole(role)) { case 'owner': - return 'Full account control, including billing, team access, and hosted workspace control.'; + return 'Full account control, including billing, team access, and workspace control.'; case 'admin': - return 'Can manage hosted workspaces and billing for this account.'; + return 'Can manage workspaces and billing for this account.'; case 'tech': - return 'Can manage hosted workspaces without billing ownership.'; + return 'Can manage workspaces without billing ownership.'; case 'read_only': - return 'Can review hosted state without making control-plane changes.'; + return 'Can review workspace status without making control-plane changes.'; case 'member': return 'Has access through the account roster.'; default: @@ -88,10 +88,10 @@ function workspaceGuidance(workspace: PortalWorkspaceSummary): string { return 'This workspace looks ready for normal use. Use the fleet table to open it, or suspend it here if you are intentionally taking it out of service.'; } if (workspace.state === 'active' && workspace.health_status === 'checking') { - return 'This workspace is active but still waiting on a completed health check. Review it before you treat the hosted posture as settled.'; + return 'This workspace is active but still waiting on a completed health check. Review it before you treat the account status as settled.'; } if (workspace.health_status === 'unhealthy') { - return 'This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the hosted fleet.'; + return 'This workspace needs review before it is treated as trustworthy. Use the management action only when you intend to suspend or remove it from the workspace list.'; } if (workspace.state === 'suspended') { return 'This workspace is already suspended. The remaining lifecycle action here is deletion, so treat it as a deliberate irreversible step.'; @@ -369,7 +369,7 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry } if (!entry.teamQuery.data.length) { if (rosterPanel) rosterPanel.classList.add('state-only'); - setContainerMessage(roster, 'No one added yet', 'Invite someone new when this hosted account needs shared access.', false); + setContainerMessage(roster, 'No one added yet', 'Invite someone new when this account needs shared access.', false); return; } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index a58807524..ef4eef48c 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -157,7 +157,7 @@ describe('shell view', function() { expect(html).toContain('Workspace summary'); expect(html).toContain('Account scope'); expect(html).toContain('Manage client workspaces, billing, and team access from one place.'); - expect(html).toContain('Hosted path'); + expect(html).toContain('Workspace path'); expect(html).toContain('Commercial path'); expect(html).toContain('Open workspaces'); expect(html).toContain('Review team access'); @@ -192,7 +192,7 @@ describe('shell view', function() { expect(html).toContain('data-action="clear-workspace-selection"'); expect(html).toContain('Team management'); expect(html).toContain('Least privilege'); - expect(html).toContain('Hosted access'); + expect(html).toContain('Workspace access'); expect(html).toContain('Invite someone new'); expect(html).toContain('Access model'); expect(html).toContain('Access review'); @@ -245,7 +245,7 @@ describe('shell view', function() { expect(html).toContain('Summary'); expect(html).toContain('None'); expect(html).toContain('Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.'); - expect(html).toContain('Pick one commercial request and keep it separate from hosted workspaces and team changes.'); + expect(html).toContain('Pick one commercial request and keep it separate from workspace and team changes.'); expect(html).toContain('Account services'); expect(html).not.toContain('Self-hosted commercial services'); }); @@ -317,8 +317,8 @@ describe('shell view', function() { expect(html).toContain('No active blockers'); expect(html).toContain('Active workspaces are healthy while suspended workspaces stay parked until you resume them.'); expect(html).toContain('Next step'); - expect(html).toContain('Active hosted workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use.'); - expect(html).toContain('Active hosted workspaces are healthy. Suspended workspaces stay parked until you resume them.'); + expect(html).toContain('Active workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use.'); + expect(html).toContain('Active workspaces are healthy. Suspended workspaces stay parked until you resume them.'); expect(html).toContain('Suspended stays parked'); }); }); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 1bdca66fd..a9c3673e3 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -200,13 +200,13 @@ function renderAttentionPanel(workspaces: PortalWorkspaceSummary[]): string { '
Attention
' + '

' + escapeHTML(suspendedCount > 0 ? 'No active blockers' : 'Fleet is clear') + '

' + '

' + escapeHTML(suspendedCount > 0 - ? 'Active hosted workspaces are healthy. Suspended workspaces stay parked until you resume them.' - : 'Every active hosted workspace currently reports a healthy status.' + ? 'Active workspaces are healthy. Suspended workspaces stay parked until you resume them.' + : 'Every active workspace currently reports a healthy status.' ) + '

' + '
' + '
Healthy now' + escapeHTML(suspendedCount > 0 - ? 'Active hosted workspaces are clear for routine use.' - : 'All active hosted workspaces are clear for routine use.' + ? 'Active workspaces are clear for routine use.' + : 'All active workspaces are clear for routine use.' ) + '
' + (suspendedCount > 0 ? '
Suspended stays parked' + escapeHTML(String(suspendedCount) + ' workspace' + (suspendedCount === 1 ? ' is' : 's are') + ' suspended and intentionally out of day-to-day use.') + '
' @@ -351,11 +351,11 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s '
' + '
Pulse Account
' + '
Account center
' + - '
' + (hosted ? 'Hosted workspaces, account access, and commercial services' : 'Commercial account services and support') + '
' + + '
' + (hosted ? 'Workspaces, account access, and commercial services' : 'Commercial account services and support') + '
' + '
' + '
' + shellSectionButton('overview', activeSection, '01', 'Overview', hosted ? 'Status, priorities, and next actions' : 'Account summary and access state', hosted ? String(totalWorkspaces) + ' total' : 'Summary') + - shellSectionButton('workspaces', activeSection, '02', hosted ? 'Workspaces' : 'Hosted access', hosted ? 'Hosted workspaces and lifecycle actions' : 'No hosted workspaces are attached yet', hosted ? String(readyWorkspaces) + ' ready' : 'None') + + shellSectionButton('workspaces', activeSection, '02', hosted ? 'Workspaces' : 'Workspace access', hosted ? 'Workspace access and lifecycle actions' : 'No workspaces are attached yet', hosted ? String(readyWorkspaces) + ' ready' : 'None') + shellSectionButton('team', activeSection, '03', 'Team', hosted ? 'Access and team roster' : 'Account membership', canManage ? 'Manage' : 'View') + shellSectionButton('services', activeSection, '04', 'Account services', 'Licenses, billing, refunds, and privacy', '4 tools') + shellSectionButton('support', activeSection, '05', 'Support', hosted ? 'Escalation and account support' : (supportEmail || 'Support contact'), supportEmail ? 'Email' : 'Help') + @@ -450,10 +450,10 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { : 'Next step'; var nextStepCopy = unhealthyCount > 0 ? 'One or more workspaces need review before you treat this account as healthy.' - : checkingCount > 0 - ? 'The fleet is mostly healthy, but there are still workspaces waiting on a completed health check.' + : checkingCount > 0 + ? 'The fleet is mostly healthy, but there are still workspaces waiting on a completed health check.' : suspendedCount > 0 - ? 'Active hosted workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use.' + ? 'Active workspaces look stable. Resume a suspended workspace only when you are ready to bring it back into regular use.' : 'Everything looks stable. Move into Team or Account services only if you need to change access or billing.'; var nextStepChecklist = unhealthyCount > 0 ? ( @@ -473,7 +473,7 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { ) : ( '
' + - '
1. Open a workspace for the next operational taskMove into Workspaces when you are ready to do hosted work.
' + + '
1. Open a workspace for the next operational taskMove into Workspaces when you are ready to do workspace work.
' + '
2. Change access in Team onlyKeep roster changes explicit instead of mixing them into routine workspace work.
' + '
3. Keep billing and privacy separateLicenses, refunds, privacy, and self-hosted billing stay in their own section.
' + '
' @@ -485,7 +485,7 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { '
'; var accountScopeCopy = account.kind === 'msp' ? 'Manage client workspaces, billing, and team access from one place.' - : 'Manage hosted workspaces, billing, and team access from one place.'; + : 'Manage workspaces, billing, and team access from one place.'; var overviewBriefStrip = ''; @@ -512,7 +512,7 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string { renderSectionContextChips([ String(totalCount) + ' total', String(readyCount) + ' ready', - suspendedCount > 0 ? String(suspendedCount) + ' suspended' : 'Active fleet', + suspendedCount > 0 ? String(suspendedCount) + ' suspended' : 'All active', ]) + renderOverviewMetricStrip(totalCount, readyCount, checkingCount, unhealthyCount, suspendedCount) + '
' + @@ -759,10 +759,10 @@ function renderAccountTeamSection(account: PortalAccountSummary): string { '

Use the roster as a controlled access list, not a dumping ground for vague shared access.

' + '' + '
' + - '
' + - 'Owners stay rare' + - 'Reserve Owner for billing, team, and full hosted control. Default to Admin, Tech, or Read-only first.' + - '
' + + '
' + + 'Owners stay rare' + + 'Reserve Owner for billing, team, and full account control. Default to Admin, Tech, or Read-only first.' + + '
' + '
' + 'Keep access narrow' + 'Use Tech for workspace control and Read-only for verification instead of handing out broader access.' + @@ -786,11 +786,11 @@ function renderAccountTeamSection(account: PortalAccountSummary): string { '
' + '' + '

Account access

' + - '

Owners govern billing and access. Admins and techs keep hosted work moving day to day.

' + + '

Owners govern billing and access. Admins and techs keep workspace operations moving day to day.

' + renderSectionContextChips([ account.can_manage ? 'Managed roster' : 'View only', 'Least privilege', - 'Hosted access', + 'Workspace access', ]) + '
' + '' + @@ -1010,7 +1010,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string '
' + '' + '

Self-hosted tools

' + - '

Pick one commercial request and keep it separate from hosted workspaces and team changes.

' + + '

Pick one commercial request and keep it separate from workspace and team changes.

' + '
' + '
' + renderServiceActionRow('open-manage-service', 'Billing', 'Manage subscriptions', 'Billing', 'Open Stripe for self-hosted plan, invoice, and payment changes.', 'manage-service-panel', 'manage-inline-email', ['Plan changes', 'Invoices']) + @@ -1119,7 +1119,7 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string { '
' + '' + '

Sign in to Pulse Account

' + - '

Use one commercial email address to get into hosted workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

' + + '

Use one commercial email address to get into workspaces, MSP access, billing, license recovery, refunds, and privacy actions.

' + '
' + '
' + '
' +