fix(portal): normalize legacy team roles

This commit is contained in:
rcourtman
2026-03-27 18:03:54 +00:00
parent 5d3acb580f
commit 2ce2848640
6 changed files with 74 additions and 23 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "b48ed843c8ab211f26d40bdb069e5a6a3f35c206eedef2cf15301ca0f640e8e3",
"source_hash": "b99ef3907290e1285e448dd8af5af07b4767f6fe4f6afd76d1b46722980f5a18",
"build_inputs": [
"package.json",
"tsconfig.json",
+16 -10
View File
@@ -1,7 +1,11 @@
(() => {
// src/account_view.ts
function normalizedTeamRole(role) {
if (role === "member") return "read_only";
return role || "read_only";
}
function roleLabel(role) {
switch (role) {
switch (normalizedTeamRole(role)) {
case "owner":
return "Owner";
case "admin":
@@ -17,7 +21,7 @@
}
}
function roleCapabilityCopy(role) {
switch (role) {
switch (normalizedTeamRole(role)) {
case "owner":
return "Full account control, including billing, team access, and hosted operations.";
case "admin":
@@ -110,7 +114,7 @@
function countMembersByRole(members, role) {
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;
}
@@ -133,10 +137,11 @@
stats.innerHTML = '<div class="team-stat-card"><span class="team-stat-label">Members</span><span class="team-stat-value">' + String(members.length) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Owners</span><span class="team-stat-value">' + String(countMembersByRole(members, "owner")) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Admins</span><span class="team-stat-value">' + String(countMembersByRole(members, "admin")) + '</span></div><div class="team-stat-card"><span class="team-stat-label">Operators</span><span class="team-stat-value">' + String(countMembersByRole(members, "tech") + countMembersByRole(members, "read_only")) + "</span></div>";
}
function renderTeamRoleControl(accountID, member, isOwner) {
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;
}
var sel = document.createElement("select");
@@ -146,7 +151,7 @@
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");
@@ -155,7 +160,7 @@
return sel;
}
function renderTeamMemberAction(accountID, member, isOwner) {
if (member.role === "owner" && !isOwner) {
if (normalizedTeamRole(member.role) === "owner" && !isOwner) {
return null;
}
var btn = document.createElement("button");
@@ -1763,11 +1768,12 @@
}
function renderAccountOverviewSection(account) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
var summaryText = accountKindLabel(account) + " \xB7 " + titleCase(account.role) + " \xB7 " + 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.";
var actions = "";
var addWorkspaceForm = "";
@@ -1777,8 +1783,8 @@
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (for example, a client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
}
}
return '<section class="account-content-panel account-content-panel-overview"><div class="account-command-deck"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(account.name) + "</h3><p>" + escapeHTML(summaryText) + '</p><div class="account-overview-callout">' + 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 '<section class="account-content-panel account-content-panel-overview"><div class="account-command-deck"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(postureTitle) + "</h3><p>" + escapeHTML(postureCopy) + '</p><div class="account-overview-callout">' + 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."
) + '</div></div><div class="account-metric-strip"><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + '<div class="account-overview-secondary"><div class="overview-side-card overview-side-card-primary"><div class="account-panel-kicker">Operator overview</div><h4>Start from the next action, not the whole account</h4><p>Use the overview for posture, then move into workspaces, team, or account services depending on what needs attention.</p><div class="overview-quick-actions">' + 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") + "</div></div>" + renderAttentionPanel(workspaces) + "</div></div></section>";
}
function renderAccountWorkspaceSection(account, accountAPIBasePath) {
@@ -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 =
'<div id="team-section-acct_1" class="team-section" data-actor-role="owner">' +
'<div id="team-stats-acct_1"></div>' +
'<div id="team-list-acct_1"></div>' +
'</div>';
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 =
'<div id="workspace-management-acct_1" class="workspace-management-panel">' +
@@ -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;
}
@@ -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');
@@ -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 {
'<div class="account-command-deck">' +
'<div class="account-overview-card">' +
'<div class="account-panel-kicker">Hosted posture</div>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p>' + escapeHTML(summaryText) + '</p>' +
'<h3>' + escapeHTML(postureTitle) + '</h3>' +
'<p>' + escapeHTML(postureCopy) + '</p>' +
'<div class="account-overview-callout">' + 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.'
) + '</div>' +
'</div>' +
'<div class="account-metric-strip">' +