feat(portal): reset signed-in account shell

This commit is contained in:
rcourtman
2026-03-27 15:39:00 +00:00
parent e78cde4389
commit 3d52d6b008
6 changed files with 2277 additions and 1486 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "d3efcc19901a11a535626510e9104c2428ad66d786604e8e59372e47af1a0e92",
"source_hash": "3e262c9fe2687c5e0d4ff9d11cd6f313d2f7b1e9fdb2a3f6be274246cd491d25",
"build_inputs": [
"package.json",
"tsconfig.json",
File diff suppressed because it is too large Load Diff
+71 -61
View File
@@ -1509,29 +1509,26 @@
function escapeAttr(value) {
return escapeHTML(value);
}
function titleCase(value) {
if (!value) return "";
return value.charAt(0).toUpperCase() + value.slice(1);
}
function formatWorkspaceDate(value) {
if (!value) return "";
var date = new Date(String(value));
if (Number.isNaN(date.getTime())) return "";
return date.toLocaleDateString(void 0, { month: "short", day: "numeric", year: "numeric" });
}
function workspaceHealthState(workspace) {
if (workspace.health_status === "healthy" || workspace.health_status === "checking" || workspace.health_status === "unhealthy") {
return workspace.health_status;
}
if (workspace.healthy) return "healthy";
if (workspace.last_health_check) return "unhealthy";
return "checking";
}
function roleBadgeHTML(role) {
if (role === "owner") return '<span class="badge badge-role">Owner</span>';
if (role === "admin") return '<span class="badge badge-role">Admin</span>';
if (role === "tech") return '<span class="badge badge-role">Tech</span>';
return "";
}
function healthBadgeHTML(workspace) {
if (workspace.health_status === "healthy") {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (workspace.health_status === "unhealthy") {
return '<span class="badge badge-unhealthy">Unhealthy</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceCountLabel(count) {
return count === 1 ? "1 workspace" : String(count) + " workspaces";
return '<span class="badge badge-role">' + escapeHTML(titleCase(role || "member")) + "</span>";
}
function accountKindLabel(account) {
if (account.kind === "msp") return "MSP account";
@@ -1539,9 +1536,11 @@
if (account.kind === "individual") return "Hosted account";
return account.kind_label ? account.kind_label + " account" : "Account";
}
function titleCase(value) {
if (!value) return "";
return value.charAt(0).toUpperCase() + value.slice(1);
function workspaceCountLabel(count) {
return count === 1 ? "1 workspace" : String(count) + " workspaces";
}
function hasHostedAccounts(accounts) {
return accounts.length > 0;
}
function countWorkspaces(accounts) {
var total = 0;
@@ -1550,16 +1549,6 @@
}
return total;
}
function hasHostedAccounts(accounts) {
return accounts.length > 0;
}
function countWorkspacesByHealth(workspaces, status) {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
if (String(workspaces[i].health_status || "") === status) count += 1;
}
return count;
}
function countWorkspacesByState(workspaces, state) {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
@@ -1567,39 +1556,64 @@
}
return count;
}
function renderOverviewBand(context, accounts) {
function countWorkspacesByHealth(workspaces, status) {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
if (workspaceHealthState(workspaces[i]) === status) count += 1;
}
return count;
}
function healthBadgeHTML(workspace) {
var status = workspaceHealthState(workspace);
if (status === "healthy") {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (status === "unhealthy") {
return '<span class="badge badge-unhealthy">Needs attention</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceStatusCopy(workspace) {
var status = workspaceHealthState(workspace);
if (status === "healthy") return "Live updates and health checks are currently good.";
if (status === "unhealthy") return "This workspace needs attention before it is trustworthy.";
return "This workspace is still waiting on a completed health check.";
}
function renderOverviewBand(accounts) {
var hosted = hasHostedAccounts(accounts);
var workspaceTotal = countWorkspaces(accounts);
var title = hosted ? "Pulse Account" : "Self-hosted Pulse Account";
var summary = hosted ? "Open hosted workspaces, manage account access, and handle commercial account services from one place." : "This account currently has no hosted workspaces. Self-hosted licenses, billing, refunds, and data requests stay available below.";
var hostedStateLabel = hosted ? "Active" : "Not attached";
var hostedStateClass = hosted ? "overview-stat-positive" : "overview-stat-muted";
var accountsLabel = accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts";
return '<section class="overview-band"><div class="overview-copy"><div class="overview-kicker">' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + "</div><h1>" + title + "</h1><p>" + summary + '</p></div><div class="overview-stats"><div class="overview-stat-card"><span class="overview-stat-label">Hosted access</span><span class="overview-stat-value ' + hostedStateClass + '">' + hostedStateLabel + '</span></div><div class="overview-stat-card"><span class="overview-stat-label">Accounts</span><span class="overview-stat-value">' + accountsLabel + '</span></div><div class="overview-stat-card"><span class="overview-stat-label">Workspaces</span><span class="overview-stat-value">' + String(workspaceTotal) + "</span></div></div></section>";
var summary = hosted ? "Open hosted workspaces, manage account access, and handle commercial account services from one place." : "Use this account for self-hosted billing, license recovery, refunds, and privacy actions. Hosted workspace access will appear here when it is attached to this email.";
return '<section class="portal-hero"><div class="portal-hero-copy"><div class="portal-hero-kicker">' + (hosted ? "Hosted access is active on this account." : "No hosted workspace access is attached to this account yet.") + "</div><h1>" + title + "</h1><p>" + summary + '</p></div><div class="portal-hero-stats"><div class="portal-hero-stat"><span class="portal-hero-stat-label">Hosted access</span><span class="portal-hero-stat-value">' + (hosted ? "Active" : "Not attached") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Accounts</span><span class="portal-hero-stat-value">' + (accounts.length === 1 ? "1 account" : String(accounts.length) + " accounts") + '</span></div><div class="portal-hero-stat"><span class="portal-hero-stat-label">Workspaces</span><span class="portal-hero-stat-value">' + String(workspaceTotal) + "</span></div></div></section>";
}
function renderShellNavigation(accounts, supportEmail) {
var hosted = hasHostedAccounts(accounts);
var hostedLabel = hosted ? "Hosted operations" : "Hosted access";
var hostedCopy = hosted ? "Workspaces, teams, and hosted billing" : "No hosted workspaces are attached yet";
return '<nav class="portal-section-nav" aria-label="Pulse Account sections"><a class="portal-section-link" href="#hosted-operations-section"><span class="portal-section-link-label">' + hostedLabel + '</span><span class="portal-section-link-copy">' + hostedCopy + '</span></a><a class="portal-section-link" href="#account-services-section"><span class="portal-section-link-label">Account services</span><span class="portal-section-link-copy">Licenses, billing, refunds, and privacy</span></a><a class="portal-section-link" href="mailto:' + escapeAttr(supportEmail || "") + '"><span class="portal-section-link-label">Support</span><span class="portal-section-link-copy">' + escapeHTML(supportEmail || "") + "</span></a></nav>";
return '<nav class="portal-section-nav" aria-label="Pulse Account sections"><a class="portal-section-link" href="#hosted-operations-section"><span class="portal-section-link-label">' + (hosted ? "Hosted operations" : "Hosted access") + '</span><span class="portal-section-link-copy">' + (hosted ? "Workspaces, teams, and hosted billing" : "No hosted workspaces are attached yet") + '</span></a><a class="portal-section-link" href="#account-services-section"><span class="portal-section-link-label">Account services</span><span class="portal-section-link-copy">Licenses, billing, refunds, and privacy</span></a><a class="portal-section-link" href="mailto:' + escapeAttr(supportEmail || "") + '"><span class="portal-section-link-label">Support</span><span class="portal-section-link-copy">' + escapeHTML(supportEmail || "") + "</span></a></nav>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
var state = String(workspace.state || "");
var safeState = escapeHTML(state);
var createdLabel = formatWorkspaceDate(workspace.created_at);
var healthSummary = workspace.health_status === "healthy" ? "Live updates and health checks are currently good." : workspace.health_status === "unhealthy" ? "This workspace needs attention before it is trustworthy." : "This workspace is still waiting on a completed health check.";
var metaParts = [
'<span class="workspace-meta-item">' + escapeHTML(titleCase(state || "unknown")) + "</span>"
];
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(accountAPIBasePath + "/" + account.id + "/tenants/" + workspace.id + "/handoff") + '"><button type="submit" class="btn-primary">Open workspace</button></form>';
} else {
openAction = '<span class="workspace-state-label">' + safeState + "</span>";
openAction = '<span class="workspace-state-label">' + escapeHTML(titleCase(state || "Unknown")) + "</span>";
}
var manageAction = "";
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) + '">Manage</button>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + "</span>" : "";
return '<div class="workspace-card"><div class="ws-info"><div class="ws-title-row"><span class="ws-name">' + escapeHTML(workspace.display_name) + '</span></div><div class="ws-meta">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + safeState + '">' + safeState + "</span>" + createdMeta + '</div><div class="ws-summary">' + escapeHTML(healthSummary) + '</div></div><div class="ws-actions">' + openAction + manageAction + "</div></div>";
return '<article class="workspace-card"><div class="workspace-card-main"><div class="workspace-card-topline"><div class="workspace-title-group"><h4 class="workspace-name">' + escapeHTML(workspace.display_name) + '</h4><div class="workspace-meta">' + metaParts.join("") + '</div></div><div class="workspace-badges">' + healthBadgeHTML(workspace) + '<span class="badge badge-' + escapeHTML(state || "unknown") + '">' + escapeHTML(titleCase(state || "Unknown")) + '</span></div></div><p class="workspace-summary">' + escapeHTML(workspaceStatusCopy(workspace)) + '</p></div><div class="workspace-actions">' + openAction + manageAction + "</div></article>";
}
function renderAccountSection(account, accountAPIBasePath) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
@@ -1609,31 +1623,26 @@
var unhealthyCount = countWorkspacesByHealth(workspaces, "unhealthy");
var activeCount = countWorkspacesByState(workspaces, "active");
var operationsCopy = account.kind === "msp" ? "Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here." : "Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.";
var workspaceHTML = "";
if (workspaces.length === 0) {
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
} else {
workspaceHTML = '<div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join("") + "</div>";
}
var actions = "";
var teamSection = "";
var addWorkspaceForm = "";
var workspaceManagement = "";
if (account.can_manage) {
actions = '<div class="account-operations-panel"><div class="account-operations-copy"><h3>Account operations</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
teamSection = '<div class="team-management-panel team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><div class="team-management-header"><div><h3>Team management</h3><p>Control who can operate this account, what role they hold, and who should be invited next.</p></div><button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.</p></div><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table></div><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-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></div></div>';
workspaceManagement = '<div class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><h3>Workspace management</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></div>';
actions = '<div class="account-operations-panel"><div class="account-panel-copy"><div class="account-panel-kicker">Account operations</div><h3>Run the hosted side from here</h3><p>' + escapeHTML(operationsCopy) + '</p></div><div class="account-actions">' + (account.kind === "msp" ? '<button type="button" class="btn-secondary" id="add-ws-btn-' + escapeAttr(account.id) + '" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Add workspace</button>' : "") + (account.has_billing ? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' + escapeAttr(account.id) + '">Manage billing</button>' : "") + '<button type="button" class="btn-secondary" id="team-btn-' + escapeAttr(account.id) + '" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Manage team</button></div></div>';
teamSection = '<section class="team-management-panel team-section" id="team-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '"><div class="team-management-header"><div><div class="account-panel-kicker">Team management</div><h3>Control who can operate this account</h3><p>Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.</p></div><button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="team-management-stats" id="team-stats-' + escapeAttr(account.id) + '"></div><div class="team-management-grid"><div class="team-roster"><div class="team-panel-heading"><h4>People on this account</h4><p>Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.</p></div><table class="team-table"><thead><tr><th>Email</th><th>Role</th><th></th></tr></thead><tbody id="team-list-' + escapeAttr(account.id) + '"><tr><td colspan="3" class="team-message-cell">Loading\u2026</td></tr></tbody></table></div><div class="team-invite-panel"><div class="team-panel-heading"><h4>Invite someone new</h4><p>Add another operator with the minimum role they need for this account.</p></div><div class="team-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></div></section>';
workspaceManagement = '<section class="workspace-management-panel" id="workspace-management-' + escapeAttr(account.id) + '"><div class="workspace-management-header"><div><div class="account-panel-kicker">Workspace management</div><h3>Review one workspace at a time</h3><p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p></div><button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' + escapeAttr(account.id) + '" data-action="clear-workspace-selection" data-account-id="' + escapeAttr(account.id) + '">Done</button></div><div class="workspace-management-empty" id="workspace-management-empty-' + escapeAttr(account.id) + '">Choose a workspace to manage from the fleet above.</div><div class="workspace-management-content" id="workspace-management-content-' + escapeAttr(account.id) + '" hidden><div class="workspace-management-meta" id="workspace-management-meta-' + escapeAttr(account.id) + '"></div><h4 id="workspace-management-title-' + escapeAttr(account.id) + '"></h4><p class="workspace-management-summary" id="workspace-management-summary-' + escapeAttr(account.id) + '"></p><div class="workspace-management-actions"><button type="button" class="btn-danger" id="workspace-management-action-' + escapeAttr(account.id) + '" data-action="workspace-action" data-account-id="' + escapeAttr(account.id) + '">Manage workspace</button></div></div></section>';
if (account.kind === "msp") {
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (e.g. client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
addWorkspaceForm = '<div class="add-workspace-form" id="add-ws-form-' + escapeAttr(account.id) + '"><label for="ws-name-' + escapeAttr(account.id) + '">Workspace name (for example, a client name)</label><input type="text" id="ws-name-' + escapeAttr(account.id) + '" placeholder="Acme Corp" maxlength="80" autocomplete="off"><div class="form-actions"><button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' + escapeAttr(account.id) + '">Create workspace</button><button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' + escapeAttr(account.id) + '">Cancel</button><div class="spinner" id="ws-spinner-' + escapeAttr(account.id) + '" hidden></div></div></div>';
}
}
return '<section class="account-section"><div class="account-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + '<div class="account-subsection-header"><h3>Workspace fleet</h3><p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p></div>' + workspaceHTML + workspaceManagement + teamSection + addWorkspaceForm + "</section>";
var workspaceHTML = workspaces.length ? '<div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join("") + "</div>" : '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
return '<section class="account-surface"><div class="account-surface-header"><div class="account-heading"><div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + "</div><h2>" + escapeHTML(account.name) + '</h2><div class="account-summary">' + escapeHTML(summaryText) + '</div></div><div class="account-badges"><span class="badge badge-' + escapeHTML(account.kind) + '">' + escapeHTML(account.kind_label) + "</span>" + roleBadgeHTML(account.role) + '</div></div><div class="account-surface-body"><aside class="account-overview-rail"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(accountKindLabel(account)) + "</h3><p>" + escapeHTML(summaryText) + '</p></div><div class="account-status-grid"><div class="account-stat-card"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + '</aside><div class="account-main-stage"><div class="account-stage-header"><div><div class="account-panel-kicker">Workspace fleet</div><h3>Open hosted Pulse workspaces</h3><p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p></div></div>' + workspaceHTML + '<div class="account-management-grid">' + workspaceManagement + teamSection + "</div></div></div></section>";
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
return "<span>" + escapeHTML(context.bootstrap.email || "") + '</span><button class="logout-btn" id="logout-btn" type="button">Sign out</button>';
return '<div class="header-account-chip"><span class="header-account-email">' + escapeHTML(context.bootstrap.email || "") + '</span><button class="logout-btn" id="logout-btn" type="button">Sign out</button></div>';
}
return '<a class="logout-btn link-button" href="' + escapeAttr(context.signupPath) + '">Create account</a>';
}
@@ -1648,9 +1657,10 @@
}
function renderAuthenticatedPortalHTML(context) {
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var serviceHeading = hasHostedAccounts(accounts) ? "Self-hosted licenses and billing" : "Account services";
var serviceNote = hasHostedAccounts(accounts) ? "Hosted access lives above. The self-hosted commercial tools remain available here for licenses, billing, refunds, and privacy actions." : "Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.";
return renderOverviewBand(context, accounts) + renderShellNavigation(accounts, context.bootstrap.support_email || "") + '<section class="portal-top-section" id="hosted-operations-section"><div class="portal-top-section-header"><h2>' + (hasHostedAccounts(accounts) ? "Hosted operations" : "Hosted access") + "</h2><p>" + (hasHostedAccounts(accounts) ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '</p></div><div id="accounts-root">' + renderAccountsHTML(context) + '</div></section><section class="service-section" id="account-services-section"><div class="service-header"><h2>' + serviceHeading + '</h2><div class="service-note">' + serviceNote + '</div></div><div class="service-grid"><button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email"><h3>Manage subscriptions</h3><p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p></button><button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email"><h3>Retrieve licenses</h3><p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p></button><button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email"><h3>Refund requests</h3><p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p></button><button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email"><h3>Data and privacy</h3><p>Request commercial data export or deletion without leaving the account shell.</p></button></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + "</a>.</div></div></section>";
var hosted = hasHostedAccounts(accounts);
var serviceHeading = hosted ? "Self-hosted licenses and billing" : "Account services";
var serviceNote = hosted ? "Hosted operations live above. Use these commercial tools for self-hosted licenses, billing, refunds, and privacy actions." : "Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.";
return '<div class="portal-shell">' + renderOverviewBand(accounts) + '<div class="portal-frame"><aside class="portal-rail">' + renderShellNavigation(accounts, context.bootstrap.support_email || "") + '<div class="portal-rail-panel"><div class="account-panel-kicker">Support</div><h3>One account surface</h3><p>This portal should be the place where hosted access and commercial account actions meet. Hosted workspaces sit above. Self-hosted services remain available below.</p><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div></aside><div class="portal-stage"><section class="portal-top-section" id="hosted-operations-section"><div class="portal-top-section-header"><div class="account-panel-kicker">' + (hosted ? "Hosted operations" : "Hosted access") + "</div><h2>" + (hosted ? "Run hosted accounts from one place" : "No hosted workspaces are attached yet") + "</h2><p>" + (hosted ? "Use this area for workspace access, fleet operations, hosted billing, and team management." : "This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.") + '</p></div><div id="accounts-root">' + renderAccountsHTML(context) + '</div></section><section class="service-section" id="account-services-section"><div class="service-header"><div><div class="account-panel-kicker">Account services</div><h2>' + serviceHeading + '</h2></div><div class="service-note">' + serviceNote + '</div></div><div class="service-grid"><button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email"><span class="service-card-kicker">Billing</span><h3>Manage subscriptions</h3><p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p></button><button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email"><span class="service-card-kicker">Licenses</span><h3>Retrieve licenses</h3><p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p></button><button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email"><span class="service-card-kicker">Refunds</span><h3>Refund requests</h3><p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p></button><button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email"><span class="service-card-kicker">Privacy</span><h3>Data and privacy</h3><p>Request commercial data export or deletion without leaving the account shell.</p></button></div><div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div><div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div><div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div><div class="service-panel" id="data-service-panel"><h3>Data and privacy</h3><p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p><div class="subsection"><div id="data-export-root"></div></div><div class="subsection"><div id="data-delete-root"></div></div><div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + "</a>.</div></div></section></div></div></div>";
}
function renderSignedOutPortalHTML(context) {
var statusHTML = "";
@@ -1660,7 +1670,7 @@
var successMessage = context.loginState.successMessage || "If that email is registered, a magic link is on the way.";
statusHTML = '<div class="service-status visible success">' + escapeHTML(successMessage) + `<br><br><strong>Don't see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.</div>`;
}
return '<section class="intro-card"><h1>Pulse Account</h1><p>Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.</p></section><section class="service-section"><div class="service-panel visible"><h3>Sign in</h3><p>Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.</p><div class="form-group"><label for="portal-login-email">Email address</label><input id="portal-login-email" type="email" autocomplete="email" placeholder="you@example.com" value="' + escapeAttr(context.loginState.emailValue || "") + '" data-portal-input="login-email"></div><div class="form-actions"><button class="btn-primary" id="portal-login-send" type="button" data-portal-action="send-magic-link">' + (context.loginState.request.pending ? "Sending\u2026" : "Send magic link") + '</button><a class="btn-secondary link-button" href="' + escapeAttr(context.signupPath) + '">Create an account</a></div>' + statusHTML + "</div></section>";
return '<section class="intro-card"><div class="account-panel-kicker">Pulse Account</div><h1>Sign in to the account surface</h1><p>Use one commercial email address to get into hosted workspaces, MSP access, billing, license recovery, refunds, and privacy actions.</p></section><section class="service-section service-section-auth"><div class="service-panel visible auth-panel"><h3>Sign in</h3><p>Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.</p><div class="form-group"><label for="portal-login-email">Email address</label><input id="portal-login-email" type="email" autocomplete="email" placeholder="you@example.com" value="' + escapeAttr(context.loginState.emailValue || "") + '" data-portal-input="login-email"></div><div class="form-actions"><button class="btn-primary" id="portal-login-send" type="button" data-portal-action="send-magic-link">' + (context.loginState.request.pending ? "Sending\u2026" : "Send magic link") + '</button><a class="btn-secondary link-button" href="' + escapeAttr(context.signupPath) + '">Create an account</a></div>' + statusHTML + "</div></section>";
}
// src/shell.ts
@@ -147,7 +147,7 @@ describe('shell view', function() {
expect(html).toContain('Active workspaces');
expect(html).toContain('Needs attention');
expect(html).toContain('Healthy</span>');
expect(html).toContain('Unhealthy</span>');
expect(html).toContain('Needs attention</span>');
expect(html).toContain('Checking</span>');
expect(html).toContain('Live updates and health checks are currently good.');
expect(html).toContain('This workspace needs attention before it is trustworthy.');
+357 -306
View File
@@ -25,6 +25,11 @@ function escapeAttr(value: unknown): string {
return escapeHTML(value);
}
function titleCase(value: string): string {
if (!value) return '';
return value.charAt(0).toUpperCase() + value.slice(1);
}
function formatWorkspaceDate(value: unknown): string {
if (!value) return '';
var date = new Date(String(value));
@@ -32,25 +37,17 @@ function formatWorkspaceDate(value: unknown): string {
return date.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
}
function workspaceHealthState(workspace: PortalWorkspaceSummary): 'healthy' | 'checking' | 'unhealthy' {
if (workspace.health_status === 'healthy' || workspace.health_status === 'checking' || workspace.health_status === 'unhealthy') {
return workspace.health_status;
}
if (workspace.healthy) return 'healthy';
if (workspace.last_health_check) return 'unhealthy';
return 'checking';
}
function roleBadgeHTML(role: string): string {
if (role === 'owner') return '<span class="badge badge-role">Owner</span>';
if (role === 'admin') return '<span class="badge badge-role">Admin</span>';
if (role === 'tech') return '<span class="badge badge-role">Tech</span>';
return '';
}
function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
if (workspace.health_status === 'healthy') {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (workspace.health_status === 'unhealthy') {
return '<span class="badge badge-unhealthy">Unhealthy</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceCountLabel(count: number): string {
return count === 1 ? '1 workspace' : String(count) + ' workspaces';
return '<span class="badge badge-role">' + escapeHTML(titleCase(role || 'member')) + '</span>';
}
function accountKindLabel(account: PortalAccountSummary): string {
@@ -60,9 +57,12 @@ function accountKindLabel(account: PortalAccountSummary): string {
return account.kind_label ? account.kind_label + ' account' : 'Account';
}
function titleCase(value: string): string {
if (!value) return '';
return value.charAt(0).toUpperCase() + value.slice(1);
function workspaceCountLabel(count: number): string {
return count === 1 ? '1 workspace' : String(count) + ' workspaces';
}
function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
return accounts.length > 0;
}
function countWorkspaces(accounts: PortalAccountSummary[]): number {
@@ -73,18 +73,6 @@ function countWorkspaces(accounts: PortalAccountSummary[]): number {
return total;
}
function hasHostedAccounts(accounts: PortalAccountSummary[]): boolean {
return accounts.length > 0;
}
function countWorkspacesByHealth(workspaces: PortalWorkspaceSummary[], status: string): number {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
if (String(workspaces[i].health_status || '') === status) count += 1;
}
return count;
}
function countWorkspacesByState(workspaces: PortalWorkspaceSummary[], state: string): number {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
@@ -93,36 +81,59 @@ function countWorkspacesByState(workspaces: PortalWorkspaceSummary[], state: str
return count;
}
function renderOverviewBand(context: ShellViewContext, accounts: PortalAccountSummary[]): string {
function countWorkspacesByHealth(workspaces: PortalWorkspaceSummary[], status: 'healthy' | 'checking' | 'unhealthy'): number {
var count = 0;
for (var i = 0; i < workspaces.length; i += 1) {
if (workspaceHealthState(workspaces[i]) === status) count += 1;
}
return count;
}
function healthBadgeHTML(workspace: PortalWorkspaceSummary): string {
var status = workspaceHealthState(workspace);
if (status === 'healthy') {
return '<span class="badge badge-healthy">Healthy</span>';
}
if (status === 'unhealthy') {
return '<span class="badge badge-unhealthy">Needs attention</span>';
}
return '<span class="badge badge-checking">Checking</span>';
}
function workspaceStatusCopy(workspace: PortalWorkspaceSummary): string {
var status = workspaceHealthState(workspace);
if (status === 'healthy') return 'Live updates and health checks are currently good.';
if (status === 'unhealthy') return 'This workspace needs attention before it is trustworthy.';
return 'This workspace is still waiting on a completed health check.';
}
function renderOverviewBand(accounts: PortalAccountSummary[]): string {
var hosted = hasHostedAccounts(accounts);
var workspaceTotal = countWorkspaces(accounts);
var title = hosted ? 'Pulse Account' : 'Self-hosted Pulse Account';
var summary = hosted
? 'Open hosted workspaces, manage account access, and handle commercial account services from one place.'
: 'This account currently has no hosted workspaces. Self-hosted licenses, billing, refunds, and data requests stay available below.';
var hostedStateLabel = hosted ? 'Active' : 'Not attached';
var hostedStateClass = hosted ? 'overview-stat-positive' : 'overview-stat-muted';
var accountsLabel = accounts.length === 1 ? '1 account' : String(accounts.length) + ' accounts';
: 'Use this account for self-hosted billing, license recovery, refunds, and privacy actions. Hosted workspace access will appear here when it is attached to this email.';
return (
'<section class="overview-band">' +
'<div class="overview-copy">' +
'<div class="overview-kicker">' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '</div>' +
'<section class="portal-hero">' +
'<div class="portal-hero-copy">' +
'<div class="portal-hero-kicker">' + (hosted ? 'Hosted access is active on this account.' : 'No hosted workspace access is attached to this account yet.') + '</div>' +
'<h1>' + title + '</h1>' +
'<p>' + summary + '</p>' +
'</div>' +
'<div class="overview-stats">' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Hosted access</span>' +
'<span class="overview-stat-value ' + hostedStateClass + '">' + hostedStateLabel + '</span>' +
'<div class="portal-hero-stats">' +
'<div class="portal-hero-stat">' +
'<span class="portal-hero-stat-label">Hosted access</span>' +
'<span class="portal-hero-stat-value">' + (hosted ? 'Active' : 'Not attached') + '</span>' +
'</div>' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Accounts</span>' +
'<span class="overview-stat-value">' + accountsLabel + '</span>' +
'<div class="portal-hero-stat">' +
'<span class="portal-hero-stat-label">Accounts</span>' +
'<span class="portal-hero-stat-value">' + (accounts.length === 1 ? '1 account' : String(accounts.length) + ' accounts') + '</span>' +
'</div>' +
'<div class="overview-stat-card">' +
'<span class="overview-stat-label">Workspaces</span>' +
'<span class="overview-stat-value">' + String(workspaceTotal) + '</span>' +
'<div class="portal-hero-stat">' +
'<span class="portal-hero-stat-label">Workspaces</span>' +
'<span class="portal-hero-stat-value">' + String(workspaceTotal) + '</span>' +
'</div>' +
'</div>' +
'</section>'
@@ -131,16 +142,11 @@ function renderOverviewBand(context: ShellViewContext, accounts: PortalAccountSu
function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: string): string {
var hosted = hasHostedAccounts(accounts);
var hostedLabel = hosted ? 'Hosted operations' : 'Hosted access';
var hostedCopy = hosted
? 'Workspaces, teams, and hosted billing'
: 'No hosted workspaces are attached yet';
return (
'<nav class="portal-section-nav" aria-label="Pulse Account sections">' +
'<a class="portal-section-link" href="#hosted-operations-section">' +
'<span class="portal-section-link-label">' + hostedLabel + '</span>' +
'<span class="portal-section-link-copy">' + hostedCopy + '</span>' +
'<span class="portal-section-link-label">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</span>' +
'<span class="portal-section-link-copy">' + (hosted ? 'Workspaces, teams, and hosted billing' : 'No hosted workspaces are attached yet') + '</span>' +
'</a>' +
'<a class="portal-section-link" href="#account-services-section">' +
'<span class="portal-section-link-label">Account services</span>' +
@@ -155,14 +161,19 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s
}
function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWorkspaceSummary, accountAPIBasePath: string): string {
var status = workspaceHealthState(workspace);
var state = String(workspace.state || '');
var safeState = escapeHTML(state);
var createdLabel = formatWorkspaceDate(workspace.created_at);
var healthSummary = workspace.health_status === 'healthy'
? 'Live updates and health checks are currently good.'
: workspace.health_status === 'unhealthy'
? 'This workspace needs attention before it is trustworthy.'
: 'This workspace is still waiting on a completed health check.';
var metaParts = [
'<span class="workspace-meta-item">' + escapeHTML(titleCase(state || 'unknown')) + '</span>',
];
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 =
@@ -172,40 +183,39 @@ function renderWorkspaceCard(account: PortalAccountSummary, workspace: PortalWor
'<button type="submit" class="btn-primary">Open workspace</button>' +
'</form>';
} else {
openAction = '<span class="workspace-state-label">' + safeState + '</span>';
openAction = '<span class="workspace-state-label">' + escapeHTML(titleCase(state || 'Unknown')) + '</span>';
}
var manageAction = '';
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) +
'">' +
'Manage' +
'</button>';
escapeAttr(account.id) +
'" data-workspace-id="' +
escapeAttr(workspace.id) +
'">Manage</button>';
}
var createdMeta = createdLabel ? '<span class="ws-created">Created ' + escapeHTML(createdLabel) + '</span>' : '';
return (
'<div class="workspace-card">' +
'<div class="ws-info">' +
'<div class="ws-title-row">' +
'<span class="ws-name">' + escapeHTML(workspace.display_name) + '</span>' +
'<article class="workspace-card">' +
'<div class="workspace-card-main">' +
'<div class="workspace-card-topline">' +
'<div class="workspace-title-group">' +
'<h4 class="workspace-name">' + escapeHTML(workspace.display_name) + '</h4>' +
'<div class="workspace-meta">' + metaParts.join('') + '</div>' +
'</div>' +
'<div class="workspace-badges">' +
healthBadgeHTML(workspace) +
'<span class="badge badge-' + escapeHTML(state || 'unknown') + '">' + escapeHTML(titleCase(state || 'Unknown')) + '</span>' +
'</div>' +
'</div>' +
'<div class="ws-meta">' +
healthBadgeHTML(workspace) +
'<span class="badge badge-' + safeState + '">' + safeState + '</span>' +
createdMeta +
'</div>' +
'<div class="ws-summary">' + escapeHTML(healthSummary) + '</div>' +
'<p class="workspace-summary">' + escapeHTML(workspaceStatusCopy(workspace)) + '</p>' +
'</div>' +
'<div class="ws-actions">' +
'<div class="workspace-actions">' +
openAction +
manageAction +
'</div>' +
'</div>'
'</article>'
);
}
@@ -219,17 +229,6 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
var operationsCopy = account.kind === 'msp'
? 'Manage the client fleet from this account surface. Workspace creation, billing, and team actions belong here.'
: 'Use this account surface to open hosted workspaces, manage billing, and control access for this hosted account.';
var workspaceHTML = '';
if (workspaces.length === 0) {
workspaceHTML = '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
} else {
workspaceHTML =
'<div class="workspace-list">' +
workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join('') +
'</div>';
}
var actions = '';
var teamSection = '';
@@ -238,157 +237,166 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
if (account.can_manage) {
actions =
'<div class="account-operations-panel">' +
'<div class="account-operations-copy">' +
'<h3>Account operations</h3>' +
'<p>' + escapeHTML(operationsCopy) + '</p>' +
'</div>' +
'<div class="account-actions">' +
(account.kind === 'msp'
? '<button type="button" class="btn-secondary" id="add-ws-btn-' +
'<div class="account-panel-copy">' +
'<div class="account-panel-kicker">Account operations</div>' +
'<h3>Run the hosted side from here</h3>' +
'<p>' + escapeHTML(operationsCopy) + '</p>' +
'</div>' +
'<div class="account-actions">' +
(account.kind === 'msp'
? '<button type="button" class="btn-secondary" id="add-ws-btn-' +
escapeAttr(account.id) +
'" data-action="toggle-add-workspace" data-account-id="' +
escapeAttr(account.id) +
'">Add workspace</button>'
: '') +
(account.has_billing
? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' +
escapeAttr(account.id) +
'">Manage billing</button>'
: '') +
'<button type="button" class="btn-secondary" id="team-btn-' +
escapeAttr(account.id) +
'" data-action="toggle-add-workspace" data-account-id="' +
'" data-action="toggle-team" data-account-id="' +
escapeAttr(account.id) +
'">Add workspace</button>'
: '') +
(account.has_billing
? '<button type="button" class="btn-secondary" data-action="open-billing" data-account-id="' +
escapeAttr(account.id) +
'">Manage billing</button>'
: '') +
'<button type="button" class="btn-secondary" id="team-btn-' +
escapeAttr(account.id) +
'" data-action="toggle-team" data-account-id="' +
escapeAttr(account.id) +
'">Manage team</button>' +
'</div>' +
'">Manage team</button>' +
'</div>' +
'</div>';
teamSection =
'<div class="team-management-panel team-section" id="team-section-' +
'<section class="team-management-panel team-section" id="team-section-' +
escapeAttr(account.id) +
'" data-actor-role="' +
escapeAttr(account.role) +
'">' +
'<div class="team-management-header">' +
'<div>' +
'<h3>Team management</h3>' +
'<p>Control who can operate this account, what role they hold, and who should be invited next.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="team-management-stats" id="team-stats-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="team-management-grid">' +
'<div class="team-roster">' +
'<div class="team-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>Owners can manage billing and account access. Admins and techs keep the hosted fleet running day to day.</p>' +
'</div>' +
'<table class="team-table">' +
'<thead><tr><th>Email</th><th>Role</th><th></th></tr></thead>' +
'<tbody id="team-list-' +
escapeAttr(account.id) +
'">' +
'<tr><td colspan="3" class="team-message-cell">Loading…</td></tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'<div class="team-invite-panel">' +
'<div class="team-panel-heading">' +
'<h4>Invite someone new</h4>' +
'<p>Add another operator with the minimum role they need for this account.</p>' +
'</div>' +
'<div class="team-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>' +
'</div>' +
'</div>';
'<div class="team-management-header">' +
'<div>' +
'<div class="account-panel-kicker">Team management</div>' +
'<h3>Control who can operate this account</h3>' +
'<p>Owners manage billing and access. Admins and techs keep the hosted fleet running day to day.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" data-action="toggle-team" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="team-management-stats" id="team-stats-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="team-management-grid">' +
'<div class="team-roster">' +
'<div class="team-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>Keep the roster small and role assignment explicit. The people listed here are the ones who can operate the hosted fleet.</p>' +
'</div>' +
'<table class="team-table">' +
'<thead><tr><th>Email</th><th>Role</th><th></th></tr></thead>' +
'<tbody id="team-list-' +
escapeAttr(account.id) +
'">' +
'<tr><td colspan="3" class="team-message-cell">Loading…</td></tr>' +
'</tbody>' +
'</table>' +
'</div>' +
'<div class="team-invite-panel">' +
'<div class="team-panel-heading">' +
'<h4>Invite someone new</h4>' +
'<p>Add another operator with the minimum role they need for this account.</p>' +
'</div>' +
'<div class="team-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>' +
'</div>' +
'</section>';
workspaceManagement =
'<div class="workspace-management-panel" id="workspace-management-' +
'<section class="workspace-management-panel" id="workspace-management-' +
escapeAttr(account.id) +
'">' +
'<div class="workspace-management-header">' +
'<div>' +
'<h3>Workspace management</h3>' +
'<p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' +
escapeAttr(account.id) +
'" data-action="clear-workspace-selection" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="workspace-management-empty" id="workspace-management-empty-' +
escapeAttr(account.id) +
'">Choose a workspace to manage from the fleet above.</div>' +
'<div class="workspace-management-content" id="workspace-management-content-' +
escapeAttr(account.id) +
'" hidden>' +
'<div class="workspace-management-meta" id="workspace-management-meta-' +
escapeAttr(account.id) +
'"></div>' +
'<h4 id="workspace-management-title-' +
escapeAttr(account.id) +
'"></h4>' +
'<p class="workspace-management-summary" id="workspace-management-summary-' +
escapeAttr(account.id) +
'"></p>' +
'<div class="workspace-management-actions">' +
'<button type="button" class="btn-danger" id="workspace-management-action-' +
escapeAttr(account.id) +
'" data-action="workspace-action" data-account-id="' +
escapeAttr(account.id) +
'">Manage workspace</button>' +
'</div>' +
'</div>' +
'</div>';
'<div class="workspace-management-header">' +
'<div>' +
'<div class="account-panel-kicker">Workspace management</div>' +
'<h3>Review one workspace at a time</h3>' +
'<p>Select a workspace from the fleet to review its lifecycle state and run explicit management actions.</p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" id="workspace-management-close-' +
escapeAttr(account.id) +
'" data-action="clear-workspace-selection" data-account-id="' +
escapeAttr(account.id) +
'">Done</button>' +
'</div>' +
'<div class="workspace-management-empty" id="workspace-management-empty-' +
escapeAttr(account.id) +
'">Choose a workspace to manage from the fleet above.</div>' +
'<div class="workspace-management-content" id="workspace-management-content-' +
escapeAttr(account.id) +
'" hidden>' +
'<div class="workspace-management-meta" id="workspace-management-meta-' +
escapeAttr(account.id) +
'"></div>' +
'<h4 id="workspace-management-title-' +
escapeAttr(account.id) +
'"></h4>' +
'<p class="workspace-management-summary" id="workspace-management-summary-' +
escapeAttr(account.id) +
'"></p>' +
'<div class="workspace-management-actions">' +
'<button type="button" class="btn-danger" id="workspace-management-action-' +
escapeAttr(account.id) +
'" data-action="workspace-action" data-account-id="' +
escapeAttr(account.id) +
'">Manage workspace</button>' +
'</div>' +
'</div>' +
'</section>';
if (account.kind === 'msp') {
addWorkspaceForm =
'<div class="add-workspace-form" id="add-ws-form-' +
escapeAttr(account.id) +
'">' +
'<label for="ws-name-' +
escapeAttr(account.id) +
'">Workspace name (e.g. client name)</label>' +
'<input type="text" id="ws-name-' +
escapeAttr(account.id) +
'" placeholder="Acme Corp" maxlength="80" autocomplete="off">' +
'<div class="form-actions">' +
'<button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' +
escapeAttr(account.id) +
'">Create workspace</button>' +
'<button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' +
escapeAttr(account.id) +
'">Cancel</button>' +
'<div class="spinner" id="ws-spinner-' +
escapeAttr(account.id) +
'" hidden></div>' +
'</div>' +
'<label for="ws-name-' +
escapeAttr(account.id) +
'">Workspace name (for example, a client name)</label>' +
'<input type="text" id="ws-name-' +
escapeAttr(account.id) +
'" placeholder="Acme Corp" maxlength="80" autocomplete="off">' +
'<div class="form-actions">' +
'<button type="button" class="btn-primary" data-action="create-workspace" data-account-id="' +
escapeAttr(account.id) +
'">Create workspace</button>' +
'<button type="button" class="btn-secondary" data-action="toggle-add-workspace" data-account-id="' +
escapeAttr(account.id) +
'">Cancel</button>' +
'<div class="spinner" id="ws-spinner-' +
escapeAttr(account.id) +
'" hidden></div>' +
'</div>' +
'</div>';
}
}
var workspaceHTML = workspaces.length
? '<div class="workspace-list">' + workspaces.map(function(workspace) {
return renderWorkspaceCard(account, workspace, accountAPIBasePath);
}).join('') + '</div>'
: '<div class="empty-state"><p>No hosted workspaces yet. Create one to get started.</p></div>';
return (
'<section class="account-section">' +
'<div class="account-header">' +
'<section class="account-surface">' +
'<div class="account-surface-header">' +
'<div class="account-heading">' +
'<div class="account-eyebrow">' + escapeHTML(accountKindLabel(account)) + '</div>' +
'<h2>' + escapeHTML(account.name) + '</h2>' +
@@ -399,33 +407,49 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
roleBadgeHTML(account.role) +
'</div>' +
'</div>' +
'<div class="account-status-grid">' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Active workspaces</span>' +
'<span class="account-stat-value">' + String(activeCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Healthy</span>' +
'<span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Checking</span>' +
'<span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Needs attention</span>' +
'<span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + '</span>' +
'<div class="account-surface-body">' +
'<aside class="account-overview-rail">' +
'<div class="account-overview-card">' +
'<div class="account-panel-kicker">Hosted posture</div>' +
'<h3>' + escapeHTML(accountKindLabel(account)) + '</h3>' +
'<p>' + escapeHTML(summaryText) + '</p>' +
'</div>' +
'<div class="account-status-grid">' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Active workspaces</span>' +
'<span class="account-stat-value">' + String(activeCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Healthy</span>' +
'<span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Checking</span>' +
'<span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span>' +
'</div>' +
'<div class="account-stat-card">' +
'<span class="account-stat-label">Needs attention</span>' +
'<span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + '</span>' +
'</div>' +
'</div>' +
actions +
addWorkspaceForm +
'</aside>' +
'<div class="account-main-stage">' +
'<div class="account-stage-header">' +
'<div>' +
'<div class="account-panel-kicker">Workspace fleet</div>' +
'<h3>Open hosted Pulse workspaces</h3>' +
'<p>Review fleet health, move into a workspace, and keep lifecycle actions explicit.</p>' +
'</div>' +
'</div>' +
workspaceHTML +
'<div class="account-management-grid">' +
workspaceManagement +
teamSection +
'</div>' +
'</div>' +
'</div>' +
actions +
'<div class="account-subsection-header">' +
'<h3>Workspace fleet</h3>' +
'<p>Open hosted Pulse workspaces, review fleet health, and manage lifecycle actions for this account.</p>' +
'</div>' +
workspaceHTML +
workspaceManagement +
teamSection +
addWorkspaceForm +
'</section>'
);
}
@@ -433,8 +457,10 @@ function renderAccountSection(account: PortalAccountSummary, accountAPIBasePath:
export function renderHeaderHTML(context: ShellViewContext): string {
if (context.bootstrap.authenticated) {
return (
'<span>' + escapeHTML(context.bootstrap.email || '') + '</span>' +
'<button class="logout-btn" id="logout-btn" type="button">Sign out</button>'
'<div class="header-account-chip">' +
'<span class="header-account-email">' + escapeHTML(context.bootstrap.email || '') + '</span>' +
'<button class="logout-btn" id="logout-btn" type="button">Sign out</button>' +
'</div>'
);
}
return '<a class="logout-btn link-button" href="' + escapeAttr(context.signupPath) + '">Create account</a>';
@@ -462,60 +488,84 @@ export function renderAccountsHTML(context: ShellViewContext): string {
export function renderAuthenticatedPortalHTML(context: ShellViewContext): string {
var accounts = Array.isArray(context.bootstrap.accounts) ? context.bootstrap.accounts : [];
var serviceHeading = hasHostedAccounts(accounts) ? 'Self-hosted licenses and billing' : 'Account services';
var serviceNote = hasHostedAccounts(accounts)
? 'Hosted access lives above. The self-hosted commercial tools remain available here for licenses, billing, refunds, and privacy actions.'
var hosted = hasHostedAccounts(accounts);
var serviceHeading = hosted ? 'Self-hosted licenses and billing' : 'Account services';
var serviceNote = hosted
? 'Hosted operations live above. Use these commercial tools for self-hosted licenses, billing, refunds, and privacy actions.'
: 'Use these account tools for self-hosted licenses, billing, refunds, and privacy actions.';
return (
renderOverviewBand(context, accounts) +
renderShellNavigation(accounts, context.bootstrap.support_email || '') +
'<section class="portal-top-section" id="hosted-operations-section">' +
'<div class="portal-top-section-header">' +
'<h2>' + (hasHostedAccounts(accounts) ? 'Hosted operations' : 'Hosted access') + '</h2>' +
'<p>' + (hasHostedAccounts(accounts)
? 'Use this area for workspace access, fleet operations, hosted billing, and team management.'
: 'This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.') + '</p>' +
'<div class="portal-shell">' +
renderOverviewBand(accounts) +
'<div class="portal-frame">' +
'<aside class="portal-rail">' +
renderShellNavigation(accounts, context.bootstrap.support_email || '') +
'<div class="portal-rail-panel">' +
'<div class="account-panel-kicker">Support</div>' +
'<h3>One account surface</h3>' +
'<p>This portal should be the place where hosted access and commercial account actions meet. Hosted workspaces sit above. Self-hosted services remain available below.</p>' +
'<a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || '') + '">' + escapeHTML(context.bootstrap.support_email || '') + '</a>' +
'</div>' +
'</aside>' +
'<div class="portal-stage">' +
'<section class="portal-top-section" id="hosted-operations-section">' +
'<div class="portal-top-section-header">' +
'<div class="account-panel-kicker">' + (hosted ? 'Hosted operations' : 'Hosted access') + '</div>' +
'<h2>' + (hosted ? 'Run hosted accounts from one place' : 'No hosted workspaces are attached yet') + '</h2>' +
'<p>' + (hosted
? 'Use this area for workspace access, fleet operations, hosted billing, and team management.'
: 'This account does not currently have hosted workspace access. If that is unexpected, contact support while using the commercial tools below.') + '</p>' +
'</div>' +
'<div id="accounts-root">' + renderAccountsHTML(context) + '</div>' +
'</section>' +
'<section class="service-section" id="account-services-section">' +
'<div class="service-header">' +
'<div>' +
'<div class="account-panel-kicker">Account services</div>' +
'<h2>' + serviceHeading + '</h2>' +
'</div>' +
'<div class="service-note">' + serviceNote + '</div>' +
'</div>' +
'<div class="service-grid">' +
'<button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email">' +
'<span class="service-card-kicker">Billing</span>' +
'<h3>Manage subscriptions</h3>' +
'<p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email">' +
'<span class="service-card-kicker">Licenses</span>' +
'<h3>Retrieve licenses</h3>' +
'<p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email">' +
'<span class="service-card-kicker">Refunds</span>' +
'<h3>Refund requests</h3>' +
'<p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email">' +
'<span class="service-card-kicker">Privacy</span>' +
'<h3>Data and privacy</h3>' +
'<p>Request commercial data export or deletion without leaving the account shell.</p>' +
'</button>' +
'</div>' +
'<div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div>' +
'<div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div>' +
'<div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div>' +
'<div class="service-panel" id="data-service-panel">' +
'<h3>Data and privacy</h3>' +
'<p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p>' +
'<div class="subsection"><div id="data-export-root"></div></div>' +
'<div class="subsection"><div id="data-delete-root"></div></div>' +
'<div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' +
escapeAttr(context.bootstrap.support_email || '') +
'">' +
escapeHTML(context.bootstrap.support_email || '') +
'</a>.</div>' +
'</div>' +
'</section>' +
'</div>' +
'</div>' +
'<div id="accounts-root">' + renderAccountsHTML(context) + '</div>' +
'</section>' +
'<section class="service-section" id="account-services-section">' +
'<div class="service-header">' +
'<h2>' + serviceHeading + '</h2>' +
'<div class="service-note">' + serviceNote + '</div>' +
'</div>' +
'<div class="service-grid">' +
'<button class="service-card service-card-button" type="button" id="open-manage-service" data-account-service-action="open-service-panel" data-account-service-panel="manage-service-panel" data-account-service-focus="manage-inline-email">' +
'<h3>Manage subscriptions</h3>' +
'<p>Open Stripe billing access for existing self-hosted subscriptions without leaving the Pulse Account shell.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-retrieve-service" data-account-service-action="open-service-panel" data-account-service-panel="retrieve-service-panel" data-account-service-focus="retrieve-inline-email">' +
'<h3>Retrieve licenses</h3>' +
'<p>Recover the latest active self-hosted license and invoice link for a commercial email address.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-refund-service" data-account-service-action="open-service-panel" data-account-service-panel="refund-service-panel" data-account-service-focus="refund-inline-email">' +
'<h3>Refund requests</h3>' +
'<p>Request an immediate self-serve refund for eligible self-hosted purchases with explicit revocation confirmation.</p>' +
'</button>' +
'<button class="service-card service-card-button" type="button" id="open-data-service" data-account-service-action="open-service-panel" data-account-service-panel="data-service-panel" data-account-service-focus="data-export-email">' +
'<h3>Data and privacy</h3>' +
'<p>Request commercial data export or deletion without leaving the account shell.</p>' +
'</button>' +
'</div>' +
'<div class="service-panel" id="manage-service-panel"><div id="manage-service-root"></div></div>' +
'<div class="service-panel" id="retrieve-service-panel"><div id="retrieve-service-root"></div></div>' +
'<div class="service-panel" id="refund-service-panel"><div id="refund-service-root"></div></div>' +
'<div class="service-panel" id="data-service-panel">' +
'<h3>Data and privacy</h3>' +
'<p>Request export or deletion of the commercial data tied to an email address. Payment data held directly by Stripe still requires support handling.</p>' +
'<div class="subsection"><div id="data-export-root"></div></div>' +
'<div class="subsection"><div id="data-delete-root"></div></div>' +
'<div class="helper-text">Payment-card data stays with Stripe. For Stripe deletion support, contact <a href="mailto:' +
escapeAttr(context.bootstrap.support_email || '') +
'">' +
escapeHTML(context.bootstrap.support_email || '') +
'</a>.</div>' +
'</div>' +
'</section>'
'</div>'
);
}
@@ -527,17 +577,18 @@ export function renderSignedOutPortalHTML(context: ShellViewContext): string {
var successMessage = context.loginState.successMessage || 'If that email is registered, a magic link is on the way.';
statusHTML =
'<div class="service-status visible success">' +
escapeHTML(successMessage) +
'<br><br><strong>Don\'t see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.' +
escapeHTML(successMessage) +
'<br><br><strong>Don\'t see it?</strong> <a href="#" data-portal-action="resend-magic-link">Send a new link</a>.' +
'</div>';
}
return (
'<section class="intro-card">' +
'<h1>Pulse Account</h1>' +
'<p>Sign in to manage Cloud workspaces, MSP access, and commercial account services from one account surface.</p>' +
'<div class="account-panel-kicker">Pulse Account</div>' +
'<h1>Sign in to the account surface</h1>' +
'<p>Use one commercial email address to get into hosted workspaces, MSP access, billing, license recovery, refunds, and privacy actions.</p>' +
'</section>' +
'<section class="service-section">' +
'<div class="service-panel visible">' +
'<section class="service-section service-section-auth">' +
'<div class="service-panel visible auth-panel">' +
'<h3>Sign in</h3>' +
'<p>Enter the commercial email address for your Pulse account. I will send a magic link so you can open Pulse Account without managing a password.</p>' +
'<div class="form-group">' +
File diff suppressed because it is too large Load Diff