Harden MSP onboarding proof gaps

This commit is contained in:
rcourtman
2026-06-02 10:15:35 +01:00
parent 2d1ad0db4c
commit 57486cee88
16 changed files with 517 additions and 110 deletions
@@ -195,6 +195,15 @@ source is available. The browser may present that state as a setup checklist
and deep-link into tenant install/reporting surfaces, but it must not infer
cross-client readiness from health alone or mint tenant-owned agent, alert, or
report configuration from Pulse Account.
MSP account UI may present those same workspace entries as clients, but that
language is portal presentation only. Bootstrap and dashboard payloads continue
to expose account `kind`, `workspaces`, tenant IDs, role IDs, setup facts, and
member roles with the stable workspace field names. Mixed-account views must
scope MSP client wording to MSP account surfaces and keep Cloud or self-hosted
workspace wording scoped to their own account surfaces. Provider setup
templates are collapsible guidance over the account payload; they must not
replace tenant-local setup facts, become a readiness source, or require a
payload shape change when the portal presents compact client rows.
1. `frontend-modern/src/api/agentProfiles.ts` shared with `agent-lifecycle`: the agent profiles frontend client is both an agent lifecycle control surface and a canonical API payload contract boundary.
2. `frontend-modern/src/api/ai.ts` shared with `ai-runtime`: the AI frontend client is both an AI runtime control surface and a canonical API payload contract boundary.
@@ -4875,7 +4884,7 @@ The PDF output owns three additional rendered sections gated on
`ReportData.Narrative`: an executive prose paragraph between the
deterministic health card and the deterministic Quick Stats table, a
`Period-over-period changes` section after Recommended Actions, and a
muted provenance footer (`Narrative generated by Pulse Assistant.
muted provenance footer (`Pulse Assistant narrative.
Verify against the data tables in this report.`) when
`Narrative.Source` is `ai`. Charts, stats tables, alert lists, storage
and disk sections must remain deterministic and rendered from the
@@ -345,6 +345,16 @@ or other self-hosted uncapped continuity plans.
check remains `Review` ahead of setup counts. Local MSP onboarding previews
should be scenario-backed portal bootstrap data, not static screenshots, so
they stay grounded in the real portal shape as the bundle changes.
MSP account surfaces may call account workspaces `clients`, but that is a
customer-facing portal vocabulary choice over the same tenant/workspace
lifecycle. Mixed-account Pulse Account sessions must label account surfaces
clearly enough that MSP client wording, Cloud workspace wording, access role
explanations, and lifecycle controls do not bleed across accounts. Read-only
account members must not see destructive client lifecycle controls. Repeated
customer hostnames are acceptable only because agent ingest and monitors stay
tenant-isolated; an MSP flow that lets two clients report `pve1` must keep
those reports in separate tenant workspaces rather than centralizing host
identity in the provider account.
Portal workspace payloads are live-workspace surfaces, not registry history
dumps: tenants in `deleting` or `deleted` state must stay hidden from the
browser bootstrap, `/api/portal/dashboard`, and workspace-detail API so
@@ -1050,7 +1060,7 @@ on the activation payload shape.
That same license-server transport boundary now treats Patrol quickstart
bootstrap as retired, not as a mixed-version runtime extension point.
`pulse-pro/license-server` must not register `/v1/quickstart/*` routes, parse
OpenAI/OpenRouter quickstart env, or create new quickstart ledger tables. The
model-provider quickstart env, or create new quickstart ledger tables. The
Pulse runtime must not call `POST /v1/quickstart/bootstrap`, must not persist
quickstart-backed AI config, and must not mint hosted-model tokens from hosted
or self-hosted billing state. Historical quickstart credit fields may remain
+101
View File
@@ -2,6 +2,7 @@ package api
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
@@ -98,6 +99,106 @@ func TestUnifiedAgentHandlers_HandleReport(t *testing.T) {
}
}
func TestUnifiedAgentHandlers_HandleReportIsolatesDuplicateHostnamesAcrossTenants(t *testing.T) {
baseDir := t.TempDir()
cfg := &config.Config{
DataPath: baseDir,
ConfigPath: baseDir,
}
persistence := config.NewMultiTenantPersistence(baseDir)
for _, orgID := range []string{"client-a", "client-b"} {
if _, err := persistence.GetPersistence(orgID); err != nil {
t.Fatalf("create tenant persistence %s: %v", orgID, err)
}
}
mtm := monitoring.NewMultiTenantMonitor(cfg, persistence, nil)
t.Cleanup(mtm.Stop)
handler := NewUnifiedAgentHandlers(mtm, nil, nil)
report := agentshost.Report{
Agent: agentshost.AgentInfo{
ID: "agent-pve1",
Version: "1.0.0",
},
Host: agentshost.HostInfo{
ID: "machine-pve1",
MachineID: "machine-pve1",
Hostname: "pve1",
Platform: "linux",
},
Timestamp: time.Now().UTC(),
}
postTenantReport := func(t *testing.T, orgID string, record config.APITokenRecord) string {
t.Helper()
body, err := json.Marshal(report)
if err != nil {
t.Fatalf("marshal report: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/agents/agent/report", bytes.NewReader(body))
req = req.WithContext(context.WithValue(req.Context(), OrgIDContextKey, orgID))
attachAPITokenRecord(req, &record)
rec := httptest.NewRecorder()
handler.HandleReport(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("tenant %s report status = %d, want 200: %s", orgID, rec.Code, rec.Body.String())
}
var resp struct {
AgentID string `json:"agentId"`
}
if err := json.NewDecoder(rec.Body).Decode(&resp); err != nil {
t.Fatalf("decode tenant %s report response: %v", orgID, err)
}
if resp.AgentID == "" {
t.Fatalf("tenant %s report response omitted agentId", orgID)
}
return resp.AgentID
}
tokenA := newTokenRecord(t, "tenant-a-agent-token-123.12345678", []string{config.ScopeAgentReport}, nil)
tokenA.OrgID = "client-a"
tokenB := newTokenRecord(t, "tenant-b-agent-token-123.12345678", []string{config.ScopeAgentReport}, nil)
tokenB.OrgID = "client-b"
agentA := postTenantReport(t, "client-a", tokenA)
agentB := postTenantReport(t, "client-b", tokenB)
if agentA != agentB {
t.Fatalf("expected duplicate tenants to keep the same local agent id, got %q and %q", agentA, agentB)
}
monitorA, ok := mtm.PeekMonitor("client-a")
if !ok {
t.Fatal("expected client-a monitor to be initialized")
}
monitorB, ok := mtm.PeekMonitor("client-b")
if !ok {
t.Fatal("expected client-b monitor to be initialized")
}
defaultMonitor, ok := mtm.PeekMonitor("default")
if !ok {
t.Fatal("expected default monitor fallback to be initialized")
}
for orgID, monitor := range map[string]*monitoring.Monitor{
"client-a": monitorA,
"client-b": monitorB,
} {
hosts := monitor.GetLiveHostsSnapshot()
if len(hosts) != 1 {
t.Fatalf("tenant %s hosts = %#v, want exactly one isolated host", orgID, hosts)
}
if hosts[0].ID != "machine-pve1" || hosts[0].Hostname != "pve1" {
t.Fatalf("tenant %s host = %#v, want duplicate pve1 identity isolated inside tenant", orgID, hosts[0])
}
}
if hosts := defaultMonitor.GetLiveHostsSnapshot(); len(hosts) != 0 {
t.Fatalf("default tenant should not receive client reports, got %#v", hosts)
}
}
func TestUnifiedAgentHandlers_HandleReport_AllowsNewHostsWithCapsRetired(t *testing.T) {
setMaxMonitoredSystemsLicenseForTests(t, 1)
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "52971d344a87d4b96d86db8184561dc150ee2cc408b1e3395c67912ad1f5e5bd",
"source_hash": "dc3aa37a68d30708d2f82b9da0da70470a2b2b3611223a477becaaf4aa2db381",
"build_inputs": [
"package.json",
"tsconfig.json",
+27 -23
View File
@@ -318,6 +318,9 @@ header .logout-btn:hover,
padding-top: 20px;
border-top: 1px solid var(--border);
}
.account-surface-header .portal-section-copy {
margin: 4px 0 0;
}
.account-content-panel {
display: block;
}
@@ -378,7 +381,7 @@ header .logout-btn:hover,
.workspace-row-primary {
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
min-width: 0;
}
.workspace-row-heading {
@@ -389,12 +392,18 @@ header .logout-btn:hover,
}
.workspace-name {
margin: 0;
min-width: 0;
max-width: 100%;
font-size: 14px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.workspace-meta-item {
@@ -406,11 +415,6 @@ header .logout-btn:hover,
margin-right: 6px;
color: var(--border);
}
.workspace-row-note {
margin: 0;
font-size: 12px;
color: var(--ink-secondary);
}
.workspace-row-status-cell {
display: flex;
align-items: center;
@@ -1687,26 +1691,29 @@ header .logout-btn:hover,
border-radius: var(--radius);
background: var(--bg);
}
.workspace-template-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workspace-template-heading h3 {
margin: 0;
.workspace-template-summary {
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.workspace-template-heading p {
margin: 3px 0 0;
.workspace-template-summary::marker,
.workspace-template-summary::-webkit-details-marker {
color: var(--ink-muted);
}
.workspace-template-summary-meta {
margin-left: 8px;
color: var(--ink-secondary);
font-size: 12px;
font-weight: 400;
}
.workspace-template-intro {
margin: 10px 0 0;
color: var(--ink-secondary);
font-size: 12px;
}
.workspace-template-heading > span {
flex: 0 0 auto;
color: var(--ink-secondary);
font-size: 12px;
.workspace-template-details[open] .workspace-template-grid {
margin-top: 10px;
}
.workspace-template-grid {
display: grid;
@@ -1953,9 +1960,6 @@ header .logout-btn:hover,
.workspace-setup-queue-actions {
justify-content: flex-start;
}
.workspace-template-heading {
flex-direction: column;
}
.workspace-template-grid,
.workspace-setup-guide {
grid-template-columns: 1fr;
+34 -19
View File
@@ -20,16 +20,16 @@
return role || "Member";
}
}
function portalRoleCapabilityCopy(role) {
function portalRoleCapabilityCopy(role, clientLanguage = false) {
switch (normalizePortalRole(role)) {
case "owner":
return "Full account control, including billing, access control, and workspace control.";
return clientLanguage ? "Full account control, including billing, access control, and client control." : "Full account control, including billing, access control, and workspace control.";
case "admin":
return "Can manage workspaces and billing for this account.";
return clientLanguage ? "Can manage clients and billing for this account." : "Can manage workspaces and billing for this account.";
case "tech":
return "Can manage workspaces without billing ownership.";
return clientLanguage ? "Can manage clients without billing ownership." : "Can manage workspaces without billing ownership.";
case "read_only":
return "Can review workspace status without making control-plane changes.";
return clientLanguage ? "Can review client status without making control-plane changes." : "Can review workspace status without making control-plane changes.";
case "member":
return "Has access through the account roster.";
default:
@@ -629,7 +629,7 @@
group.appendChild(btn);
return group;
}
function renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob) {
function renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage) {
var showActionColumn = canManage && activeJob === "remove";
var row = document.createElement("div");
row.className = "access-member-row" + (showActionColumn ? "" : " access-member-row-readonly");
@@ -654,7 +654,7 @@
identity.appendChild(topline);
var caption = document.createElement("div");
caption.className = "access-member-caption";
caption.textContent = (member.state || "active") === "pending" ? "Invitation pending acceptance." : portalRoleCapabilityCopy(member.role);
caption.textContent = (member.state || "active") === "pending" ? "Invitation pending acceptance." : portalRoleCapabilityCopy(member.role, clientLanguage);
identity.appendChild(caption);
row.appendChild(identity);
row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage, activeJob));
@@ -703,6 +703,7 @@
var actorRole = section.getAttribute("data-actor-role") || "";
var isOwner = actorRole === "owner";
var canManage = section.getAttribute("data-can-manage") === "true";
var clientLanguage = section.getAttribute("data-client-language") === "true";
var activeJob = canManage ? entry.activeAccessJob : "";
section.classList.toggle("visible", entry.accessVisible);
renderAccessStats(accountID, entry, canManage);
@@ -749,7 +750,7 @@
renderAccessRosterHead(roster, activeJob, canManage);
for (var i = 0; i < entry.accessQuery.data.length; i += 1) {
var member = entry.accessQuery.data[i];
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob));
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage));
}
}
function renderAccountUI(accountState, accounts, accountAPIBasePath = "") {
@@ -2792,9 +2793,6 @@
if (createdLabel) {
metaParts.push('<span class="workspace-meta-item">Created ' + escapeHTML(createdLabel) + "</span>");
}
if (workspace.last_health_check && status === "healthy") {
metaParts.push('<span class="workspace-meta-item">Checked recently</span>');
}
var openAction = "";
if (state === "active") {
openAction = '<form method="POST" action="' + escapeAttr(workspaceHandoffActionPath2(accountAPIBasePath, account.id, workspace.id)) + '"><button type="submit" class="btn-primary">' + escapeHTML(clientLanguage ? "Open client" : "Open workspace") + "</button></form>";
@@ -2807,7 +2805,7 @@
if (account.can_manage && (state === "active" || state === "suspended" || state === "failed")) {
manageAction = '<button type="button" class="btn-secondary btn-workspace-manage" data-action="select-workspace" data-account-id="' + escapeAttr(account.id) + '" data-workspace-id="' + escapeAttr(workspace.id) + '">' + escapeHTML(clientLanguage ? "Client onboarding" : "Setup checklist") + "</button>";
}
return '<article class="workspace-row workspace-row-health-' + escapeAttr(status) + " workspace-row-state-" + escapeAttr(state || "unknown") + '" id="' + escapeAttr(workspaceRowAnchorID(account.id, workspace.id)) + '" data-workspace-row="' + escapeAttr(workspace.id) + '"><div class="workspace-row-primary"><div class="workspace-row-heading"><h4 class="workspace-name">' + escapeHTML(workspace.display_name) + '</h4><div class="workspace-meta">' + metaParts.join("") + '</div></div><div class="workspace-row-note">' + escapeHTML(workspaceRowNote(workspace)) + '</div></div><div class="workspace-row-status-cell workspace-row-status-cell-badge">' + setupBadgeHTML(workspace) + '</div><div class="workspace-row-status-cell workspace-row-status-cell-badge">' + healthBadgeHTML(workspace) + '</div><div class="workspace-actions">' + openAction + installAction + manageAction + "</div></article>";
return '<article class="workspace-row workspace-row-health-' + escapeAttr(status) + " workspace-row-state-" + escapeAttr(state || "unknown") + '" id="' + escapeAttr(workspaceRowAnchorID(account.id, workspace.id)) + '" data-workspace-row="' + escapeAttr(workspace.id) + '"><div class="workspace-row-primary"><div class="workspace-row-heading"><h4 class="workspace-name">' + escapeHTML(workspace.display_name) + "</h4></div>" + (metaParts.length ? '<div class="workspace-meta">' + metaParts.join("") + "</div>" : "") + '</div><div class="workspace-row-status-cell workspace-row-status-cell-badge">' + setupBadgeHTML(workspace) + '</div><div class="workspace-row-status-cell workspace-row-status-cell-badge">' + healthBadgeHTML(workspace) + '</div><div class="workspace-actions">' + openAction + installAction + manageAction + "</div></article>";
}
function renderWorkspaceHandoffForm(accountID, workspaceID, accountAPIBasePath, label, buttonClassName = "btn-secondary btn-compact") {
if (!accountAPIBasePath) {
@@ -2882,6 +2880,13 @@
var guide = workspaceSetupGuide(workspace);
return guide.diagnostics.length ? guide.diagnostics[0] : workspaceSetupNextStep(workspace);
}
function workspaceSummaryStatusCopy(workspace, clientLanguage) {
if (!clientLanguage) return workspaceStatusCopy(workspace);
var state = String(workspace.state || "");
if (state === "suspended") return "This client is suspended.";
if (state === "failed") return "This client is in a failed state.";
return workspaceStatusCopy(workspace);
}
function workspaceSummaryContext(entry, includeAccountName, note) {
if (!includeAccountName) return note;
return entry.account.name + " \xB7 " + note;
@@ -2910,7 +2915,7 @@
if (attention.length) {
var attentionEntry = attention[0];
title = "Review " + attentionEntry.workspace.display_name;
description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceStatusCopy(attentionEntry.workspace));
description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage));
primaryAction = renderWorkspaceAnchorAction(
workspaceRowAnchorID(attentionEntry.account.id, attentionEntry.workspace.id),
clientLanguage ? "Review client" : "Review workspace",
@@ -2920,7 +2925,7 @@
} else if (suspended.length) {
var suspendedEntry = suspended[0];
title = "Review " + suspendedEntry.workspace.display_name;
description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceStatusCopy(suspendedEntry.workspace));
description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceSummaryStatusCopy(suspendedEntry.workspace, clientLanguage));
primaryAction = renderWorkspaceAnchorAction(
workspaceRowAnchorID(suspendedEntry.account.id, suspendedEntry.workspace.id),
clientLanguage ? "Review client" : "Review workspace",
@@ -3039,7 +3044,7 @@
});
if (!templateAccount || !templateAccount.setup_templates || !templateAccount.setup_templates.length) return "";
var template = templateAccount.setup_templates[0];
return '<section class="workspace-template-panel" aria-label="Provider setup template"><div class="workspace-template-heading"><div><h3>' + escapeHTML(template.title || "Provider setup template") + "</h3><p>Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.</p></div><span>" + escapeHTML(templateAccount.name) + '</span></div><div class="workspace-template-grid"><div><strong>Agent naming</strong><span>' + escapeHTML(template.agent_naming) + "</span></div><div><strong>Alert routing</strong><span>" + escapeHTML(template.alert_routing) + "</span></div><div><strong>Reports</strong><span>" + escapeHTML(template.reporting) + "</span></div><div><strong>Access</strong><span>" + escapeHTML(template.access) + "</span></div></div></section>";
return '<section class="workspace-template-panel" aria-label="Provider setup template"><details class="workspace-template-details"><summary class="workspace-template-summary"><span class="workspace-template-summary-title">' + escapeHTML(template.title || "Provider setup template") + '</span><span class="workspace-template-summary-meta">' + escapeHTML(templateAccount.name) + '</span></summary><p class="workspace-template-intro">Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.</p><div class="workspace-template-grid"><div><strong>Agent naming</strong><span>' + escapeHTML(template.agent_naming) + "</span></div><div><strong>Alert routing</strong><span>" + escapeHTML(template.alert_routing) + "</span></div><div><strong>Reports</strong><span>" + escapeHTML(template.reporting) + "</span></div><div><strong>Access</strong><span>" + escapeHTML(template.access) + "</span></div></div></details></section>";
}
function renderWorkspaceSetupQueue(entries, accountAPIBasePath, clientLanguage = false) {
var setupNeeded = setupNeededWorkspaceEntries(entries);
@@ -3072,6 +3077,10 @@
var clientLanguage = accountsUseClientLanguage(accounts);
return '<section class="workspace-summary-shell"><div class="portal-page-header"><h2>' + escapeHTML(clientLanguage ? "Clients" : "Workspaces") + "</h2><p>" + escapeHTML(workspaceSectionHeaderCopy(accounts, entries)) + "</p></div>" + renderFactLine("workspace-summary-facts", renderWorkspaceSummaryFacts(accounts, entries)) + renderWorkspaceSummaryInline(accounts, entries, context.accountAPIBasePath, showSelfHostedCommercial) + renderProviderSetupTemplates(accounts) + renderWorkspaceSetupQueue(entries, context.accountAPIBasePath, clientLanguage) + "</section>";
}
function renderAccountSurfaceHeader(account, showHeader) {
if (!showHeader) return "";
return '<div class="portal-section-header account-surface-header"><div><h3>' + escapeHTML(account.name) + '</h3><p class="portal-section-copy">' + escapeHTML(accountKindLabel(account) + " \xB7 " + portalRoleLabel(account.role)) + "</p></div></div>";
}
function renderNoHostedWorkspacesSection() {
return '<section class="account-content-panel account-content-panel-workspaces"><div class="empty-state"><p>No hosted workspaces are attached to this account. Use Billing for self-hosted subscriptions and licenses.</p></div></section>';
}
@@ -3108,12 +3117,18 @@
return '<section class="account-content-panel account-content-panel-workspaces"><div class="workspace-operations-shell workspace-operations-shell-idle" id="workspace-operations-shell-' + escapeAttr(account.id) + '"><div class="workspace-operations-detail" id="workspace-operations-detail-' + escapeAttr(account.id) + '" hidden>' + workspaceManagement + '</div><div class="workspace-operations-main">' + workspaceHTML + "</div></div></section>";
}
function renderAccountAccessSection(account) {
var clientLanguage = accountUsesClientLanguage(account);
var accessRoleCopy = {
admin: clientLanguage ? "Client control, billing, and roster management." : "Workspace control, billing, and roster management.",
tech: clientLanguage ? "Client control without billing or roster ownership." : "Workspace control without billing or roster ownership.",
readOnly: clientLanguage ? "Review client status without control-plane changes." : "Review access without control-plane changes."
};
var accessTaskStrip = account.can_manage ? '<div class="access-task-strip"><button type="button" class="access-task-button" id="access-task-invite-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="invite">Invite people</button><button type="button" class="access-task-button" id="access-task-change_role-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="change_role">Change roles</button><button type="button" class="access-task-button" id="access-task-remove-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="remove">Remove access</button></div>' : renderSectionContextChips(["View roster", "Owner or admin required"]);
var accessRoleGuide = '<div class="access-policy-panel"><div class="access-panel-heading"><h4>' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "</h4><p>" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '</p></div><div class="access-policy-list"><div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div><div class="access-policy-row"><strong>Admin</strong><span>Workspace control, billing, and roster management.</span></div><div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing or roster ownership.</span></div><div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div></div></div>';
var accessRoleGuide = '<div class="access-policy-panel"><div class="access-panel-heading"><h4>' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "</h4><p>" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '</p></div><div class="access-policy-list"><div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div><div class="access-policy-row"><strong>Admin</strong><span>' + escapeHTML(accessRoleCopy.admin) + '</span></div><div class="access-policy-row"><strong>Tech</strong><span>' + escapeHTML(accessRoleCopy.tech) + '</span></div><div class="access-policy-row"><strong>Read-only</strong><span>' + escapeHTML(accessRoleCopy.readOnly) + "</span></div></div></div>";
var accessInvitePanel = account.can_manage ? '<div class="access-invite-panel"><div class="access-panel-heading"><h4>Invite people</h4><p>Add one person with the minimum role they need on this account.</p></div><div class="access-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>' : "";
var accessChangeRolePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Change roles on the roster</h4><p>Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.</p></div></div>' + accessRoleGuide;
var accessRemovePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Remove stale access</h4><p>Use removal only when this person should no longer be on this hosted account. Owners may still be protected when they are the last owner.</p></div><div class="access-remove-points"><div class="access-remove-point"><strong>Pick the exact person</strong><span>Use the roster to remove one account member at a time.</span></div><div class="access-remove-point"><strong>Keep current owners safe</strong><span>The last owner cannot be removed until another owner exists.</span></div></div></div>';
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '">' + (!account.can_manage ? '<p class="portal-section-copy">' + escapeHTML("Review who has access. An owner or admin must make changes.") + "</p>" : "") + '<div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '">' + (account.can_manage ? '<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden><div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden><div class="access-task-header"><div><h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4><p id="access-task-copy-' + escapeAttr(account.id) + '"></p></div><button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button></div><div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' + accessInvitePanel + accessRoleGuide + '</div><div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' + accessChangeRolePanel + '</div><div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' + accessRemovePanel + "</div></div></div>" : "") + '<div class="access-shell-main"><div class="access-roster-column"><div class="access-roster">' + (account.can_manage ? '<div class="access-roster-toolbar">' + accessTaskStrip + "</div>" : "") + '<div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div></div></div></section></section>';
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '" data-client-language="' + escapeAttr(clientLanguage ? "true" : "false") + '">' + (!account.can_manage ? '<p class="portal-section-copy">' + escapeHTML("Review who has access. An owner or admin must make changes.") + "</p>" : "") + '<div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '">' + (account.can_manage ? '<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden><div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden><div class="access-task-header"><div><h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4><p id="access-task-copy-' + escapeAttr(account.id) + '"></p></div><button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button></div><div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' + accessInvitePanel + accessRoleGuide + '</div><div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' + accessChangeRolePanel + '</div><div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' + accessRemovePanel + "</div></div></div>" : "") + '<div class="access-shell-main"><div class="access-roster-column"><div class="access-roster">' + (account.can_manage ? '<div class="access-roster-toolbar">' + accessTaskStrip + "</div>" : "") + '<div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div></div></div></section></section>';
}
function renderHostedBillingCards(accounts, showSelfHostedCommercial) {
var hostedBillingAccounts = accounts.filter(function(account) {
@@ -3175,11 +3190,11 @@
var billingNote = hosted ? showSelfHostedCommercial ? "Hosted billing by account. Self-hosted purchases stay separate below." : "Hosted billing by account." : "Self-hosted subscriptions, licenses, refunds, and privacy requests.";
var selfHostedBillingEscalationCopy = hosted ? "Escalate with the same hosted billing action or self-hosted path and the exact failed step." : "Escalate with the same self-hosted billing path and the exact failed step.";
var workspacesContent = accounts.length ? accounts.map(function(account) {
return '<section class="account-surface">' + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + "</section>";
return '<section class="account-surface">' + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountWorkspaceSection(account, context.accountAPIBasePath) + "</section>";
}).join("") : renderNoHostedWorkspacesSection();
var workspaceSummaryContent = hosted ? renderWorkspaceSummarySection(context) : "";
var accessContent = accounts.length ? accounts.map(function(account) {
return '<section class="account-surface">' + renderAccountAccessSection(account) + "</section>";
return '<section class="account-surface">' + renderAccountSurfaceHeader(account, accounts.length > 1) + renderAccountAccessSection(account) + "</section>";
}).join("") : renderNoHostedAccessSection();
var selfHostedBillingLeadCopy = showSelfHostedCommercial ? "Use self-hosted billing only for self-hosted purchases." : "Pulse Account owns the commercial handoff for self-hosted upgrades from the app.";
var selfHostedBillingActionsHTML = renderSelfHostedUpgradeActionRow(context);
+76 -4
View File
@@ -9,7 +9,7 @@ import { createPortalBuildOptions, frontendRoot } from './build_config.mjs';
const scenarioCookieName = 'pulse_portal_preview_scenario';
const previewHost = process.env.PULSE_PORTAL_PREVIEW_HOST || '127.0.0.1';
const previewPort = Number(process.env.PULSE_PORTAL_PREVIEW_PORT || '8765');
const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty', 'onboarding'];
const previewScenarios = ['managed', 'readonly', 'selfhosted', 'empty', 'onboarding', 'mixed'];
const previewFaviconSVG = fs.readFileSync(path.join(frontendRoot, '..', '..', 'favicon.svg'), 'utf8');
const previewFaviconHref = '/favicon.svg?v=' + createHash('sha256').update(previewFaviconSVG).digest('hex').slice(0, 16);
@@ -204,6 +204,77 @@ function buildScenarioTemplate(name) {
};
}
if (name === 'mixed') {
return {
...base,
accounts: [
{
id: 'acct_mixed_msp',
name: 'Provider Account',
kind: 'msp',
kind_label: 'MSP',
role: 'owner',
can_manage: true,
has_billing: true,
setup_templates: standardSetupTemplates(),
workspaces: [
{
id: 'ws_mixed_client',
display_name: 'Acme Dental',
state: 'active',
healthy: true,
health_status: 'healthy',
setup_status: 'install_agents',
agent_count: 0,
agent_token_count: 1,
unused_agent_token_count: 1,
alert_route_count: 0,
disabled_alert_route_count: 0,
report_schedule_count: 0,
disabled_report_schedule_count: 0,
created_at: iso('2026-05-28T10:00:00Z'),
},
],
members: [
{ email: 'owner@example.com', role: 'owner', user_id: 'u_owner' },
{ email: 'helpdesk@example.com', role: 'tech', user_id: 'u_helpdesk' },
],
},
{
id: 'acct_mixed_cloud',
name: 'Hosted Ops',
kind: 'cloud',
kind_label: 'Cloud',
role: 'admin',
can_manage: true,
has_billing: true,
workspaces: [
{
id: 'ws_mixed_cloud',
display_name: 'Operations Workspace',
state: 'active',
healthy: true,
health_status: 'healthy',
setup_status: 'ready',
agent_count: 2,
agent_token_count: 2,
unused_agent_token_count: 0,
alert_route_count: 1,
disabled_alert_route_count: 0,
report_schedule_count: 1,
disabled_report_schedule_count: 0,
created_at: iso('2026-05-30T10:00:00Z'),
},
],
members: [
{ email: 'admin@example.com', role: 'admin', user_id: 'u_admin' },
{ email: 'viewer@example.com', role: 'read_only', user_id: 'u_viewer' },
],
},
],
};
}
return {
...base,
accounts: [{
@@ -800,11 +871,11 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) {
return item.id === resourceID;
});
if (!workspace) {
sendJSON(response, 404, { error: 'Workspace not found.' });
sendJSON(response, 404, { error: 'Client workspace not found.' });
return;
}
if (workspace.state !== 'active') {
sendJSON(response, 409, { error: 'Workspace is not active.' });
sendJSON(response, 409, { error: 'Client workspace is not active.' });
return;
}
response.writeHead(303, {
@@ -855,7 +926,7 @@ function routeAccountAPI(request, response, url, bootstrap, scenario) {
return item.id === resourceID;
});
if (!workspace) {
sendJSON(response, 404, { error: 'Workspace not found.' });
sendJSON(response, 404, { error: 'Client workspace not found.' });
return;
}
if (String(body.state || '').trim() === 'suspended') {
@@ -1212,6 +1283,7 @@ server.listen(previewPort, previewHost, function() {
console.log('[portal-preview] readonly -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=readonly');
console.log('[portal-preview] selfhosted -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=selfhosted');
console.log('[portal-preview] empty -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=empty');
console.log('[portal-preview] mixed -> http://' + previewHost + ':' + String(previewPort) + '/?scenario=mixed');
});
async function shutdown(signal) {
@@ -12,5 +12,7 @@ describe('account roles', function() {
it('returns product copy for read-only operators', function() {
expect(portalRoleCapabilityCopy('read_only')).toContain('review workspace status');
expect(portalRoleCapabilityCopy('read_only', true)).toContain('review client status');
expect(portalRoleCapabilityCopy('tech', true)).toContain('manage clients');
});
});
@@ -20,16 +20,24 @@ export function portalRoleLabel(role: string): string {
}
}
export function portalRoleCapabilityCopy(role: string): string {
export function portalRoleCapabilityCopy(role: string, clientLanguage = false): string {
switch (normalizePortalRole(role)) {
case 'owner':
return 'Full account control, including billing, access control, and workspace control.';
return clientLanguage
? 'Full account control, including billing, access control, and client control.'
: 'Full account control, including billing, access control, and workspace control.';
case 'admin':
return 'Can manage workspaces and billing for this account.';
return clientLanguage
? 'Can manage clients and billing for this account.'
: 'Can manage workspaces and billing for this account.';
case 'tech':
return 'Can manage workspaces without billing ownership.';
return clientLanguage
? 'Can manage clients without billing ownership.'
: 'Can manage workspaces without billing ownership.';
case 'read_only':
return 'Can review workspace status without making control-plane changes.';
return clientLanguage
? 'Can review client status without making control-plane changes.'
: 'Can review workspace status without making control-plane changes.';
case 'member':
return 'Has access through the account roster.';
default:
@@ -209,6 +209,66 @@ describe('account runtime', function() {
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('');
});
it('routes suspended MSP client deletion through the management panel', async function() {
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(jsonResponse({})));
var confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(true);
deps.store.setBootstrap({
authenticated: true,
email: 'owner@example.com',
accounts: [{
id: 'acct_1',
name: 'Acme MSP',
kind: 'msp',
kind_label: 'MSP',
role: 'owner',
can_manage: true,
has_billing: true,
members: [],
workspaces: [{
id: 'ws_3',
display_name: 'Paused Client',
state: 'suspended',
healthy: false,
health_status: 'unhealthy',
}],
}],
});
document.body.innerHTML =
'<div id="workspace-operations-shell-acct_1" class="workspace-operations-shell workspace-operations-shell-idle">' +
'<div id="workspace-operations-detail-acct_1" class="workspace-operations-detail workspace-operations-detail-idle">' +
'<div id="workspace-management-acct_1" class="workspace-management-panel">' +
'<button id="workspace-management-close-acct_1"></button>' +
'<div id="workspace-management-empty-acct_1"></div>' +
'<div id="workspace-management-content-acct_1" hidden>' +
'<div id="workspace-management-meta-acct_1"></div>' +
'<h4 id="workspace-management-title-acct_1"></h4>' +
'<p id="workspace-management-summary-acct_1"></p>' +
'<div id="workspace-management-health-acct_1"></div>' +
'<div id="workspace-management-setup-acct_1"></div>' +
'<div id="workspace-management-created-acct_1"></div>' +
'<div id="workspace-management-guidance-acct_1"></div>' +
'<button id="workspace-management-action-acct_1"></button>' +
'</div>' +
'</div>' +
'</div>' +
'</div>';
runtime.selectWorkspace('acct_1', 'ws_3');
expect(document.getElementById('workspace-management-action-acct_1')?.textContent).toContain('Delete client');
await runtime.manageWorkspaceAction('acct_1', 'ws_3', 'delete', 'Paused Client');
await flushAsync();
expect(confirmSpy).toHaveBeenCalledWith('Delete client "Paused Client"?');
expect(fetch).toHaveBeenCalledWith(
'/api/accounts/acct_1/tenants/ws_3',
expect.objectContaining({ method: 'DELETE' })
);
expect(deps.showToast).toHaveBeenCalledWith('Deleted client.');
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('');
});
it('reveals the setup panel when a workspace job opens below the viewport', function() {
deps.store.setBootstrap({
authenticated: true,
@@ -255,6 +255,33 @@ describe('account view', function() {
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('1');
});
it('uses client language for MSP access role capability copy', function() {
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true" data-client-language="true">' +
'<div id="access-stats-acct_1"></div>' +
'<div id="access-list-acct_1"></div>' +
'</div>';
renderAccessSection(
'acct_1',
createEntry({
accessVisible: true,
accessQuery: {
status: 'ready',
error: '',
data: [
{ email: 'tech@example.com', role: 'tech', user_id: 'u2' },
{ email: 'viewer@example.com', role: 'read_only', user_id: 'u3' },
],
},
})
);
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Can manage clients without billing ownership.');
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Can review client status without making control-plane changes.');
expect(document.getElementById('access-list-acct_1')?.textContent).not.toContain('Can manage workspaces');
});
it('renders access roster as view-only when the account is not manageable', function() {
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="tech" data-can-manage="false">' +
@@ -397,7 +397,7 @@ function renderAccessMemberAction(accountID: string, member: PortalAccessMember,
return group;
}
function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob): HTMLElement {
function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob, clientLanguage: boolean): HTMLElement {
var showActionColumn = canManage && activeJob === 'remove';
var row = document.createElement('div');
row.className = 'access-member-row' + (showActionColumn ? '' : ' access-member-row-readonly');
@@ -431,7 +431,7 @@ function renderAccessMemberRow(accountID: string, member: PortalAccessMember, is
caption.className = 'access-member-caption';
caption.textContent = (member.state || 'active') === 'pending'
? 'Invitation pending acceptance.'
: portalRoleCapabilityCopy(member.role);
: portalRoleCapabilityCopy(member.role, clientLanguage);
identity.appendChild(caption);
row.appendChild(identity);
@@ -494,6 +494,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
var actorRole = section.getAttribute('data-actor-role') || '';
var isOwner = actorRole === 'owner';
var canManage = section.getAttribute('data-can-manage') === 'true';
var clientLanguage = section.getAttribute('data-client-language') === 'true';
var activeJob = canManage ? entry.activeAccessJob : '';
section.classList.toggle('visible', entry.accessVisible);
renderAccessStats(accountID, entry, canManage);
@@ -544,7 +545,7 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
renderAccessRosterHead(roster, activeJob, canManage);
for (var i = 0; i < entry.accessQuery.data.length; i += 1) {
var member = entry.accessQuery.data[i];
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob));
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob, clientLanguage));
}
}
@@ -224,8 +224,7 @@ describe('shell view', function() {
expect(html).toContain('Gamma Workspace');
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Checking</span>');
expect(html).toContain('This workspace is in a failed state.');
expect(html).toContain('Health check pending');
expect(html).toContain('This client is in a failed state.');
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff');
expect(html).toContain('/api/accounts/acct_1/tenants/ws_active/handoff?target_path=%2Fsettings%2Finfrastructure%3Fadd%3Dlinux-host');
expect(html).toContain('Open client');
@@ -248,9 +247,10 @@ describe('shell view', function() {
expect(html).toContain('id="access-detail-acct_1" hidden');
expect(html).toContain('Choose the smallest role');
expect(html).toContain('Full account, billing, and access control.');
expect(html).toContain('Workspace control, billing, and roster management.');
expect(html).toContain('Workspace control without billing or roster ownership.');
expect(html).toContain('Review access without control-plane changes.');
expect(html).toContain('Client control, billing, and roster management.');
expect(html).toContain('Client control without billing or roster ownership.');
expect(html).toContain('Review client status without control-plane changes.');
expect(html).not.toContain('Workspace control, billing, and roster management.');
expect(html).toContain('data-can-manage="true"');
expect(html).toContain('Remove stale access');
expect(html).toContain('data-action="workspace-action"');
@@ -396,6 +396,71 @@ describe('shell view', function() {
expect(html).not.toContain('Services');
});
it('labels mixed account surfaces and keeps MSP client copy scoped', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
bootstrap: createBootstrap({
accounts: [
{
id: 'acct_msp_mixed',
name: 'Provider Account',
kind: 'msp',
kind_label: 'MSP',
role: 'owner',
can_manage: true,
has_billing: true,
members: [],
workspaces: [
{
id: 'ws_client',
display_name: 'Acme Dental',
state: 'active',
healthy: true,
health_status: 'healthy',
},
],
},
{
id: 'acct_cloud_mixed',
name: 'Hosted Ops',
kind: 'cloud',
kind_label: 'Cloud',
role: 'admin',
can_manage: true,
has_billing: true,
members: [],
workspaces: [
{
id: 'ws_ops',
display_name: 'Operations Workspace',
state: 'active',
healthy: true,
health_status: 'healthy',
agent_count: 1,
alert_route_count: 1,
report_schedule_count: 1,
},
],
},
],
}),
})
);
expect(html).toContain('data-shell-section="workspaces">Workspaces</button>');
expect(html).toContain('<h3>Provider Account</h3>');
expect(html).toContain('MSP account · Owner');
expect(html).toContain('<h3>Hosted Ops</h3>');
expect(html).toContain('Cloud account · Admin');
expect(html).toContain('data-client-language="true"');
expect(html).toContain('data-client-language="false"');
expect(html).toContain('Add client');
expect(html).toContain('Open client');
expect(html).toContain('Open workspace');
expect(html).toContain('Client control, billing, and roster management.');
expect(html).toContain('Workspace control, billing, and roster management.');
});
it('renders one simple account context strip without summary facts', function() {
var html = renderAuthenticatedPortalHTML(
createContext({
@@ -795,6 +860,8 @@ describe('shell view', function() {
);
expect(html).toContain('Suspended');
expect(html).toContain('This client is suspended.');
expect(html).not.toContain('This workspace is suspended.');
expect(html).toContain('Add client');
expect(html).toContain('Paused Workspace');
});
@@ -472,9 +472,6 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
if (createdLabel) {
metaParts.push('<span class="workspace-meta-item">Created ' + escapeHTML(createdLabel) + '</span>');
}
if (workspace.last_health_check && status === 'healthy') {
metaParts.push('<span class="workspace-meta-item">Checked recently</span>');
}
var openAction = '';
if (state === 'active') {
@@ -511,9 +508,8 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
'<div class="workspace-row-primary">' +
'<div class="workspace-row-heading">' +
'<h4 class="workspace-name">' + escapeHTML(workspace.display_name) + '</h4>' +
'<div class="workspace-meta">' + metaParts.join('') + '</div>' +
'</div>' +
'<div class="workspace-row-note">' + escapeHTML(workspaceRowNote(workspace)) + '</div>' +
(metaParts.length ? '<div class="workspace-meta">' + metaParts.join('') + '</div>' : '') +
'</div>' +
'<div class="workspace-row-status-cell workspace-row-status-cell-badge">' +
setupBadgeHTML(workspace) +
@@ -641,6 +637,14 @@ function workspaceSetupDiagnosticsLine(workspace: PortalWorkspaceSummary): strin
return guide.diagnostics.length ? guide.diagnostics[0] : workspaceSetupNextStep(workspace);
}
function workspaceSummaryStatusCopy(workspace: PortalWorkspaceSummary, clientLanguage: boolean): string {
if (!clientLanguage) return workspaceStatusCopy(workspace);
var state = String(workspace.state || '');
if (state === 'suspended') return 'This client is suspended.';
if (state === 'failed') return 'This client is in a failed state.';
return workspaceStatusCopy(workspace);
}
function workspaceSummaryContext(entry: WorkspaceSummaryEntry, includeAccountName: boolean, note: string): string {
if (!includeAccountName) return note;
return entry.account.name + ' · ' + note;
@@ -684,7 +688,7 @@ function renderWorkspaceSummaryDecision(
if (attention.length) {
var attentionEntry = attention[0];
title = 'Review ' + attentionEntry.workspace.display_name;
description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceStatusCopy(attentionEntry.workspace));
description = workspaceSummaryContext(attentionEntry, accounts.length > 1, workspaceSummaryStatusCopy(attentionEntry.workspace, clientLanguage));
primaryAction = renderWorkspaceAnchorAction(
workspaceRowAnchorID(attentionEntry.account.id, attentionEntry.workspace.id),
clientLanguage ? 'Review client' : 'Review workspace',
@@ -704,7 +708,7 @@ function renderWorkspaceSummaryDecision(
} else if (suspended.length) {
var suspendedEntry = suspended[0];
title = 'Review ' + suspendedEntry.workspace.display_name;
description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceStatusCopy(suspendedEntry.workspace));
description = workspaceSummaryContext(suspendedEntry, accounts.length > 1, workspaceSummaryStatusCopy(suspendedEntry.workspace, clientLanguage));
primaryAction = renderWorkspaceAnchorAction(
workspaceRowAnchorID(suspendedEntry.account.id, suspendedEntry.workspace.id),
clientLanguage ? 'Review client' : 'Review workspace',
@@ -880,19 +884,19 @@ function renderProviderSetupTemplates(accounts: PortalAccountSummary[]): string
var template = templateAccount.setup_templates[0];
return (
'<section class="workspace-template-panel" aria-label="Provider setup template">' +
'<div class="workspace-template-heading">' +
'<div>' +
'<h3>' + escapeHTML(template.title || 'Provider setup template') + '</h3>' +
'<p>Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.</p>' +
'<details class="workspace-template-details">' +
'<summary class="workspace-template-summary">' +
'<span class="workspace-template-summary-title">' + escapeHTML(template.title || 'Provider setup template') + '</span>' +
'<span class="workspace-template-summary-meta">' + escapeHTML(templateAccount.name) + '</span>' +
'</summary>' +
'<p class="workspace-template-intro">Use the same onboarding shape for each client, then finish the tenant-owned configuration inside that client workspace.</p>' +
'<div class="workspace-template-grid">' +
'<div><strong>Agent naming</strong><span>' + escapeHTML(template.agent_naming) + '</span></div>' +
'<div><strong>Alert routing</strong><span>' + escapeHTML(template.alert_routing) + '</span></div>' +
'<div><strong>Reports</strong><span>' + escapeHTML(template.reporting) + '</span></div>' +
'<div><strong>Access</strong><span>' + escapeHTML(template.access) + '</span></div>' +
'</div>' +
'<span>' + escapeHTML(templateAccount.name) + '</span>' +
'</div>' +
'<div class="workspace-template-grid">' +
'<div><strong>Agent naming</strong><span>' + escapeHTML(template.agent_naming) + '</span></div>' +
'<div><strong>Alert routing</strong><span>' + escapeHTML(template.alert_routing) + '</span></div>' +
'<div><strong>Reports</strong><span>' + escapeHTML(template.reporting) + '</span></div>' +
'<div><strong>Access</strong><span>' + escapeHTML(template.access) + '</span></div>' +
'</div>' +
'</details>' +
'</section>'
);
}
@@ -994,6 +998,18 @@ function renderAccountBlockHeader(
);
}
function renderAccountSurfaceHeader(account: PortalAccountSummary, showHeader: boolean): string {
if (!showHeader) return '';
return (
'<div class="portal-section-header account-surface-header">' +
'<div>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p class="portal-section-copy">' + escapeHTML(accountKindLabel(account) + ' · ' + portalRoleLabel(account.role)) + '</p>' +
'</div>' +
'</div>'
);
}
function renderNoHostedWorkspacesSection(): string {
return (
'<section class="account-content-panel account-content-panel-workspaces">' +
@@ -1267,6 +1283,12 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
}
function renderAccountAccessSection(account: PortalAccountSummary): string {
var clientLanguage = accountUsesClientLanguage(account);
var accessRoleCopy = {
admin: clientLanguage ? 'Client control, billing, and roster management.' : 'Workspace control, billing, and roster management.',
tech: clientLanguage ? 'Client control without billing or roster ownership.' : 'Workspace control without billing or roster ownership.',
readOnly: clientLanguage ? 'Review client status without control-plane changes.' : 'Review access without control-plane changes.',
};
var accessTaskStrip = account.can_manage
? (
'<div class="access-task-strip">' +
@@ -1286,9 +1308,9 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'</div>' +
'<div class="access-policy-list">' +
'<div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div>' +
'<div class="access-policy-row"><strong>Admin</strong><span>Workspace control, billing, and roster management.</span></div>' +
'<div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing or roster ownership.</span></div>' +
'<div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div>' +
'<div class="access-policy-row"><strong>Admin</strong><span>' + escapeHTML(accessRoleCopy.admin) + '</span></div>' +
'<div class="access-policy-row"><strong>Tech</strong><span>' + escapeHTML(accessRoleCopy.tech) + '</span></div>' +
'<div class="access-policy-row"><strong>Read-only</strong><span>' + escapeHTML(accessRoleCopy.readOnly) + '</span></div>' +
'</div>' +
'</div>';
var accessInvitePanel = account.can_manage
@@ -1344,6 +1366,8 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
escapeAttr(account.role) +
'" data-can-manage="' +
escapeAttr(account.can_manage ? 'true' : 'false') +
'" data-client-language="' +
escapeAttr(clientLanguage ? 'true' : 'false') +
'">' +
(!account.can_manage
? '<p class="portal-section-copy">' + escapeHTML('Review who has access. An owner or admin must make changes.') + '</p>'
@@ -1551,6 +1575,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
? accounts.map(function(account) {
return (
'<section class="account-surface">' +
renderAccountSurfaceHeader(account, accounts.length > 1) +
renderAccountWorkspaceSection(account, context.accountAPIBasePath) +
'</section>'
);
@@ -1561,6 +1586,7 @@ export function renderAuthenticatedPortalHTML(context: ShellViewContext): string
? accounts.map(function(account) {
return (
'<section class="account-surface">' +
renderAccountSurfaceHeader(account, accounts.length > 1) +
renderAccountAccessSection(account) +
'</section>'
);
+30 -25
View File
@@ -293,6 +293,10 @@ header .logout-btn:hover,
border-top: 1px solid var(--border);
}
.account-surface-header .portal-section-copy {
margin: 4px 0 0;
}
.account-content-panel { display: block; }
.account-content-panel-workspaces,
@@ -357,7 +361,7 @@ header .logout-btn:hover,
.workspace-row-primary {
display: flex;
flex-direction: column;
gap: 1px;
gap: 2px;
min-width: 0;
}
@@ -370,13 +374,19 @@ header .logout-btn:hover,
.workspace-name {
margin: 0;
min-width: 0;
max-width: 100%;
font-size: 14px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.workspace-meta {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
@@ -391,12 +401,6 @@ header .logout-btn:hover,
color: var(--border);
}
.workspace-row-note {
margin: 0;
font-size: 12px;
color: var(--ink-secondary);
}
.workspace-row-status-cell {
display: flex;
align-items: center;
@@ -1524,29 +1528,33 @@ header .logout-btn:hover,
background: var(--bg);
}
.workspace-template-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.workspace-template-heading h3 {
margin: 0;
.workspace-template-summary {
cursor: pointer;
font-size: 14px;
font-weight: 600;
color: var(--ink);
}
.workspace-template-heading p {
margin: 3px 0 0;
.workspace-template-summary::marker,
.workspace-template-summary::-webkit-details-marker {
color: var(--ink-muted);
}
.workspace-template-summary-meta {
margin-left: 8px;
color: var(--ink-secondary);
font-size: 12px;
font-weight: 400;
}
.workspace-template-intro {
margin: 10px 0 0;
color: var(--ink-secondary);
font-size: 12px;
}
.workspace-template-heading > span {
flex: 0 0 auto;
color: var(--ink-secondary);
font-size: 12px;
.workspace-template-details[open] .workspace-template-grid {
margin-top: 10px;
}
.workspace-template-grid {
@@ -1754,9 +1762,6 @@ header .logout-btn:hover,
.workspace-setup-queue-actions {
justify-content: flex-start;
}
.workspace-template-heading {
flex-direction: column;
}
.workspace-template-grid,
.workspace-setup-guide {
grid-template-columns: 1fr;
@@ -2860,7 +2860,7 @@ class SubsystemLookupTest(unittest.TestCase):
{
"heading": "## Shared Boundaries",
"path": "internal/api/access_control_handlers.go",
"line": 318,
"line": 327,
"heading_line": 112,
}
],