mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-10 02:25:56 +00:00
Harden MSP onboarding proof gaps
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "52971d344a87d4b96d86db8184561dc150ee2cc408b1e3395c67912ad1f5e5bd",
|
||||
"source_hash": "dc3aa37a68d30708d2f82b9da0da70470a2b2b3611223a477becaaf4aa2db381",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+27
-23
@@ -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
@@ -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);
|
||||
|
||||
@@ -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>'
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user