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 '

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.

' + accessPolicy + "
"; + 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 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.

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.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;