From 23df63ed978074f2a21a7ee7ab41bf0ed98deb21 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 14:09:37 +0000 Subject: [PATCH] feat(portal): add explicit team management panel --- .../v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md | 3 + .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 94 ++++++++++++++++++- internal/cloudcp/portal/dist/portal_app.js | 28 +++++- .../frontend/src/account_runtime.test.ts | 2 + .../portal/frontend/src/account_view.test.ts | 12 ++- .../portal/frontend/src/account_view.ts | 33 +++++++ .../portal/frontend/src/shell_view.test.ts | 3 + .../cloudcp/portal/frontend/src/shell_view.ts | 29 +++++- .../cloudcp/portal/frontend/src/styles.css | 23 ++++- 10 files changed, 214 insertions(+), 15 deletions(-) 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 0c8b510440..4e3b88ada3 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -143,6 +143,9 @@ Core rules: 10. Workspace lifecycle actions must open in an explicit management surface with the selected workspace context visible, not inside a hidden overflow menu or a blind confirm-first interaction. +11. Team and membership management must be treated as an account-management + surface with visible roster and invite context, not as a hidden table that + appears without orientation. ## Screen Model diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 2e601fff06..eb7ba35590 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "9e66ef00fa2623eb7e00f30d4cd4a823bfb4552eeb89136392577ad25b7caad2", + "source_hash": "ffa91a3e86257e3723273ec4a2950841ad9a5d3ed8d67b74c591d825ed5e1103", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index b12e37421c..09ad2b7acd 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -451,19 +451,92 @@ header .logout-btn:hover { .team-section { margin-top: 12px; display: none; +} +.team-section.visible { + display: block; +} +.team-management-panel { + background: #fff; + border: 1px solid #dbe4f0; + border-radius: 12px; + padding: 18px; + box-shadow: 0 1px 3px rgba(15, 23, 42, .04); +} +.team-management-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 14px; +} +.team-management-header h3 { + margin: 0 0 4px; + font-size: 16px; + font-weight: 700; +} +.team-management-header p { + margin: 0; + font-size: 13px; + color: #64748b; + line-height: 1.5; + max-width: 560px; +} +.team-management-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 16px; +} +.team-stat-card { + background: #f8fafc; + border: 1px solid #e2e8f0; + border-radius: 10px; + padding: 12px 14px; + display: flex; + flex-direction: column; + gap: 6px; +} +.team-stat-label { + font-size: 12px; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; + color: #64748b; +} +.team-stat-value { + font-size: 20px; + font-weight: 700; + color: #0f172a; +} +.team-stat-error { + color: #b91c1c; +} +.team-management-grid { + display: grid; + grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); + gap: 16px; +} +.team-roster, +.team-invite-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } -.team-section.visible { - display: block; +.team-panel-heading { + margin-bottom: 12px; } -.team-section h3 { - margin: 0 0 12px; +.team-panel-heading h4 { + margin: 0 0 4px; font-size: 15px; font-weight: 700; } +.team-panel-heading p { + margin: 0; + font-size: 13px; + color: #64748b; + line-height: 1.5; +} .team-table { width: 100%; border-collapse: collapse; @@ -511,7 +584,6 @@ header .logout-btn:hover { text-decoration: underline; } .team-invite { - margin-top: 12px; display: flex; gap: 8px; align-items: flex-end; @@ -917,6 +989,15 @@ header .logout-btn:hover { .workspace-management-header { flex-direction: column; } + .team-management-header { + flex-direction: column; + } + .team-management-stats { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + .team-management-grid { + grid-template-columns: 1fr; + } .overview-stats { grid-template-columns: 1fr; } @@ -925,6 +1006,9 @@ header .logout-btn:hover { .account-status-grid { grid-template-columns: 1fr; } + .team-management-stats { + grid-template-columns: 1fr; + } .workspace-card { flex-direction: column; align-items: flex-start; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 5cb61cc8a2..1b2a8adba5 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -78,6 +78,31 @@ tr.appendChild(td); tbody.appendChild(tr); } + function countMembersByRole(members, role) { + var count = 0; + for (var i = 0; i < members.length; i += 1) { + if (members[i].role === role) count += 1; + } + return count; + } + function renderTeamStats(accountID, entry) { + var stats = getElement("team-stats-" + accountID); + if (!stats) return; + if (!entry.teamVisible) { + stats.innerHTML = ""; + return; + } + if (entry.teamQuery.status === "loading") { + stats.innerHTML = '
RosterLoading\u2026
'; + return; + } + if (entry.teamQuery.status === "error") { + stats.innerHTML = '
RosterNeeds attention
'; + return; + } + var members = entry.teamQuery.data; + stats.innerHTML = '
Members' + String(members.length) + '
Owners' + String(countMembersByRole(members, "owner")) + '
Admins' + String(countMembersByRole(members, "admin")) + '
Operators' + String(countMembersByRole(members, "tech") + countMembersByRole(members, "read_only")) + "
"; + } function renderTeamMemberRoleCell(accountID, member, isOwner) { var tdRole = document.createElement("td"); if (member.role === "owner" && !isOwner) { @@ -131,6 +156,7 @@ var actorRole = section.getAttribute("data-actor-role") || ""; var isOwner = actorRole === "owner"; section.classList.toggle("visible", entry.teamVisible); + renderTeamStats(accountID, entry); if (!entry.teamVisible) { return; } @@ -1586,7 +1612,7 @@ var workspaceManagement = ""; if (account.can_manage) { actions = '
'; - teamSection = '

Team members

EmailRole
Loading\u2026
'; + teamSection = '

Team management

Control who can operate this account, what role they hold, and who should be invited next.

People on this account

Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.

EmailRole
Loading\u2026

Invite someone new

Add another operator with the minimum role they need for this account.

'; workspaceManagement = '

Workspace management

Select a workspace from the fleet to review its lifecycle state and run explicit management actions.

Choose a workspace to manage from the fleet above.
'; if (account.kind === "msp") { addWorkspaceForm = '
'; diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index e49b48e91b..5f310d70b2 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -176,6 +176,7 @@ describe('account runtime', function() { document.body.innerHTML = '
' + '
' + + '
' + '
' + '' + '' + @@ -187,6 +188,7 @@ describe('account runtime', function() { expect(deps.store.getAccountState().byAccountID.acct_1.teamVisible).toBe(true); expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.status).toBe('ready'); expect(deps.store.getAccountState().byAccountID.acct_1.teamQuery.data).toHaveLength(1); + expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members'); var roleSelect = document.querySelector('[data-action="change-role"]') as HTMLSelectElement | null; expect(roleSelect).not.toBeNull(); expect(roleSelect?.value).toBe('owner'); diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index 8df91f0d46..f06b9d5217 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -75,6 +75,7 @@ describe('account view', function() { it('renders team loading, error, and populated member states', function() { document.body.innerHTML = '
' + + '
' + '
' + '
'; @@ -97,12 +98,17 @@ describe('account view', function() { teamQuery: { status: 'ready', error: '', - data: [{ email: 'owner@example.com', role: 'owner', user_id: 'u1' }], + data: [ + { email: 'owner@example.com', role: 'owner', user_id: 'u1' }, + { email: 'tech@example.com', role: 'tech', user_id: 'u2' }, + ], }, }) ); expect(document.querySelector('[data-action="change-role"]')).not.toBeNull(); expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull(); + expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('Members'); + expect(document.getElementById('team-stats-acct_1')?.textContent).toContain('2'); }); it('renders workspace management selection from account UI state', function() { @@ -130,10 +136,10 @@ describe('account view', function() { document.body.innerHTML = '
' + '
' + - '
' + + '
' + '
' + '
' + - '
'; + '
'; var state: PortalAccountState = { byAccountID: { diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 5b2ccc9b83..f4c50c2938 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -98,6 +98,38 @@ function setTbodyMessage(tbody: HTMLElement, msg: string, isError: boolean): voi tbody.appendChild(tr); } +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; + } + return count; +} + +function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void { + var stats = getElement('team-stats-' + accountID); + if (!stats) return; + if (!entry.teamVisible) { + stats.innerHTML = ''; + return; + } + if (entry.teamQuery.status === 'loading') { + stats.innerHTML = '
RosterLoading…
'; + return; + } + if (entry.teamQuery.status === 'error') { + stats.innerHTML = '
RosterNeeds attention
'; + return; + } + + var members = entry.teamQuery.data; + stats.innerHTML = + '
Members' + String(members.length) + '
' + + '
Owners' + String(countMembersByRole(members, 'owner')) + '
' + + '
Admins' + String(countMembersByRole(members, 'admin')) + '
' + + '
Operators' + String(countMembersByRole(members, 'tech') + countMembersByRole(members, 'read_only')) + '
'; +} + function renderTeamMemberRoleCell(accountID: string, member: PortalTeamMember, isOwner: boolean): HTMLTableCellElement { var tdRole = document.createElement('td'); if (member.role === 'owner' && !isOwner) { @@ -157,6 +189,7 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry var actorRole = section.getAttribute('data-actor-role') || ''; var isOwner = actorRole === 'owner'; section.classList.toggle('visible', entry.teamVisible); + renderTeamStats(accountID, entry); if (!entry.teamVisible) { return; diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index 63dca4b3a8..48bc613d69 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -151,6 +151,9 @@ describe('shell view', function() { expect(html).toContain('Workspace management'); expect(html).toContain('Choose a workspace to manage from the fleet above.'); expect(html).toContain('data-action="clear-workspace-selection"'); + expect(html).toContain('Team management'); + expect(html).toContain('Invite someone new'); + expect(html).toContain('People on this account'); expect(html).toContain('data-action="workspace-action"'); expect(html).toContain('service-card-button'); expect(html).toContain('id="open-retrieve-service"'); diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 10b2f06a8e..18726da019 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -239,12 +239,29 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath: '
'; teamSection = - '
' + - '

Team members

' + + '
' + + '
' + + '

Team management

' + + '

Control who can operate this account, what role they hold, and who should be invited next.

' + + '
' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '

People on this account

' + + '

Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.

' + + '
' + '' + '' + 'Loading…' + '' + '
EmailRole
' + + '
' + + '
' + + '
' + + '

Invite someone new

' + + '

Add another operator with the minimum role they need for this account.

' + + '
' + '
' + '
' + + '
' + + '
' + '
'; workspaceManagement = diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index b8360fe28c..27ad4f434f 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -76,9 +76,22 @@ .add-workspace-form label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; } .add-workspace-form input { width: 100%; border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; margin-bottom: 10px; } .add-workspace-form .form-actions { display: flex; gap: 8px; } - .team-section { margin-top: 12px; display: none; background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } + .team-section { margin-top: 12px; display: none; } .team-section.visible { display: block; } - .team-section h3 { margin: 0 0 12px; font-size: 15px; font-weight: 700; } + .team-management-panel { background: #fff; border: 1px solid #dbe4f0; border-radius: 12px; padding: 18px; box-shadow: 0 1px 3px rgba(15,23,42,.04); } + .team-management-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 14px; } + .team-management-header h3 { margin: 0 0 4px; font-size: 16px; font-weight: 700; } + .team-management-header p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; max-width: 560px; } + .team-management-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px; margin-bottom: 16px; } + .team-stat-card { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 12px 14px; display: flex; flex-direction: column; gap: 6px; } + .team-stat-label { font-size: 12px; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; color: #64748b; } + .team-stat-value { font-size: 20px; font-weight: 700; color: #0f172a; } + .team-stat-error { color: #b91c1c; } + .team-management-grid { display: grid; grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); gap: 16px; } + .team-roster, .team-invite-panel { background: #f8fafc; border: 1px solid #e2e8f0; border-radius: 10px; padding: 16px; } + .team-panel-heading { margin-bottom: 12px; } + .team-panel-heading h4 { margin: 0 0 4px; font-size: 15px; font-weight: 700; } + .team-panel-heading p { margin: 0; font-size: 13px; color: #64748b; line-height: 1.5; } .team-table { width: 100%; border-collapse: collapse; font-size: 14px; } .team-table th { text-align: left; font-size: 12px; font-weight: 600; color: #64748b; text-transform: uppercase; letter-spacing: 0.5px; padding: 6px 8px; border-bottom: 1px solid #e2e8f0; } .team-table td { padding: 8px; border-bottom: 1px solid #f1f5f9; vertical-align: middle; } @@ -87,7 +100,7 @@ .team-table select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 4px 8px; font-size: 13px; background: #fff; } .team-table .btn-remove { background: none; border: none; color: #dc2626; cursor: pointer; font-size: 13px; padding: 4px 8px; } .team-table .btn-remove:hover { text-decoration: underline; } - .team-invite { margin-top: 12px; display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } + .team-invite { display: flex; gap: 8px; align-items: flex-end; flex-wrap: wrap; } .team-invite label { font-size: 12px; font-weight: 600; display: block; margin-bottom: 4px; } .team-invite input { border: 1px solid #cbd5e1; border-radius: 8px; padding: 8px 12px; font-size: 14px; min-width: 200px; } .team-invite select { border: 1px solid #cbd5e1; border-radius: 6px; padding: 8px 10px; font-size: 14px; background: #fff; } @@ -163,10 +176,14 @@ .account-actions { justify-content: flex-start; } .account-subsection-header { flex-direction: column; align-items: flex-start; } .workspace-management-header { flex-direction: column; } + .team-management-header { flex-direction: column; } + .team-management-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } + .team-management-grid { grid-template-columns: 1fr; } .overview-stats { grid-template-columns: 1fr; } } @media (max-width: 560px) { .account-status-grid { grid-template-columns: 1fr; } + .team-management-stats { grid-template-columns: 1fr; } .workspace-card { flex-direction: column; align-items: flex-start; } .ws-actions { align-self: stretch; } .ws-actions form, .ws-actions .btn-primary { width: 100%; text-align: center; }