style(portal): harden operator console shell

This commit is contained in:
rcourtman
2026-03-27 17:08:03 +00:00
parent 2d41e960a4
commit 1842603cc5
6 changed files with 314 additions and 154 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "087912d40011626d575748045a827f33b34abfd10a3a35ddf8f79ba776b2f549",
"source_hash": "02f5d00566a4d55ae7999d1121d77557d884ffcedd01d2a278a62f90d61be7a4",
"build_inputs": [
"package.json",
"tsconfig.json",
+123 -70
View File
@@ -1,15 +1,15 @@
/* src/styles.css */
:root {
color-scheme: light;
--bg: #f3f4f6;
--bg: #edf1f5;
--panel: #ffffff;
--panel-strong: #fbfbfc;
--panel-muted: #f6f7f9;
--ink: #17202a;
--ink-soft: #5a6675;
--ink-muted: #7d8895;
--line: #dde3ea;
--line-strong: #c5d0da;
--panel-strong: #f8fafc;
--panel-muted: #f3f6f9;
--ink: #17212b;
--ink-soft: #4d5b6a;
--ink-muted: #6d7784;
--line: #d6dfe8;
--line-strong: #bcc8d3;
--accent: #0b6a72;
--accent-strong: #084d53;
--accent-soft: #dceef0;
@@ -19,11 +19,15 @@
--danger-soft: #fdeaea;
--success: #19603c;
--success-soft: #e3f3e8;
--nav-bg: #111b24;
--nav-line: #203240;
--nav-copy: #8fa0b0;
--nav-ink: #edf4f8;
--shadow-sm: 0 1px 2px rgba(23, 32, 42, 0.06);
--shadow-md: 0 10px 24px rgba(23, 32, 42, 0.08);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-md: 10px;
--radius-lg: 16px;
}
* {
box-sizing: border-box;
@@ -35,14 +39,16 @@ body {
margin: 0;
min-height: 100vh;
font-family:
Inter,
"SF Pro Text",
"SF Pro Display",
"Segoe UI Variable Text",
"Segoe UI",
-apple-system,
BlinkMacSystemFont,
"Helvetica Neue",
Arial,
sans-serif;
background: #f3f4f6;
background: var(--bg);
color: var(--ink);
}
a {
@@ -56,9 +62,9 @@ header {
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 28px;
border-bottom: 1px solid rgba(197, 208, 218, 0.8);
background: rgba(248, 249, 251, 0.92);
padding: 14px 24px;
border-bottom: 1px solid rgba(197, 208, 218, 0.75);
background: rgba(247, 249, 252, 0.94);
backdrop-filter: blur(14px);
}
header .brand {
@@ -147,30 +153,30 @@ header .logout-btn:hover,
font-size: 13px;
}
.main {
max-width: 1360px;
max-width: 1480px;
margin: 0 auto;
padding: 28px 24px 56px;
padding: 22px 24px 56px;
}
.portal-shell {
display: flex;
flex-direction: column;
gap: 24px;
gap: 18px;
}
.portal-hero {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 0.95fr);
gap: 20px;
padding: 30px;
grid-template-columns: minmax(0, 1.8fr) minmax(360px, 0.92fr);
gap: 16px;
padding: 22px 24px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-md);
background: var(--panel);
box-shadow: var(--shadow-sm);
}
.portal-hero-copy {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 820px;
gap: 8px;
max-width: 900px;
}
.portal-hero-kicker,
.account-panel-kicker,
@@ -192,32 +198,32 @@ header .logout-btn:hover,
.portal-hero h1,
.intro-card h1 {
margin: 0;
font-size: clamp(1.85rem, 3.1vw, 2.9rem);
font-size: clamp(1.75rem, 2.6vw, 2.6rem);
line-height: 0.98;
letter-spacing: -0.045em;
}
.portal-hero p,
.intro-card p {
margin: 0;
font-size: 15px;
line-height: 1.6;
font-size: 14px;
line-height: 1.55;
color: var(--ink-soft);
}
.portal-hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
gap: 10px;
align-self: stretch;
}
.portal-hero-stat {
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 120px;
padding: 16px;
min-height: 92px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.92);
background: var(--panel-strong);
}
.portal-hero-stat-label {
font-size: 12px;
@@ -227,7 +233,7 @@ header .logout-btn:hover,
text-transform: uppercase;
}
.portal-hero-stat-value {
font-size: 24px;
font-size: 22px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.04em;
@@ -243,19 +249,45 @@ header .logout-btn:hover,
}
.portal-shell-layout {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 24px;
grid-template-columns: 240px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.portal-shell-nav {
position: sticky;
top: 88px;
border: 1px solid var(--line);
border: 1px solid var(--nav-line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
background: var(--nav-bg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.portal-shell-nav-header {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 18px 16px;
border-bottom: 1px solid var(--nav-line);
}
.portal-shell-nav-eyebrow {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #78b6c0;
}
.portal-shell-nav-title {
font-size: 20px;
line-height: 1.02;
letter-spacing: -0.03em;
font-weight: 800;
color: var(--nav-ink);
}
.portal-shell-nav-support {
font-size: 12px;
line-height: 1.45;
color: var(--nav-copy);
}
.portal-shell-nav-group {
display: flex;
flex-direction: column;
@@ -265,9 +297,9 @@ header .logout-btn:hover,
flex-direction: column;
gap: 6px;
width: 100%;
padding: 15px 16px;
padding: 14px 18px;
border: none;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--nav-line);
background: transparent;
text-align: left;
cursor: pointer;
@@ -277,21 +309,23 @@ header .logout-btn:hover,
border-bottom: none;
}
.portal-shell-nav-link:hover {
background: #f7fafc;
background: rgba(255, 255, 255, 0.04);
}
.portal-shell-nav-link.active {
background: #eef6f7;
background: rgba(120, 182, 192, 0.12);
box-shadow: inset 3px 0 0 #78b6c0;
}
.portal-shell-nav-label {
display: block;
font-size: 14px;
font-weight: 700;
color: var(--nav-ink);
}
.portal-shell-nav-copy {
display: block;
font-size: 12px;
line-height: 1.45;
color: var(--ink-soft);
color: var(--nav-copy);
}
.portal-shell-main {
min-width: 0;
@@ -339,7 +373,7 @@ header .logout-btn:hover,
.portal-top-section-header h2,
.service-header h2 {
margin: 6px 0 0;
font-size: clamp(1.7rem, 2.2vw, 2.35rem);
font-size: clamp(1.55rem, 2vw, 2.15rem);
line-height: 1.02;
letter-spacing: -0.05em;
}
@@ -360,7 +394,7 @@ header .logout-btn:hover,
}
.account-surface {
border-radius: var(--radius-lg);
padding: 26px;
padding: 22px;
}
.account-surface + .account-surface {
margin-top: 18px;
@@ -371,7 +405,7 @@ header .logout-btn:hover,
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(203, 183, 153, 0.6);
border-bottom: 1px solid var(--line);
}
.account-heading h2 {
margin: 4px 0 0;
@@ -427,7 +461,7 @@ header .logout-btn:hover,
.service-panel,
.team-roster,
.team-invite-panel {
padding: 18px;
padding: 16px;
}
.account-overview-card h3,
.account-stage-header h3,
@@ -456,8 +490,8 @@ header .logout-btn:hover,
}
.account-command-deck {
display: grid;
grid-template-columns: minmax(280px, 1.05fr) minmax(0, 1.35fr);
gap: 14px;
grid-template-columns: minmax(320px, 1.08fr) minmax(0, 1.3fr);
gap: 12px;
align-items: stretch;
}
.account-metric-strip {
@@ -503,7 +537,7 @@ header .logout-btn:hover,
justify-content: space-between;
gap: 18px;
grid-column: 1 / -1;
padding: 16px 18px 0;
padding: 14px 16px 0;
border-top: 1px solid var(--line);
}
.account-action-copy {
@@ -526,7 +560,7 @@ header .logout-btn:hover,
.portal-support-panel {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 26px;
padding: 22px;
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-sm);
}
@@ -572,19 +606,19 @@ header .logout-btn:hover,
.workspace-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.workspace-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
gap: 14px;
align-items: center;
padding: 20px;
padding: 16px;
}
.workspace-row-main {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.85fr);
gap: 18px;
grid-template-columns: minmax(0, 1.55fr) minmax(220px, 0.8fr);
gap: 14px;
align-items: center;
}
.workspace-row-title {
@@ -601,7 +635,7 @@ header .logout-btn:hover,
.workspace-management-content h4,
.team-panel-heading h4 {
margin: 0;
font-size: 18px;
font-size: 17px;
line-height: 1.1;
letter-spacing: -0.025em;
}
@@ -636,8 +670,8 @@ header .logout-btn:hover,
display: flex;
flex-direction: column;
gap: 8px;
min-height: 74px;
padding: 14px;
min-height: 68px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--panel-muted);
@@ -759,7 +793,7 @@ header .logout-btn:hover,
}
.team-management-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.95fr);
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.92fr);
gap: 14px;
}
.team-panel-heading {
@@ -771,11 +805,11 @@ header .logout-btn:hover,
gap: 10px;
}
.team-member-row {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px auto;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-muted);
@@ -798,9 +832,17 @@ header .logout-btn:hover,
.team-member-controls {
display: flex;
align-items: center;
justify-content: end;
gap: 10px;
flex-wrap: wrap;
}
.team-member-role-summary {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
}
.team-role-badge {
display: inline-flex;
align-items: center;
@@ -846,13 +888,13 @@ header .logout-btn:hover,
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
gap: 12px;
}
.service-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px;
padding: 16px;
}
.service-card-button {
width: 100%;
@@ -862,7 +904,7 @@ header .logout-btn:hover,
.service-card:hover,
.service-card-button:hover {
border-color: var(--accent);
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-sm);
}
.service-panel {
padding: 22px;
@@ -943,6 +985,14 @@ header .logout-btn:hover,
.support-copy {
margin-top: 10px !important;
}
.account-overview-callout {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
font-size: 13px;
line-height: 1.65;
color: var(--ink-soft);
}
.badge {
display: inline-flex;
align-items: center;
@@ -1044,7 +1094,7 @@ header .logout-btn:hover,
@media (max-width: 920px) {
.portal-hero {
grid-template-columns: 1fr;
padding: 28px;
padding: 20px;
}
.portal-hero-stats {
grid-template-columns: 1fr;
@@ -1060,12 +1110,12 @@ header .logout-btn:hover,
}
@media (max-width: 720px) {
header {
padding: 16px 18px;
padding: 14px 16px;
align-items: flex-start;
flex-direction: column;
}
.main {
padding: 18px 16px 40px;
padding: 18px 14px 40px;
}
.portal-shell-layout,
.portal-support-card-grid,
@@ -1076,7 +1126,7 @@ header .logout-btn:hover,
.portal-hero,
.intro-card,
.service-panel {
padding: 20px;
padding: 18px;
}
.account-surface-header,
.workspace-management-header,
@@ -1097,9 +1147,12 @@ header .logout-btn:hover,
}
.team-member-row,
.team-member-controls {
flex-direction: column;
grid-template-columns: 1fr;
align-items: stretch;
}
.team-member-controls {
justify-content: start;
}
.workspace-row-status-grid {
grid-template-columns: 1fr;
}
+27 -7
View File
@@ -1,5 +1,19 @@
(() => {
// src/account_view.ts
function roleLabel(role) {
switch (role) {
case "owner":
return "Owner";
case "admin":
return "Admin";
case "tech":
return "Tech";
case "read_only":
return "Read-only";
default:
return role || "Member";
}
}
function getElement(id) {
return document.getElementById(id);
}
@@ -104,7 +118,7 @@
if (member.role === "owner" && !isOwner) {
var locked = document.createElement("span");
locked.className = "team-role-badge";
locked.textContent = "Owner";
locked.textContent = roleLabel(member.role);
return locked;
}
var sel = document.createElement("select");
@@ -113,7 +127,7 @@
for (var j = 0; j < roles.length; j += 1) {
var opt = document.createElement("option");
opt.value = roles[j];
opt.textContent = roles[j].replace("_", " ");
opt.textContent = roleLabel(roles[j]);
if (member.role === roles[j]) opt.selected = true;
sel.appendChild(opt);
}
@@ -147,14 +161,18 @@
identity.appendChild(email);
var caption = document.createElement("div");
caption.className = "team-member-caption";
caption.textContent = member.role === "owner" ? "Full account owner access" : "Hosted account operator";
caption.textContent = member.role === "owner" ? "Full account control" : "Hosted account access";
identity.appendChild(caption);
var roleSummary = document.createElement("div");
roleSummary.className = "team-member-role-summary";
roleSummary.textContent = roleLabel(member.role);
var controls = document.createElement("div");
controls.className = "team-member-controls";
controls.appendChild(renderTeamRoleControl(accountID, member, isOwner));
var action = renderTeamMemberAction(accountID, member, isOwner);
if (action) controls.appendChild(action);
row.appendChild(identity);
row.appendChild(roleSummary);
row.appendChild(controls);
return row;
}
@@ -1638,7 +1656,7 @@
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." : "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.";
var summary = hosted ? "Run hosted operations, operator access, and commercial account services from one control surface." : "Use this account for 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 shellSectionButton(section, activeSection, title, copy) {
@@ -1646,7 +1664,7 @@
}
function renderShellNavigation(accounts, supportEmail, activeSection) {
var hosted = hasHostedAccounts(accounts);
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "Overview", hosted ? "Posture, accounts, and quick actions" : "Account summary and access state") + shellSectionButton("workspaces", activeSection, hosted ? "Workspaces" : "Hosted access", hosted ? "Hosted fleet and lifecycle actions" : "No hosted workspaces are attached yet") + shellSectionButton("team", activeSection, "Team", hosted ? "Access and operator roster" : "Account membership") + shellSectionButton("services", activeSection, "Account services", "Licenses, billing, refunds, and privacy") + shellSectionButton("support", activeSection, "Support", supportEmail || "Support contact") + "</div></aside>";
return '<aside class="portal-shell-nav" aria-label="Pulse Account sections"><div class="portal-shell-nav-header"><div class="portal-shell-nav-eyebrow">Pulse Account</div><div class="portal-shell-nav-title">' + (hosted ? "Operator console" : "Account console") + '</div><div class="portal-shell-nav-support">' + escapeHTML(supportEmail || "support@pulserelay.pro") + '</div></div><div class="portal-shell-nav-group">' + shellSectionButton("overview", activeSection, "Overview", hosted ? "Posture, accounts, and quick actions" : "Account summary and access state") + shellSectionButton("workspaces", activeSection, hosted ? "Workspaces" : "Hosted access", hosted ? "Hosted fleet and lifecycle actions" : "No hosted workspaces are attached yet") + shellSectionButton("team", activeSection, "Team", hosted ? "Access and operator roster" : "Account membership") + shellSectionButton("services", activeSection, "Account services", "Licenses, billing, refunds, and privacy") + shellSectionButton("support", activeSection, "Support", supportEmail || "Support contact") + "</div></aside>";
}
function renderWorkspaceCard(account, workspace, accountAPIBasePath) {
var status = workspaceHealthState(workspace);
@@ -1689,7 +1707,9 @@
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-content-panel account-content-panel-overview"><div class="account-command-deck"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(account.name) + "</h3><p>" + escapeHTML(summaryText) + '</p></div><div class="account-metric-strip"><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + "</div></section>";
return '<section class="account-content-panel account-content-panel-overview"><div class="account-command-deck"><div class="account-overview-card"><div class="account-panel-kicker">Hosted posture</div><h3>' + escapeHTML(account.name) + "</h3><p>" + escapeHTML(summaryText) + '</p><div class="account-overview-callout">' + escapeHTML(
account.kind === "msp" ? "This is the control surface for hosted clients, account billing, and operator access." : "This is the control surface for hosted workspaces, billing, and account access."
) + '</div></div><div class="account-metric-strip"><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Active workspaces</span><span class="account-stat-value">' + String(activeCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Healthy</span><span class="account-stat-value account-stat-healthy">' + String(healthyCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Checking</span><span class="account-stat-value account-stat-checking">' + String(checkingCount) + '</span></div><div class="account-stat-card account-stat-card-inline"><span class="account-stat-label">Needs attention</span><span class="account-stat-value account-stat-unhealthy">' + String(unhealthyCount) + "</span></div></div>" + actions + addWorkspaceForm + "</div></section>";
}
function renderAccountWorkspaceSection(account, accountAPIBasePath) {
var workspaces = Array.isArray(account.workspaces) ? account.workspaces : [];
@@ -1706,7 +1726,7 @@
return '<section class="account-content-panel account-content-panel-team"><section class="team-management-panel team-section team-section-shell" 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-shell-action="activate-section" data-shell-section="workspaces">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><div class="team-roster-list" id="team-list-' + escapeAttr(account.id) + '"><div class="team-list-message">Loading\u2026</div></div></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></section>';
}
function renderSupportSection(context) {
return '<section class="portal-support-panel"><div class="account-panel-kicker">Support</div><h2>Get help without leaving Pulse Account</h2><p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p><div class="portal-support-card-grid"><div class="portal-support-card"><h3>Account support</h3><p>For access, tenant handoff, team, and billing issues, contact the hosted operations desk.</p><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div><div class="portal-support-card"><h3>Commercial services</h3><p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the same account surface.</p><button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button></div></div></section>';
return '<section class="portal-support-panel"><div class="account-panel-kicker">Support</div><h2>Support and escalation</h2><p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p><div class="portal-support-card-grid"><div class="portal-support-card"><h3>Account support</h3><p>For access, tenant handoff, team, and billing issues, contact the hosted operations desk.</p><a class="portal-support-link" href="mailto:' + escapeAttr(context.bootstrap.support_email || "") + '">' + escapeHTML(context.bootstrap.support_email || "") + '</a></div><div class="portal-support-card"><h3>Commercial services</h3><p>Self-hosted subscriptions, license recovery, refunds, and privacy requests all route through the same account surface.</p><button type="button" class="btn-secondary" data-shell-action="activate-section" data-shell-section="services">Open account services</button></div></div></section>';
}
function renderHeaderHTML(context) {
if (context.bootstrap.authenticated) {
@@ -8,6 +8,21 @@ import type {
type FormValueElement = HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement;
function roleLabel(role: string): string {
switch (role) {
case 'owner':
return 'Owner';
case 'admin':
return 'Admin';
case 'tech':
return 'Tech';
case 'read_only':
return 'Read-only';
default:
return role || 'Member';
}
}
export function getElement<T extends HTMLElement = HTMLElement>(id: string): T | null {
return document.getElementById(id) as T | null;
}
@@ -131,7 +146,7 @@ function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOw
if (member.role === 'owner' && !isOwner) {
var locked = document.createElement('span');
locked.className = 'team-role-badge';
locked.textContent = 'Owner';
locked.textContent = roleLabel(member.role);
return locked;
}
@@ -141,7 +156,7 @@ function renderTeamRoleControl(accountID: string, member: PortalTeamMember, isOw
for (var j = 0; j < roles.length; j += 1) {
var opt = document.createElement('option');
opt.value = roles[j];
opt.textContent = roles[j].replace('_', ' ');
opt.textContent = roleLabel(roles[j]);
if (member.role === roles[j]) opt.selected = true;
sel.appendChild(opt);
}
@@ -181,9 +196,13 @@ function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwne
var caption = document.createElement('div');
caption.className = 'team-member-caption';
caption.textContent = member.role === 'owner' ? 'Full account owner access' : 'Hosted account operator';
caption.textContent = member.role === 'owner' ? 'Full account control' : 'Hosted account access';
identity.appendChild(caption);
var roleSummary = document.createElement('div');
roleSummary.className = 'team-member-role-summary';
roleSummary.textContent = roleLabel(member.role);
var controls = document.createElement('div');
controls.className = 'team-member-controls';
controls.appendChild(renderTeamRoleControl(accountID, member, isOwner));
@@ -191,6 +210,7 @@ function renderTeamMemberRow(accountID: string, member: PortalTeamMember, isOwne
if (action) controls.appendChild(action);
row.appendChild(identity);
row.appendChild(roleSummary);
row.appendChild(controls);
return row;
}
@@ -114,8 +114,8 @@ function renderOverviewBand(accounts: PortalAccountSummary[]): string {
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.'
: '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.';
? 'Run hosted operations, operator access, and commercial account services from one control surface.'
: 'Use this account for 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">' +
@@ -155,6 +155,11 @@ function renderShellNavigation(accounts: PortalAccountSummary[], supportEmail: s
var hosted = hasHostedAccounts(accounts);
return (
'<aside class="portal-shell-nav" aria-label="Pulse Account sections">' +
'<div class="portal-shell-nav-header">' +
'<div class="portal-shell-nav-eyebrow">Pulse Account</div>' +
'<div class="portal-shell-nav-title">' + (hosted ? 'Operator console' : 'Account console') + '</div>' +
'<div class="portal-shell-nav-support">' + escapeHTML(supportEmail || 'support@pulserelay.pro') + '</div>' +
'</div>' +
'<div class="portal-shell-nav-group">' +
shellSectionButton('overview', activeSection, 'Overview', hosted ? 'Posture, accounts, and quick actions' : 'Account summary and access state') +
shellSectionButton('workspaces', activeSection, hosted ? 'Workspaces' : 'Hosted access', hosted ? 'Hosted fleet and lifecycle actions' : 'No hosted workspaces are attached yet') +
@@ -305,6 +310,10 @@ function renderAccountOverviewSection(account: PortalAccountSummary): string {
'<div class="account-panel-kicker">Hosted posture</div>' +
'<h3>' + escapeHTML(account.name) + '</h3>' +
'<p>' + escapeHTML(summaryText) + '</p>' +
'<div class="account-overview-callout">' + escapeHTML(account.kind === 'msp'
? 'This is the control surface for hosted clients, account billing, and operator access.'
: 'This is the control surface for hosted workspaces, billing, and account access.'
) + '</div>' +
'</div>' +
'<div class="account-metric-strip">' +
'<div class="account-stat-card account-stat-card-inline">' +
@@ -463,7 +472,7 @@ function renderSupportSection(context: ShellViewContext): string {
return (
'<section class="portal-support-panel">' +
'<div class="account-panel-kicker">Support</div>' +
'<h2>Get help without leaving Pulse Account</h2>' +
'<h2>Support and escalation</h2>' +
'<p>Use support when hosted access looks wrong, billing does not behave as expected, or you need help with commercial licensing and privacy actions.</p>' +
'<div class="portal-support-card-grid">' +
'<div class="portal-support-card">' +
+128 -70
View File
@@ -1,14 +1,14 @@
:root {
color-scheme: light;
--bg: #f3f4f6;
--bg: #edf1f5;
--panel: #ffffff;
--panel-strong: #fbfbfc;
--panel-muted: #f6f7f9;
--ink: #17202a;
--ink-soft: #5a6675;
--ink-muted: #7d8895;
--line: #dde3ea;
--line-strong: #c5d0da;
--panel-strong: #f8fafc;
--panel-muted: #f3f6f9;
--ink: #17212b;
--ink-soft: #4d5b6a;
--ink-muted: #6d7784;
--line: #d6dfe8;
--line-strong: #bcc8d3;
--accent: #0b6a72;
--accent-strong: #084d53;
--accent-soft: #dceef0;
@@ -18,11 +18,15 @@
--danger-soft: #fdeaea;
--success: #19603c;
--success-soft: #e3f3e8;
--nav-bg: #111b24;
--nav-line: #203240;
--nav-copy: #8fa0b0;
--nav-ink: #edf4f8;
--shadow-sm: 0 1px 2px rgba(23, 32, 42, 0.06);
--shadow-md: 0 10px 24px rgba(23, 32, 42, 0.08);
--radius-sm: 6px;
--radius-md: 12px;
--radius-lg: 18px;
--radius-md: 10px;
--radius-lg: 16px;
}
* { box-sizing: border-box; }
@@ -34,8 +38,8 @@ html {
body {
margin: 0;
min-height: 100vh;
font-family: Inter, "Segoe UI Variable Text", "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", sans-serif;
background: #f3f4f6;
font-family: "SF Pro Text", "SF Pro Display", "Segoe UI Variable Text", "Segoe UI", -apple-system, BlinkMacSystemFont, "Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--ink);
}
@@ -49,9 +53,9 @@ header {
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 18px 28px;
border-bottom: 1px solid rgba(197, 208, 218, 0.8);
background: rgba(248, 249, 251, 0.92);
padding: 14px 24px;
border-bottom: 1px solid rgba(197, 208, 218, 0.75);
background: rgba(247, 249, 252, 0.94);
backdrop-filter: blur(14px);
}
@@ -145,33 +149,33 @@ header .logout-btn:hover,
}
.main {
max-width: 1360px;
max-width: 1480px;
margin: 0 auto;
padding: 28px 24px 56px;
padding: 22px 24px 56px;
}
.portal-shell {
display: flex;
flex-direction: column;
gap: 24px;
gap: 18px;
}
.portal-hero {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(300px, 0.95fr);
gap: 20px;
padding: 30px;
grid-template-columns: minmax(0, 1.8fr) minmax(360px, 0.92fr);
gap: 16px;
padding: 22px 24px;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-md);
background: var(--panel);
box-shadow: var(--shadow-sm);
}
.portal-hero-copy {
display: flex;
flex-direction: column;
gap: 10px;
max-width: 820px;
gap: 8px;
max-width: 900px;
}
.portal-hero-kicker,
@@ -197,7 +201,7 @@ header .logout-btn:hover,
.portal-hero h1,
.intro-card h1 {
margin: 0;
font-size: clamp(1.85rem, 3.1vw, 2.9rem);
font-size: clamp(1.75rem, 2.6vw, 2.6rem);
line-height: 0.98;
letter-spacing: -0.045em;
}
@@ -205,15 +209,15 @@ header .logout-btn:hover,
.portal-hero p,
.intro-card p {
margin: 0;
font-size: 15px;
line-height: 1.6;
font-size: 14px;
line-height: 1.55;
color: var(--ink-soft);
}
.portal-hero-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
gap: 10px;
align-self: stretch;
}
@@ -221,11 +225,11 @@ header .logout-btn:hover,
display: flex;
flex-direction: column;
justify-content: space-between;
min-height: 120px;
padding: 16px;
min-height: 92px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.92);
background: var(--panel-strong);
}
.portal-hero-stat-label {
@@ -237,7 +241,7 @@ header .logout-btn:hover,
}
.portal-hero-stat-value {
font-size: 24px;
font-size: 22px;
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.04em;
@@ -255,21 +259,51 @@ header .logout-btn:hover,
.portal-shell-layout {
display: grid;
grid-template-columns: 248px minmax(0, 1fr);
gap: 24px;
grid-template-columns: 240px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.portal-shell-nav {
position: sticky;
top: 88px;
border: 1px solid var(--line);
border: 1px solid var(--nav-line);
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.98);
background: var(--nav-bg);
box-shadow: var(--shadow-sm);
overflow: hidden;
}
.portal-shell-nav-header {
display: flex;
flex-direction: column;
gap: 6px;
padding: 18px 18px 16px;
border-bottom: 1px solid var(--nav-line);
}
.portal-shell-nav-eyebrow {
font-size: 11px;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
color: #78b6c0;
}
.portal-shell-nav-title {
font-size: 20px;
line-height: 1.02;
letter-spacing: -0.03em;
font-weight: 800;
color: var(--nav-ink);
}
.portal-shell-nav-support {
font-size: 12px;
line-height: 1.45;
color: var(--nav-copy);
}
.portal-shell-nav-group {
display: flex;
flex-direction: column;
@@ -280,9 +314,9 @@ header .logout-btn:hover,
flex-direction: column;
gap: 6px;
width: 100%;
padding: 15px 16px;
padding: 14px 18px;
border: none;
border-bottom: 1px solid var(--line);
border-bottom: 1px solid var(--nav-line);
background: transparent;
text-align: left;
cursor: pointer;
@@ -294,24 +328,26 @@ header .logout-btn:hover,
}
.portal-shell-nav-link:hover {
background: #f7fafc;
background: rgba(255, 255, 255, 0.04);
}
.portal-shell-nav-link.active {
background: #eef6f7;
background: rgba(120, 182, 192, 0.12);
box-shadow: inset 3px 0 0 #78b6c0;
}
.portal-shell-nav-label {
display: block;
font-size: 14px;
font-weight: 700;
color: var(--nav-ink);
}
.portal-shell-nav-copy {
display: block;
font-size: 12px;
line-height: 1.45;
color: var(--ink-soft);
color: var(--nav-copy);
}
.portal-shell-main {
@@ -368,7 +404,7 @@ header .logout-btn:hover,
.portal-top-section-header h2,
.service-header h2 {
margin: 6px 0 0;
font-size: clamp(1.7rem, 2.2vw, 2.35rem);
font-size: clamp(1.55rem, 2vw, 2.15rem);
line-height: 1.02;
letter-spacing: -0.05em;
}
@@ -392,7 +428,7 @@ header .logout-btn:hover,
.account-surface {
border-radius: var(--radius-lg);
padding: 26px;
padding: 22px;
}
.account-surface + .account-surface {
@@ -405,7 +441,7 @@ header .logout-btn:hover,
justify-content: space-between;
gap: 16px;
padding-bottom: 18px;
border-bottom: 1px solid rgba(203, 183, 153, 0.6);
border-bottom: 1px solid var(--line);
}
.account-heading h2 {
@@ -468,7 +504,7 @@ header .logout-btn:hover,
.service-panel,
.team-roster,
.team-invite-panel {
padding: 18px;
padding: 16px;
}
.account-overview-card h3,
@@ -500,8 +536,8 @@ header .logout-btn:hover,
.account-command-deck {
display: grid;
grid-template-columns: minmax(280px, 1.05fr) minmax(0, 1.35fr);
gap: 14px;
grid-template-columns: minmax(320px, 1.08fr) minmax(0, 1.3fr);
gap: 12px;
align-items: stretch;
}
@@ -547,7 +583,7 @@ header .logout-btn:hover,
justify-content: space-between;
gap: 18px;
grid-column: 1 / -1;
padding: 16px 18px 0;
padding: 14px 16px 0;
border-top: 1px solid var(--line);
}
@@ -575,7 +611,7 @@ header .logout-btn:hover,
.portal-support-panel {
border: 1px solid var(--line);
border-radius: var(--radius-lg);
padding: 26px;
padding: 22px;
background: rgba(255, 255, 255, 0.98);
box-shadow: var(--shadow-sm);
}
@@ -629,21 +665,21 @@ header .logout-btn:hover,
.workspace-list {
display: flex;
flex-direction: column;
gap: 12px;
gap: 10px;
}
.workspace-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 16px;
gap: 14px;
align-items: center;
padding: 20px;
padding: 16px;
}
.workspace-row-main {
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(220px, 0.85fr);
gap: 18px;
grid-template-columns: minmax(0, 1.55fr) minmax(220px, 0.8fr);
gap: 14px;
align-items: center;
}
@@ -663,7 +699,7 @@ header .logout-btn:hover,
.workspace-management-content h4,
.team-panel-heading h4 {
margin: 0;
font-size: 18px;
font-size: 17px;
line-height: 1.1;
letter-spacing: -0.025em;
}
@@ -703,8 +739,8 @@ header .logout-btn:hover,
display: flex;
flex-direction: column;
gap: 8px;
min-height: 74px;
padding: 14px;
min-height: 68px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--panel-muted);
@@ -846,7 +882,7 @@ header .logout-btn:hover,
.team-management-grid {
display: grid;
grid-template-columns: minmax(0, 1.6fr) minmax(320px, 0.95fr);
grid-template-columns: minmax(0, 1.65fr) minmax(320px, 0.92fr);
gap: 14px;
}
@@ -861,11 +897,11 @@ header .logout-btn:hover,
}
.team-member-row {
display: flex;
display: grid;
grid-template-columns: minmax(0, 1fr) 120px auto;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 14px 16px;
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--panel-muted);
@@ -892,10 +928,19 @@ header .logout-btn:hover,
.team-member-controls {
display: flex;
align-items: center;
justify-content: end;
gap: 10px;
flex-wrap: wrap;
}
.team-member-role-summary {
font-size: 12px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-muted);
}
.team-role-badge {
display: inline-flex;
align-items: center;
@@ -948,14 +993,14 @@ header .logout-btn:hover,
.service-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 14px;
gap: 12px;
}
.service-card {
display: flex;
flex-direction: column;
gap: 8px;
padding: 18px;
padding: 16px;
}
.service-card-button {
@@ -967,7 +1012,7 @@ header .logout-btn:hover,
.service-card:hover,
.service-card-button:hover {
border-color: var(--accent);
box-shadow: var(--shadow-md);
box-shadow: var(--shadow-sm);
}
.service-panel {
@@ -1068,6 +1113,15 @@ header .logout-btn:hover,
margin-top: 10px !important;
}
.account-overview-callout {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--line);
font-size: 13px;
line-height: 1.65;
color: var(--ink-soft);
}
.badge {
display: inline-flex;
align-items: center;
@@ -1178,7 +1232,7 @@ header .logout-btn:hover,
@media (max-width: 920px) {
.portal-hero {
grid-template-columns: 1fr;
padding: 28px;
padding: 20px;
}
.portal-hero-stats {
@@ -1198,13 +1252,13 @@ header .logout-btn:hover,
@media (max-width: 720px) {
header {
padding: 16px 18px;
padding: 14px 16px;
align-items: flex-start;
flex-direction: column;
}
.main {
padding: 18px 16px 40px;
padding: 18px 14px 40px;
}
.portal-shell-layout,
@@ -1217,7 +1271,7 @@ header .logout-btn:hover,
.portal-hero,
.intro-card,
.service-panel {
padding: 20px;
padding: 18px;
}
.account-surface-header,
@@ -1242,10 +1296,14 @@ header .logout-btn:hover,
.team-member-row,
.team-member-controls {
flex-direction: column;
grid-template-columns: 1fr;
align-items: stretch;
}
.team-member-controls {
justify-content: start;
}
.workspace-row-status-grid {
grid-template-columns: 1fr;
}