' + escapeHTML(
- account.kind === "msp" ? "This is the control surface for hosted clients, account billing, and operator access." : "This is the control surface for hosted workspaces, billing, and account access."
+ return '
Hosted posture
' + escapeHTML(postureTitle) + "
" + escapeHTML(postureCopy) + '
' + escapeHTML(
+ account.kind === "msp" ? "Use this console to run client workspaces, account billing, and operator access from one place." : "Use this console to run hosted workspaces, account billing, and operator access from one place."
) + '
Active workspaces' + String(activeCount) + '
Healthy' + String(healthyCount) + '
Checking' + String(checkingCount) + '
Needs attention' + String(unhealthyCount) + "
" + actions + addWorkspaceForm + '
Operator overview
Start from the next action, not the whole account
Use the overview for posture, then move into workspaces, team, or account services depending on what needs attention.
' + renderOverviewQuickAction("workspaces", "Open workspaces", "Review the hosted fleet and move into a workspace") + renderOverviewQuickAction("team", "Review team access", "Check who can operate billing and hosted workspaces") + renderOverviewQuickAction("services", "Open account services", "Handle billing, licenses, refunds, and privacy actions") + "
" + renderAttentionPanel(workspaces) + "
";
}
function renderAccountWorkspaceSection(account, accountAPIBasePath) {
diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts
index f06b9d5217..db747e0e3f 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts
@@ -111,6 +111,34 @@ describe('account view', function() {
expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('2');
});
+ it('normalizes legacy member roles into the current read-only operator model', function() {
+ document.body.innerHTML =
+ '
';
+
+ renderTeamSection(
+ 'acct_1',
+ createEntry({
+ teamVisible: true,
+ teamQuery: {
+ status: 'ready',
+ error: '',
+ data: [
+ { email: 'legacy@example.com', role: 'member', user_id: 'u_legacy' },
+ ],
+ },
+ })
+ );
+
+ var roleSelect = document.querySelector('.team-role-select') as HTMLSelectElement;
+ expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Read-only');
+ expect(roleSelect.value).toBe('read_only');
+ expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Operators');
+ expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('1');
+ });
+
it('renders workspace management selection from account UI state', function() {
document.body.innerHTML =
'
' +
diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts
index c156064651..35dfb78ea5 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.ts
@@ -8,8 +8,13 @@ import type {
type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
+function normalizedTeamRole(role: string): string {
+ if (role === 'member') return 'read_only';
+ return role || 'read_only';
+}
+
function roleLabel(role: string): string {
- switch (role) {
+ switch (normalizedTeamRole(role)) {
case 'owner':
return 'Owner';
case 'admin':
@@ -26,7 +31,7 @@ function roleLabel(role: string): string {
}
function roleCapabilityCopy(role: string): string {
- switch (role) {
+ switch (normalizedTeamRole(role)) {
case 'owner':
return 'Full account control, including billing, team access, and hosted operations.';
case 'admin':
@@ -132,7 +137,7 @@ function setContainerMessage(container: HTMLElement, msg: string, isError: boole
function countMembersByRole(members: PortalTeamMember[], role: string): number {
var count = 0;
for (var i = 0; i < members.length; i += 1) {
- if (members[i].role === role) count += 1;
+ if (normalizedTeamRole(members[i].role) === role) count += 1;
}
return count;
}
@@ -162,10 +167,11 @@ function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void {
}
function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLElement {
- if (member.role === 'owner' && !isOwner) {
+ var currentRole = normalizedTeamRole(member.role);
+ if (currentRole === 'owner' && !isOwner) {
var locked = document.createElement('span');
locked.className = 'team-role-badge';
- locked.textContent = roleLabel(member.role);
+ locked.textContent = roleLabel(currentRole);
return locked;
}
@@ -176,7 +182,7 @@ function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOw
var opt = document.createElement('option');
opt.value = roles[j];
opt.textContent = roleLabel(roles[j]);
- if (member.role === roles[j]) opt.selected = true;
+ if (currentRole === roles[j]) opt.selected = true;
sel.appendChild(opt);
}
sel.setAttribute('data-action', 'change-role');
@@ -186,7 +192,7 @@ function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOw
}
function renderTeamMemberAction(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLElement | null {
- if (member.role === 'owner' && !isOwner) {
+ if (normalizedTeamRole(member.role) === 'owner' && !isOwner) {
return null;
}
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
index ab281b3d37..cb64b1fa47 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts
@@ -142,6 +142,8 @@ describe('shell view', function() {
expect(html).toContain('Manage the client fleet from this account surface.');
expect(html).toContain('Operator overview');
expect(html).toContain('Start from the next action, not the whole account');
+ expect(html).toContain('Hosted posture needs review');
+ expect(html).toContain('Use this console to run client workspaces, account billing, and operator access from one place.');
expect(html).toContain('Open workspaces');
expect(html).toContain('Review team access');
expect(html).toContain('Needs review');
diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts
index 3be5c85a17..b73c0400fc 100644
--- a/internal/cloudcp/portal/frontend/src/shell_view.ts
+++ b/internal/cloudcp/portal/frontend/src/shell_view.ts
@@ -328,11 +328,20 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
function renderAccountOverviewSection(account: PortalAccountSummary): string {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
- var summaryText = accountKindLabel(account) + ' · ' + titleCase(account.role) + ' · ' + workspaceCountLabel(workspaces.length);
var healthyCount = countWorkspacesByHealth(workspaces, 'healthy');
var checkingCount = countWorkspacesByHealth(workspaces, 'checking');
var unhealthyCount = countWorkspacesByHealth(workspaces, 'unhealthy');
var activeCount = countWorkspacesByState(workspaces, 'active');
+ var postureTitle = unhealthyCount > 0
+ ? 'Hosted posture needs review'
+ : checkingCount > 0
+ ? 'Hosted posture is still settling'
+ : 'Hosted posture is stable';
+ var postureCopy = unhealthyCount > 0
+ ? 'One or more workspaces still need attention before the hosted fleet is trustworthy.'
+ : checkingCount > 0
+ ? 'The hosted fleet is mostly healthy, but some workspaces are still waiting on a completed health check.'
+ : 'The hosted fleet is healthy and ready for routine operator work.';
var operationsCopy = account.kind === 'msp'
? 'Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here.'
: 'Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.';
@@ -398,11 +407,11 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string {
'
' +
'
' +
'
Hosted posture
' +
- '
' + escapeHTML(account.name) + '
' +
- '
' + escapeHTML(summaryText) + '
' +
+ '
' + escapeHTML(postureTitle) + '
' +
+ '
' + escapeHTML(postureCopy) + '
' +
'
' + escapeHTML(account.kind === 'msp'
- ? 'This is the control surface for hosted clients, account billing, and operator access.'
- : 'This is the control surface for hosted workspaces, billing, and account access.'
+ ? 'Use this console to run client workspaces, account billing, and operator access from one place.'
+ : 'Use this console to run hosted workspaces, account billing, and operator access from one place.'
) + '
' +
'
' +
'
' +