Make portal readonly shell honest

This commit is contained in:
rcourtman
2026-03-28 21:33:49 +00:00
parent 0b98a0d3e0
commit 30abdc1922
7 changed files with 265 additions and 48 deletions
@@ -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
@@ -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.
@@ -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.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "f9e6d5b177cc9d390fdf659da4acb4335a14f618e32b0f31599072dbe6fe1d79",
"source_hash": "3db6522b355842bfc018e0dbeab09df9411de3475c6af9e2fff17a388b91a424",
"build_inputs": [
"package.json",
"tsconfig.json",
File diff suppressed because one or more lines are too long
@@ -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() {
@@ -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') +
'</div>' +
'</aside>'
);
@@ -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 (
'<article class="overview-task-card">' +
@@ -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.'
) + '</span></div>' +
'<div class="overview-task-item"><strong>' + escapeHTML(suspendedCount > 0 ? 'Suspended stays parked' : 'Billing stays separate') + '</strong><span>' + escapeHTML(suspendedCount > 0
'<div class="overview-task-item"><strong>' + escapeHTML(suspendedCount > 0 ? 'Suspended stays parked' : billingSeparation.title) + '</strong><span>' + 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.'
: billingSeparation.copy
) + '</span></div>' +
'</div>' +
'</article>'
@@ -725,6 +791,7 @@ function renderOverviewNextActionCard(accounts: PortalAccountSummary[], entries:
function renderShellOverviewSection(context: ShellViewContext): string {
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;
@@ -750,7 +817,7 @@ function renderShellOverviewSection(context: ShellViewContext): string {
'</div>' +
'</div>' +
'<div class="overview-task-grid">' +
renderOverviewAttentionCard(entries, accounts.length) +
renderOverviewAttentionCard(accounts, entries, showSelfHostedCommercial) +
renderOverviewReadyCard(accounts, entries, context.accountAPIBasePath) +
renderOverviewNextActionCard(accounts, entries, context.accountAPIBasePath) +
'</div>' +
@@ -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
? (
'<div class="portal-support-route-card">' +
'<div class="account-panel-kicker">Hosted path</div>' +
'<h3>Workspace or access path failed</h3>' +
'<p>Go back to the hosted task first. Escalate only when the same workspace or access path still cannot finish the job.</p>' +
'<h3>' + (hostedViewOnly ? 'Hosted review or owner/admin path failed' : 'Workspace or access path failed') + '</h3>' +
'<p>' + (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.') + '</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Start from the same task</strong><span>Use Workspaces for lifecycle issues and Access for roster issues before you escalate.</span></div>' +
'<div class="portal-support-point"><strong>Keep the hosted context intact</strong><span>Include the account, workspace, and failed action so support inherits the same request.</span></div>' +
'<div class="portal-support-point"><strong>' + (hostedViewOnly ? 'Review the same task' : 'Start from the same task') + '</strong><span>' + (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.') + '</span></div>' +
'<div class="portal-support-point"><strong>' + (hostedViewOnly ? 'Name the blocked owner/admin action' : 'Keep the hosted context intact') + '</strong><span>' + (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.') + '</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Open workspaces</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">Open access</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">' + (hostedViewOnly ? 'Review workspaces' : 'Open workspaces') + '</button>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="access">' + (hostedViewOnly ? 'Review access' : 'Open access') + '</button>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(supportEmail) + '">' + escapeHTML(supportEmail) + '</a>' +
'</div>' +
'</div>' +
'<div class="portal-support-route-card">' +
'<div class="account-panel-kicker">Billing path</div>' +
'<h3>' + (showSelfHostedCommercial ? 'Billing path failed' : 'Hosted billing path failed') + '</h3>' +
'<p>' + (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.') + '</p>' +
'<h3>' + (hostedViewOnly
? (showSelfHostedCommercial ? 'Billing or owner/admin path failed' : 'Hosted billing or owner/admin path failed')
: (showSelfHostedCommercial ? 'Billing path failed' : 'Hosted billing path failed')) + '</h3>' +
'<p>' + (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.')) + '</p>' +
'<div class="portal-support-points">' +
'<div class="portal-support-point"><strong>Name the billing job</strong><span>' + (showSelfHostedCommercial
? 'Say whether the failed path was hosted billing, licenses, refunds, or privacy.'
: 'Say whether the failed path was hosted billing.') + '</span></div>' +
'<div class="portal-support-point"><strong>Keep the request intact</strong><span>' + (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.') + '</span></div>' +
'<div class="portal-support-point"><strong>Name the billing job</strong><span>' + (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.')) + '</span></div>' +
'<div class="portal-support-point"><strong>Keep the request intact</strong><span>' + (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.')) + '</span></div>' +
'</div>' +
'<div class="portal-support-actions">' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="billing">Open billing</button>' +
@@ -1224,12 +1324,20 @@ function renderSupportSection(context: ShellViewContext): string {
);
var runbookSteps = hasHostedAccounts
? (
'<div class="portal-support-runbook-step"><strong>1. Failed path</strong><span>' + (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.') + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + (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.') + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>1. Failed path</strong><span>' + (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.')) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>2. Account or email</strong><span>' + (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.')) + '</span></div>' +
'<div class="portal-support-runbook-step"><strong>3. Failed action</strong><span>Name the exact button, form, or billing step that failed and what happened next.</span></div>'
)
: (