diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json
index c1bbeb0161..11e4cf71c1 100644
--- a/internal/cloudcp/portal/dist/build_manifest.json
+++ b/internal/cloudcp/portal/dist/build_manifest.json
@@ -1,5 +1,5 @@
{
- "source_hash": "610586b6afe868a59ea40e626d2d5af557bb144053f5a98dfa06ee7394b30625",
+ "source_hash": "e0dc04d4f7be0d3c9048256e3dfff63acb71c236f2c8fd44b237612191a7d696",
"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 6b318135d2..d492f3e93c 100644
--- a/internal/cloudcp/portal/dist/portal_app.css
+++ b/internal/cloudcp/portal/dist/portal_app.css
@@ -1165,26 +1165,42 @@ header .logout-btn:hover,
.team-side-column {
display: flex;
flex-direction: column;
- gap: 14px;
align-self: start;
}
.team-roster,
-.team-invite-panel {
+.team-invite-panel,
+.team-operations-panel {
background: #fff;
border-color: var(--line);
}
.team-roster {
align-self: start;
}
-.team-policy-panel {
+.team-operations-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
- background: #fff;
+}
+.team-operations-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+.team-policy-panel {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel-muted);
+ padding: 14px;
}
.team-panel-heading {
margin-bottom: 12px;
}
+.team-panel-heading-tight {
+ margin-bottom: 0;
+}
.team-panel-heading p {
max-width: 720px;
}
@@ -1282,38 +1298,18 @@ header .logout-btn:hover,
}
.team-list-message {
display: flex;
- flex-direction: column;
- align-items: flex-start;
+ align-items: center;
justify-content: center;
- gap: 8px;
- min-height: 144px;
- padding: 18px;
+ min-height: 120px;
+ padding: 16px;
border: 1px dashed var(--line-strong);
border-radius: 10px;
color: var(--ink-muted);
background: var(--panel-muted);
}
-.team-list-message-title {
- font-size: 15px;
- line-height: 1.15;
- letter-spacing: -0.02em;
- color: var(--ink);
-}
-.team-list-message-copy {
- font-size: 13px;
- line-height: 1.55;
- color: var(--ink-soft);
-}
.team-list-message.error {
- border-color: #efc1c1;
- background: #fff4f4;
-}
-.team-list-message.error .team-list-message-title {
color: var(--danger);
}
-.team-list-message.error .team-list-message-copy {
- color: #8a4141;
-}
.btn-remove {
padding: 0;
background: none;
diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js
index f4a5c71510..950e2e0a54 100644
--- a/internal/cloudcp/portal/dist/portal_app.js
+++ b/internal/cloudcp/portal/dist/portal_app.js
@@ -138,19 +138,12 @@
actionButton.setAttribute("data-workspace-action", workspace.state === "active" ? "suspend" : "delete");
closeButton.disabled = entry.manageWorkspace.pending;
}
- function setContainerMessage(container, title, msg, isError) {
+ function setContainerMessage(container, msg, isError) {
container.textContent = "";
- var card = document.createElement("div");
- card.className = "team-list-message" + (isError ? " error" : "");
- var titleNode = document.createElement("strong");
- titleNode.className = "team-list-message-title";
- titleNode.textContent = title;
- card.appendChild(titleNode);
- var body = document.createElement("span");
- body.className = "team-list-message-copy";
- body.textContent = msg;
- card.appendChild(body);
- container.appendChild(card);
+ var message = document.createElement("div");
+ message.className = "team-list-message" + (isError ? " error" : "");
+ message.textContent = msg;
+ container.appendChild(message);
}
function countMembersByRole(members, role) {
var count = 0;
@@ -167,11 +160,11 @@
return;
}
if (entry.teamQuery.status === "loading") {
- stats.innerHTML = '
RosterLoading\u2026
InvitesReady
';
+ stats.innerHTML = 'RosterLoading\u2026
';
return;
}
if (entry.teamQuery.status === "error") {
- stats.innerHTML = 'RosterNeeds attention
FallbackInvite only
';
+ stats.innerHTML = 'RosterNeeds attention
';
return;
}
var members = entry.teamQuery.data;
@@ -272,15 +265,15 @@
return;
}
if (entry.teamQuery.status === "loading") {
- setContainerMessage(roster, "Loading roster", "Checking who currently has access to this account.", false);
+ setContainerMessage(roster, "Loading\u2026", false);
return;
}
if (entry.teamQuery.status === "error") {
- setContainerMessage(roster, "Roster needs attention", entry.teamQuery.error, true);
+ setContainerMessage(roster, entry.teamQuery.error, true);
return;
}
if (!entry.teamQuery.data.length) {
- setContainerMessage(roster, "No operators yet", "Invite someone new when this hosted account needs shared access.", false);
+ setContainerMessage(roster, "No team members.", false);
return;
}
roster.textContent = "";
@@ -1865,7 +1858,7 @@
}
function renderAccountTeamSection(account) {
var accessPolicy = 'Access model
Assign the smallest role that still lets someone do the work they own on this account.
OwnerBilling, team access, and full hosted control.
AdminHosted operations plus billing for the account.
TechWorkspace operations without billing ownership.
Read-onlyState review and verification without control-plane changes.
';
- return '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.
Invite someone new
Add another operator with the minimum role they need for this account.
' + accessPolicy + "
";
+ return '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.
Access desk
Invite and role policy
Keep the roster deliberate. Invite the smallest role first, then tighten access as responsibilities become clearer.
Invite someone new
Add another operator with the minimum role they need for this account.
' + accessPolicy + "
";
}
function renderSupportSection(context) {
return 'Support
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.
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.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts
index aa1878d546..b07a5fb8ed 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.test.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts
@@ -83,13 +83,12 @@ describe('account view', function() {
teamVisible: true,
teamQuery: { status: 'loading', error: '', data: [] },
}));
- expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading roster');
+ expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Loading');
renderTeamSection('acct_1', createEntry({
teamVisible: true,
teamQuery: { status: 'error', error: 'Failed to load team.', data: [] },
}));
- expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Roster needs attention');
expect(document.getElementById('team-list-acct_1')?.textContent).toContain('Failed to load team.');
renderTeamSection(
diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts
index 37eb84ab2a..2f5ae96dfa 100644
--- a/internal/cloudcp/portal/frontend/src/account_view.ts
+++ b/internal/cloudcp/portal/frontend/src/account_view.ts
@@ -163,22 +163,12 @@ export function renderWorkspaceManagement(account: PortalAccountSummary, entry:
closeButton.disabled = entry.manageWorkspace.pending;
}
-function setContainerMessage(container: HTMLElement, title: string, msg: string, isError: boolean): void {
+function setContainerMessage(container: HTMLElement, msg: string, isError: boolean): void {
container.textContent = '';
- var card = document.createElement('div');
- card.className = 'team-list-message' + (isError ? ' error' : '');
-
- var titleNode = document.createElement('strong');
- titleNode.className = 'team-list-message-title';
- titleNode.textContent = title;
- card.appendChild(titleNode);
-
- var body = document.createElement('span');
- body.className = 'team-list-message-copy';
- body.textContent = msg;
- card.appendChild(body);
-
- container.appendChild(card);
+ var message = document.createElement('div');
+ message.className = 'team-list-message' + (isError ? ' error' : '');
+ message.textContent = msg;
+ container.appendChild(message);
}
function countMembersByRole(members: PortalTeamMember[], role: string): number {
@@ -197,15 +187,11 @@ function renderTeamStats(accountID: string, entry: PortalAccountUIEntry): void {
return;
}
if (entry.teamQuery.status === 'loading') {
- stats.innerHTML =
- 'RosterLoading…
' +
- 'InvitesReady
';
+ stats.innerHTML = 'RosterLoading…
';
return;
}
if (entry.teamQuery.status === 'error') {
- stats.innerHTML =
- 'RosterNeeds attention
' +
- 'FallbackInvite only
';
+ stats.innerHTML = 'RosterNeeds attention
';
return;
}
@@ -331,15 +317,15 @@ export function renderTeamSection(accountID: string, entry: PortalAccountUIEntry
return;
}
if (entry.teamQuery.status === 'loading') {
- setContainerMessage(roster, 'Loading roster', 'Checking who currently has access to this account.', false);
+ setContainerMessage(roster, 'Loading…', false);
return;
}
if (entry.teamQuery.status === 'error') {
- setContainerMessage(roster, 'Roster needs attention', entry.teamQuery.error, true);
+ setContainerMessage(roster, entry.teamQuery.error, true);
return;
}
if (!entry.teamQuery.data.length) {
- setContainerMessage(roster, 'No operators yet', 'Invite someone new when this hosted account needs shared access.', false);
+ setContainerMessage(roster, 'No team members.', false);
return;
}
diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css
index 577b733efc..2f034ae4f4 100644
--- a/internal/cloudcp/portal/frontend/src/styles.css
+++ b/internal/cloudcp/portal/frontend/src/styles.css
@@ -1311,12 +1311,12 @@ header .logout-btn:hover,
.team-side-column {
display: flex;
flex-direction: column;
- gap: 14px;
align-self: start;
}
.team-roster,
-.team-invite-panel {
+.team-invite-panel,
+.team-operations-panel {
background: #fff;
border-color: var(--line);
}
@@ -1325,17 +1325,36 @@ header .logout-btn:hover,
align-self: start;
}
-.team-policy-panel {
+.team-operations-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
padding: 16px;
border: 1px solid var(--line);
border-radius: 12px;
- background: #fff;
+}
+
+.team-operations-grid {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+}
+
+.team-policy-panel {
+ border: 1px solid var(--line);
+ border-radius: 12px;
+ background: var(--panel-muted);
+ padding: 14px;
}
.team-panel-heading {
margin-bottom: 12px;
}
+.team-panel-heading-tight {
+ margin-bottom: 0;
+}
+
.team-panel-heading p {
max-width: 720px;
}
@@ -1446,44 +1465,20 @@ header .logout-btn:hover,
.team-list-message {
display: flex;
- flex-direction: column;
- align-items: flex-start;
+ align-items: center;
justify-content: center;
- gap: 8px;
- min-height: 144px;
- padding: 18px;
+ min-height: 120px;
+ padding: 16px;
border: 1px dashed var(--line-strong);
border-radius: 10px;
color: var(--ink-muted);
background: var(--panel-muted);
}
-.team-list-message-title {
- font-size: 15px;
- line-height: 1.15;
- letter-spacing: -0.02em;
- color: var(--ink);
-}
-
-.team-list-message-copy {
- font-size: 13px;
- line-height: 1.55;
- color: var(--ink-soft);
-}
-
.team-list-message.error {
- border-color: #efc1c1;
- background: #fff4f4;
-}
-
-.team-list-message.error .team-list-message-title {
color: var(--danger);
}
-.team-list-message.error .team-list-message-copy {
- color: #8a4141;
-}
-
.btn-remove {
padding: 0;
background: none;