From 0ccc18cd4c9fc736864707102ce0a0c554a588b6 Mon Sep 17 00:00:00 2001 From: rcourtman Date: Fri, 27 Mar 2026 17:41:29 +0000 Subject: [PATCH] style(portal): harden team management surface --- .../cloudcp/portal/dist/build_manifest.json | 2 +- internal/cloudcp/portal/dist/portal_app.css | 66 +++++++++++++---- internal/cloudcp/portal/dist/portal_app.js | 35 +++++++-- .../portal/frontend/src/account_view.ts | 37 ++++++++-- .../cloudcp/portal/frontend/src/shell_view.ts | 5 ++ .../cloudcp/portal/frontend/src/styles.css | 73 +++++++++++++++---- 6 files changed, 174 insertions(+), 44 deletions(-) diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index 0686537f02..5582d7b9bc 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "c17b1452a1c3fa53f55c51b5219dddbd5ee5faec28d79f4743271b3a3be0e4f1", + "source_hash": "b5bf3d15198c6bb317c1446735208077a02ef32a47dca5b012200aba131071ef", "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 37488709f1..2e397e3631 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -980,7 +980,7 @@ header .logout-btn:hover, } .team-roster-head { display: grid; - grid-template-columns: minmax(0, 1fr) 120px auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 0 8px 2px; font-size: 11px; @@ -991,10 +991,10 @@ header .logout-btn:hover, } .team-member-row { display: grid; - grid-template-columns: minmax(0, 1fr) 120px auto; - align-items: center; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; gap: 16px; - padding: 12px 14px; + padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: #fff; @@ -1002,7 +1002,14 @@ header .logout-btn:hover, .team-member-identity { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; + min-width: 0; +} +.team-member-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; min-width: 0; } .team-member-email { @@ -1011,7 +1018,8 @@ header .logout-btn:hover, color: var(--ink); } .team-member-caption { - font-size: 12px; + font-size: 13px; + line-height: 1.55; color: var(--ink-soft); } .team-member-controls { @@ -1022,13 +1030,6 @@ header .logout-btn:hover, flex-wrap: wrap; min-width: 0; } -.team-member-role-summary { - font-size: 12px; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-muted); -} .team-role-badge { display: inline-flex; align-items: center; @@ -1040,6 +1041,20 @@ header .logout-btn:hover, font-size: 12px; font-weight: 700; } +.team-inline-role-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--panel-muted); + color: var(--ink-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} .team-role-select { min-width: 140px; } @@ -1071,6 +1086,31 @@ header .logout-btn:hover, flex-direction: column; gap: 12px; } +.team-invite-guide { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; +} +.team-invite-guide-row { + display: flex; + flex-direction: column; + gap: 2px; +} +.team-invite-guide-row strong { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} +.team-invite-guide-row span { + font-size: 13px; + line-height: 1.5; + color: var(--ink-soft); +} .service-action-list { display: flex; flex-direction: column; diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 8bdbeed728..61767cff5d 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -16,6 +16,22 @@ return role || "Member"; } } + function roleCapabilityCopy(role) { + switch (role) { + case "owner": + return "Full account control, including billing, team access, and hosted operations."; + case "admin": + return "Can manage hosted operations and billing for this account."; + case "tech": + return "Can operate hosted workspaces without billing ownership."; + case "read_only": + return "Can review hosted state without making control-plane changes."; + case "member": + return "Has access through the account roster."; + default: + return "Has access through the account roster."; + } + } function getElement(id) { return document.getElementById(id); } @@ -157,24 +173,27 @@ row.className = "team-member-row"; var identity = document.createElement("div"); identity.className = "team-member-identity"; + var topline = document.createElement("div"); + topline.className = "team-member-topline"; var email = document.createElement("div"); email.className = "team-member-email"; email.textContent = member.email; - identity.appendChild(email); + topline.appendChild(email); + var roleBadge = document.createElement("span"); + roleBadge.className = "team-inline-role-badge"; + roleBadge.textContent = roleLabel(member.role); + topline.appendChild(roleBadge); + identity.appendChild(topline); var caption = document.createElement("div"); caption.className = "team-member-caption"; - caption.textContent = member.role === "owner" ? "Full account control" : "Hosted account access"; + caption.textContent = roleCapabilityCopy(member.role); identity.appendChild(caption); - var roleSummary = document.createElement("div"); - roleSummary.className = "team-member-role-summary"; - roleSummary.textContent = roleLabel(member.role); var controls = document.createElement("div"); controls.className = "team-member-controls"; controls.appendChild(renderTeamRoleControl(accountID, member, isOwner)); var action = renderTeamMemberAction(accountID, member, isOwner); if (action) controls.appendChild(action); row.appendChild(identity); - row.appendChild(roleSummary); row.appendChild(controls); return row; } @@ -183,7 +202,7 @@ if (existing) return; var head = document.createElement("div"); head.className = "team-roster-head"; - head.innerHTML = "OperatorAccessControls"; + head.innerHTML = "OperatorControls"; container.appendChild(head); } function renderAddWorkspaceSection(accountID, entry) { @@ -1763,7 +1782,7 @@ return '
' + workspaceHTML + '
"; } function renderAccountTeamSection(account) { - return '

Control who can operate this account

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

People on this account

Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.

Loading\u2026

Invite someone new

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

'; + return '

Control who can operate this account

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

People on this account

Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.

Loading\u2026

Invite someone new

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

AdminBilling and hosted operations.
TechOperational access without billing control.
Read-onlyReview hosted state without making changes.
'; } function renderSupportSection(context) { return '

Support and escalation

Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.

Account support

For access, tenant handoff, team, and billing issues, contact the hosted operations desk.

' + escapeHTML(context.bootstrap.support_email || "") + '

Commercial services

Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the same account surface.

'; diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 84de7be78c..a78cd44e46 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -25,6 +25,23 @@ function roleLabel(role: string): string { } } +function roleCapabilityCopy(role: string): string { + switch (role) { + case 'owner': + return 'Full account control, including billing, team access, and hosted operations.'; + case 'admin': + return 'Can manage hosted operations and billing for this account.'; + case 'tech': + return 'Can operate hosted workspaces without billing ownership.'; + case 'read_only': + return 'Can review hosted state without making control-plane changes.'; + case 'member': + return 'Has access through the account roster.'; + default: + return 'Has access through the account roster.'; + } +} + export function getElement(id: string): T | null { return document.getElementById(id) as T | null; } @@ -191,20 +208,26 @@ function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwne var identity = document.createElement('div'); identity.className = 'team-member-identity'; + var topline = document.createElement('div'); + topline.className = 'team-member-topline'; + var email = document.createElement('div'); email.className = 'team-member-email'; email.textContent = member.email; - identity.appendChild(email); + topline.appendChild(email); + + var roleBadge = document.createElement('span'); + roleBadge.className = 'team-inline-role-badge'; + roleBadge.textContent = roleLabel(member.role); + topline.appendChild(roleBadge); + + identity.appendChild(topline); var caption = document.createElement('div'); caption.className = 'team-member-caption'; - caption.textContent = member.role === 'owner' ? 'Full account control' : 'Hosted account access'; + caption.textContent = roleCapabilityCopy(member.role); identity.appendChild(caption); - var roleSummary = document.createElement('div'); - roleSummary.className = 'team-member-role-summary'; - roleSummary.textContent = roleLabel(member.role); - var controls = document.createElement('div'); controls.className = 'team-member-controls'; controls.appendChild(renderTeamRoleControl(accountID, member, isOwner)); @@ -212,7 +235,6 @@ function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwne if (action) controls.appendChild(action); row.appendChild(identity); - row.appendChild(roleSummary); row.appendChild(controls); return row; } @@ -224,7 +246,6 @@ function ensureRosterHead(container: HTMLElement): void { head.className = 'team-roster-head'; head.innerHTML = 'Operator' + - 'Access' + 'Controls'; container.appendChild(head); } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 6d5b8c8951..082e265bdb 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -566,6 +566,11 @@ function renderAccountTeamSection(account: PortalAccountSummary): string { '">Role' + + '
' + + '
AdminBilling and hosted operations.
' + + '
TechOperational access without billing control.
' + + '
Read-onlyReview hosted state without making changes.
' + + '
' + '' + diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 80d75b12a4..85946c285b 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -1098,7 +1098,7 @@ header .logout-btn:hover, .team-roster-head { display: grid; - grid-template-columns: minmax(0, 1fr) 120px auto; + grid-template-columns: minmax(0, 1fr) auto; gap: 16px; padding: 0 8px 2px; font-size: 11px; @@ -1110,10 +1110,10 @@ header .logout-btn:hover, .team-member-row { display: grid; - grid-template-columns: minmax(0, 1fr) 120px auto; - align-items: center; + grid-template-columns: minmax(0, 1fr) auto; + align-items: start; gap: 16px; - padding: 12px 14px; + padding: 14px 16px; border: 1px solid var(--line); border-radius: 10px; background: #fff; @@ -1122,7 +1122,15 @@ header .logout-btn:hover, .team-member-identity { display: flex; flex-direction: column; - gap: 4px; + gap: 8px; + min-width: 0; +} + +.team-member-topline { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; min-width: 0; } @@ -1133,7 +1141,8 @@ header .logout-btn:hover, } .team-member-caption { - font-size: 12px; + font-size: 13px; + line-height: 1.55; color: var(--ink-soft); } @@ -1146,14 +1155,6 @@ header .logout-btn:hover, min-width: 0; } -.team-member-role-summary { - font-size: 12px; - font-weight: 800; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--ink-muted); -} - .team-role-badge { display: inline-flex; align-items: center; @@ -1166,6 +1167,21 @@ header .logout-btn:hover, font-weight: 700; } +.team-inline-role-badge { + display: inline-flex; + align-items: center; + min-height: 28px; + padding: 0 10px; + border-radius: 999px; + background: var(--panel-muted); + color: var(--ink-soft); + font-size: 11px; + font-weight: 800; + letter-spacing: 0.08em; + text-transform: uppercase; + white-space: nowrap; +} + .team-role-select { min-width: 140px; } @@ -1203,6 +1219,35 @@ header .logout-btn:hover, gap: 12px; } +.team-invite-guide { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; +} + +.team-invite-guide-row { + display: flex; + flex-direction: column; + gap: 2px; +} + +.team-invite-guide-row strong { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + color: var(--ink-muted); +} + +.team-invite-guide-row span { + font-size: 13px; + line-height: 1.5; + color: var(--ink-soft); +} + .service-action-list { display: flex; flex-direction: column;