Make access task-driven

This commit is contained in:
rcourtman
2026-03-28 18:08:06 +00:00
parent 8020306ca8
commit 8eea329ef6
17 changed files with 685 additions and 167 deletions
@@ -181,6 +181,9 @@ Core rules:
24. `Workspaces` must default to the workspace list and task entry points, not
an idle lifecycle explainer. The lifecycle rail should appear only when a
lifecycle or create-workspace job is actually active.
25. `Access` must default to the hosted roster plus explicit job entry points,
not a permanently open mutation rail. Invite, role-change, and remove
controls should appear only when that exact access job is active.
## Screen Model
@@ -280,7 +280,10 @@ create-workspace form, the runtime must reveal the opened surface instead of
leaving the user at the top of the list. The same shared runtime contract must
also keep the workspace detail rail absent until a lifecycle or
create-workspace job is active, rather than rendering a default idle
lifecycle explainer before the user has picked a task.
lifecycle explainer before the user has picked a task. The same task-first
runtime rule now also applies to `Access`: the hosted roster is the default
surface, and invite, role-change, or remove controls only appear when the
matching access job is active.
plus a package-local `tsc --noEmit` gate, so future account-shell work should
extend the typed source boundary instead of reviving opaque global runtime
objects, document-wide render events, or untyped embedded asset edits.
@@ -835,6 +835,9 @@ workspace job surfaces such as lifecycle review or create-workspace forms must
be revealed when the user opens them. `Workspaces` must also default to the
workspace list plus the real task entry points rather than an idle lifecycle
essay; the lifecycle rail should appear only when a lifecycle or
create-workspace job is active. Until that candidate lane lands, new
create-workspace job is active. `Access` follows the same rule: the hosted
roster must be the default state, while invite, role-change, and remove
controls appear only when that exact access job is active. Until that
candidate lane lands, new
commercial account work must extend the governed Pulse account shape rather
than spawning additional one-off recovery or billing pages.
+1 -1
View File
@@ -1,5 +1,5 @@
{
"source_hash": "136eb40c8f56e03e067c9eca7fb20713dbecf8ee60efa8f47b318bb7954ac641",
"source_hash": "32735365beff496cbfb64b485b1a89d40dcd459e036a09ead2bfe337370c6dda",
"build_inputs": [
"package.json",
"tsconfig.json",
+77 -15
View File
@@ -1906,12 +1906,49 @@ header .logout-btn:hover,
margin-top: 6px;
font-size: 22px;
}
.access-management-grid {
.access-task-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink-muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.access-task-button.is-active {
border-color: rgba(15, 109, 115, 0.22);
background: rgba(15, 109, 115, 0.08);
color: var(--accent-strong);
}
.access-shell {
display: grid;
grid-template-columns: minmax(0, 1.82fr) minmax(292px, 0.84fr);
gap: 14px;
align-items: start;
}
.access-shell-main,
.access-shell-detail {
min-width: 0;
}
.access-shell-detail {
position: sticky;
top: 14px;
}
.access-shell-idle {
grid-template-columns: 1fr;
}
.access-roster-column {
display: flex;
flex-direction: column;
@@ -1921,16 +1958,10 @@ header .logout-btn:hover,
.access-management-panel {
box-shadow: var(--shadow-sm);
}
.access-side-column {
display: flex;
flex-direction: column;
align-self: start;
position: sticky;
top: 14px;
}
.access-roster,
.access-invite-panel,
.access-operations-panel {
.access-task-panel,
.access-job-note-panel {
background: #fff;
border-color: var(--line);
}
@@ -1946,7 +1977,8 @@ header .logout-btn:hover,
box-shadow: none;
padding: 0;
}
.access-operations-panel {
.access-task-panel,
.access-job-note-panel {
display: flex;
flex-direction: column;
gap: 12px;
@@ -1954,11 +1986,15 @@ header .logout-btn:hover,
border: 1px solid var(--line);
border-radius: 12px;
}
.access-operations-grid {
.access-task-header,
.access-task-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.access-task-header {
gap: 12px;
}
.access-policy-panel {
border: 1px solid var(--line);
border-radius: 12px;
@@ -2229,6 +2265,28 @@ header .logout-btn:hover,
line-height: 1.4;
color: var(--ink-soft);
}
.access-remove-points {
display: flex;
flex-direction: column;
gap: 10px;
}
.access-remove-point {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
border-left: 3px solid rgba(11, 106, 114, 0.14);
}
.access-remove-point strong {
font-size: 13px;
line-height: 1.3;
color: var(--ink);
}
.access-remove-point span {
font-size: 12px;
line-height: 1.45;
color: var(--ink-soft);
}
.billing-action-list {
display: grid;
grid-template-columns: 1fr;
@@ -2756,7 +2814,7 @@ header .logout-btn:hover,
}
@media (max-width: 1180px) {
.account-management-grid,
.access-management-grid,
.access-shell,
.access-review-grid,
.account-overview-secondary,
.overview-task-grid,
@@ -2793,9 +2851,6 @@ header .logout-btn:hover,
.workspace-management-facts {
grid-template-columns: 1fr;
}
.access-side-column {
position: static;
}
.workspace-list-head {
display: none;
}
@@ -2972,6 +3027,12 @@ header .logout-btn:hover,
.workspace-operations-shell-form-open .workspace-operations-main {
order: 2;
}
.access-shell-job-open .access-shell-detail {
order: 1;
}
.access-shell-job-open .access-shell-main {
order: 2;
}
.workspace-management-header,
.access-management-header,
.billing-header,
@@ -3004,6 +3065,7 @@ header .logout-btn:hover,
.billing-shell,
.billing-empty-shell,
.billing-empty-intro,
.access-shell,
.workspace-operations-shell,
.overview-task-grid,
.account-overview-card,
+129 -42
View File
@@ -180,6 +180,30 @@
}
return count;
}
function accessJobTitle(job) {
switch (job) {
case "invite":
return "Invite people";
case "change_role":
return "Change roles";
case "remove":
return "Remove access";
default:
return "";
}
}
function accessJobCopy(job) {
switch (job) {
case "invite":
return "Add one person with the minimum role they need on this account.";
case "change_role":
return "Use the roster to change one person at a time and keep each person on the smallest role they need.";
case "remove":
return "Use removal only when this person should no longer be on this hosted account.";
default:
return "";
}
}
function renderAccessStats(accountID, entry, canManage) {
var stats = getElement("access-stats-" + accountID);
if (!stats) return;
@@ -203,10 +227,10 @@
cell.className = "access-control-cell " + className;
return cell;
}
function renderAccessRoleControl(accountID, member, isOwner, canManage) {
function renderAccessRoleControl(accountID, member, isOwner, canManage, activeJob) {
var currentRole = normalizedAccessRole(member.role);
var group = createAccessControlCell("access-control-cell-role");
if (!canManage) {
if (!canManage || activeJob !== "change_role") {
var badge = document.createElement("span");
badge.className = "access-role-badge";
badge.textContent = roleLabel(currentRole);
@@ -236,22 +260,28 @@
group.appendChild(sel);
return group;
}
function renderAccessMemberAction(accountID, member, isOwner, canManage) {
function renderAccessMemberAction(accountID, member, isOwner, canManage, activeJob) {
var group = createAccessControlCell("access-control-cell-access");
if (!canManage) {
var readonly = createAccessControlCell("access-control-cell-access");
var readonlyText = document.createElement("span");
readonlyText.className = "access-control-locked";
readonlyText.textContent = "View only";
readonly.appendChild(readonlyText);
return readonly;
group.appendChild(readonlyText);
return group;
}
if (activeJob !== "remove") {
var idleText = document.createElement("span");
idleText.className = "access-control-locked";
idleText.textContent = activeJob === "change_role" ? "Role change" : "Review only";
group.appendChild(idleText);
return group;
}
if (normalizedAccessRole(member.role) === "owner" && !isOwner) {
var locked = createAccessControlCell("access-control-cell-access");
var lockedText = document.createElement("span");
lockedText.className = "access-control-locked";
lockedText.textContent = "Locked";
locked.appendChild(lockedText);
return locked;
group.appendChild(lockedText);
return group;
}
var btn = document.createElement("button");
btn.type = "button";
@@ -261,11 +291,10 @@
btn.setAttribute("data-account-id", accountID);
btn.setAttribute("data-user-id", member.user_id);
btn.setAttribute("data-member-email", member.email);
var group = createAccessControlCell("access-control-cell-access");
group.appendChild(btn);
return group;
}
function renderAccessMemberRow(accountID, member, isOwner, canManage) {
function renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob) {
var row = document.createElement("div");
row.className = "access-member-row";
var identity = document.createElement("div");
@@ -286,16 +315,14 @@
caption.textContent = roleCapabilityCopy(member.role);
identity.appendChild(caption);
row.appendChild(identity);
row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage));
row.appendChild(renderAccessMemberAction(accountID, member, isOwner, canManage) || createAccessControlCell("access-control-cell-access"));
row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage, activeJob));
row.appendChild(renderAccessMemberAction(accountID, member, isOwner, canManage, activeJob) || createAccessControlCell("access-control-cell-access"));
return row;
}
function ensureAccessRosterHead(container) {
var existing = container.querySelector(".access-roster-head");
if (existing) return;
function renderAccessRosterHead(container, activeJob) {
var head = document.createElement("div");
head.className = "access-roster-head";
head.innerHTML = "<span>Operator</span><span>Role</span><span>Access</span>";
head.innerHTML = "<span>Operator</span><span>" + (activeJob === "change_role" ? "New role" : "Role") + "</span><span>" + (activeJob === "remove" ? "Remove" : "Action") + "</span>";
container.appendChild(head);
}
function renderAddWorkspaceSection(accountID, entry) {
@@ -312,11 +339,41 @@
var roster = getElement("access-list-" + accountID);
if (!section || !roster) return;
var rosterPanel = roster.closest(".access-roster");
var shell = getElement("access-shell-" + accountID);
var detail = getElement("access-detail-" + accountID);
var taskPanel = getElement("access-task-panel-" + accountID);
var taskTitle = getElement("access-task-title-" + accountID);
var taskCopy = getElement("access-task-copy-" + accountID);
var taskButtons = {
invite: getElement("access-task-invite-" + accountID),
change_role: getElement("access-task-change_role-" + accountID),
remove: getElement("access-task-remove-" + accountID)
};
var taskBodies = {
invite: getElement("access-task-body-invite-" + accountID),
change_role: getElement("access-task-body-change_role-" + accountID),
remove: getElement("access-task-body-remove-" + accountID)
};
var actorRole = section.getAttribute("data-actor-role") || "";
var isOwner = actorRole === "owner";
var canManage = section.getAttribute("data-can-manage") === "true";
var activeJob = canManage ? entry.activeAccessJob : "";
section.classList.toggle("visible", entry.accessVisible);
renderAccessStats(accountID, entry, canManage);
if (shell) {
shell.classList.toggle("access-shell-job-open", !!activeJob);
shell.classList.toggle("access-shell-idle", !activeJob);
}
if (detail) detail.hidden = !activeJob;
if (taskPanel) taskPanel.hidden = !activeJob;
if (taskTitle) taskTitle.textContent = accessJobTitle(activeJob);
if (taskCopy) taskCopy.textContent = accessJobCopy(activeJob);
taskButtons.invite?.classList.toggle("is-active", activeJob === "invite");
taskButtons.change_role?.classList.toggle("is-active", activeJob === "change_role");
taskButtons.remove?.classList.toggle("is-active", activeJob === "remove");
if (taskBodies.invite) taskBodies.invite.hidden = activeJob !== "invite";
if (taskBodies.change_role) taskBodies.change_role.hidden = activeJob !== "change_role";
if (taskBodies.remove) taskBodies.remove.hidden = activeJob !== "remove";
if (!entry.accessVisible) {
return;
}
@@ -343,10 +400,10 @@
roster.textContent = "";
roster.classList.remove("state-only");
if (rosterPanel) rosterPanel.classList.remove("state-only");
ensureAccessRosterHead(roster);
renderAccessRosterHead(roster, activeJob);
for (var i = 0; i < entry.accessQuery.data.length; i += 1) {
var member = entry.accessQuery.data[i];
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage));
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob));
}
}
function renderAccountUI(accountState, accounts) {
@@ -391,6 +448,16 @@
deps.setShellSection("access");
deps.runtime.ensureAccessVisible(accountID);
return;
case "set-access-job":
event.preventDefault();
deps.setShellSection("access");
void deps.runtime.setAccessJob(accountID, actionEl.getAttribute("data-access-job") || "");
return;
case "clear-access-job":
event.preventDefault();
deps.setShellSection("access");
deps.runtime.clearAccessJob(accountID);
return;
case "invite-member":
event.preventDefault();
void deps.runtime.inviteMember(accountID);
@@ -683,6 +750,7 @@
selectedWorkspaceID: "",
manageWorkspace: createMutationState(),
accessVisible: false,
activeAccessJob: "",
accessQuery: createQueryState([])
};
}
@@ -863,6 +931,7 @@
entry.addWorkspaceOpen = !entry.addWorkspaceOpen;
if (entry.addWorkspaceOpen) {
entry.accessVisible = false;
entry.activeAccessJob = "";
entry.selectedWorkspaceID = "";
}
shouldFocus = entry.addWorkspaceOpen;
@@ -880,6 +949,7 @@
entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? "" : workspaceID;
if (entry.selectedWorkspaceID) {
entry.accessVisible = false;
entry.activeAccessJob = "";
entry.addWorkspaceOpen = false;
}
selectedWorkspaceID = entry.selectedWorkspaceID;
@@ -977,36 +1047,50 @@
deps.showToast(error instanceof Error ? error.message : "Failed to open billing portal.", true);
}
};
var toggleAccess = function(accountID) {
var nextVisible = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.accessVisible = !entry.accessVisible;
if (entry.accessVisible) {
entry.selectedWorkspaceID = "";
entry.addWorkspaceOpen = false;
}
nextVisible = entry.accessVisible;
});
if (nextVisible) {
void loadAccessRoster(accountID);
}
};
var ensureAccessVisible = function(accountID) {
var shouldLoad = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
if (!entry.accessVisible) {
entry.accessVisible = true;
entry.selectedWorkspaceID = "";
entry.addWorkspaceOpen = false;
}
entry.selectedWorkspaceID = "";
entry.addWorkspaceOpen = false;
shouldLoad = entry.accessQuery.status === "idle" || entry.accessQuery.status === "error";
});
if (shouldLoad) {
void loadAccessRoster(accountID);
}
};
var setAccessJob = async function(accountID, job) {
var nextJob = "";
var shouldLoad = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.accessVisible = true;
entry.selectedWorkspaceID = "";
entry.addWorkspaceOpen = false;
entry.activeAccessJob = entry.activeAccessJob === job ? "" : job;
nextJob = entry.activeAccessJob;
shouldLoad = entry.accessQuery.status === "idle" || entry.accessQuery.status === "error";
});
if (shouldLoad) {
await loadAccessRoster(accountID);
}
if (nextJob) {
revealElementWhenReady("access-detail-" + accountID, function() {
if (nextJob === "invite") {
focusElement("invite-email-" + accountID);
}
});
}
};
var clearAccessJob = function(accountID) {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.activeAccessJob = "";
});
};
var inviteMember = async function(accountID) {
var emailEl = getElement("invite-email-" + accountID);
var roleEl = getElement("invite-role-" + accountID);
@@ -1071,8 +1155,9 @@
selectWorkspace,
clearWorkspaceSelection,
openBilling,
toggleAccess,
ensureAccessVisible,
setAccessJob,
clearAccessJob,
inviteMember,
createWorkspace,
manageWorkspaceAction,
@@ -2085,11 +2170,13 @@
}
function renderAccountAccessSection(account) {
var accessHeaderTitle = account.can_manage ? "Manage access" : "Review access";
var accessHeaderCopy = account.can_manage ? "Invite people, change roles, and remove stale access from one hosted roster." : "Review who already has access to this hosted account. An owner or admin must make changes.";
var accessContextChips = account.can_manage ? ["Invite people", "Change roles", "Remove access"] : ["View roster", "Owner or admin required"];
var accessPolicy = '<div class="access-policy-panel"><div class="access-panel-heading"><h4>' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "</h4><p>" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '</p></div><div class="access-policy-list"><div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div><div class="access-policy-row"><strong>Admin</strong><span>Workspace control, billing, and roster management.</span></div><div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing or roster ownership.</span></div><div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div></div></div>';
var accessActions = account.can_manage ? '<div class="access-invite-panel"><div class="access-panel-heading"><h4>Invite people</h4><p>Add one person with the minimum role they need on this account.</p></div><div class="access-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>' : '<div class="access-invite-panel"><div class="access-panel-heading"><h4>Need an access change?</h4><p>An owner or admin on this account must invite people, change roles, or remove access.</p></div></div>';
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '"><div class="access-management-header"><div><div class="account-panel-kicker">Access</div><h3>' + accessHeaderTitle + "</h3><p>" + accessHeaderCopy + "</p>" + renderSectionContextChips(accessContextChips) + '</div><button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Back to workspaces</button></div><div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-management-grid"><div class="access-roster-column"><div class="access-roster"><div class="access-panel-heading"><h4>People on this account</h4><p>Review the hosted roster here before you open, change, or remove access.</p></div><div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div><div class="access-side-column"><div class="access-operations-panel"><div class="access-panel-heading access-panel-heading-tight"><div class="account-panel-kicker">Next action</div><h4>' + (account.can_manage ? "Pick the access job" : "This account is view-only for you") + "</h4><p>" + (account.can_manage ? "Use this column to invite people, tighten roles, or remove access that no longer belongs here." : "You can review the roster here, but access changes must be done by an owner or admin.") + '</p></div><div class="access-operations-grid">' + accessActions + accessPolicy + "</div></div></div></div></section></section>";
var accessHeaderCopy = account.can_manage ? "Review the hosted roster, then open one access job at a time." : "Review who already has access to this hosted account. An owner or admin must make changes.";
var accessTaskStrip = account.can_manage ? '<div class="access-task-strip"><button type="button" class="access-task-button" id="access-task-invite-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="invite">Invite people</button><button type="button" class="access-task-button" id="access-task-change_role-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="change_role">Change roles</button><button type="button" class="access-task-button" id="access-task-remove-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="remove">Remove access</button></div>' : renderSectionContextChips(["View roster", "Owner or admin required"]);
var accessRoleGuide = '<div class="access-policy-panel"><div class="access-panel-heading"><h4>' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "</h4><p>" + (account.can_manage ? "Match each person to the narrowest role that still lets them do the job they own." : "Use these role meanings to understand what each person on this roster can do.") + '</p></div><div class="access-policy-list"><div class="access-policy-row"><strong>Owner</strong><span>Full account, billing, and access control.</span></div><div class="access-policy-row"><strong>Admin</strong><span>Workspace control, billing, and roster management.</span></div><div class="access-policy-row"><strong>Tech</strong><span>Workspace control without billing or roster ownership.</span></div><div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div></div></div>';
var accessInvitePanel = account.can_manage ? '<div class="access-invite-panel"><div class="access-panel-heading"><h4>Invite people</h4><p>Add one person with the minimum role they need on this account.</p></div><div class="access-invite"><div><label for="invite-email-' + escapeAttr(account.id) + '">Email</label><input type="email" id="invite-email-' + escapeAttr(account.id) + '" placeholder="user@example.com" autocomplete="off"></div><div><label for="invite-role-' + escapeAttr(account.id) + '">Role</label><select id="invite-role-' + escapeAttr(account.id) + '"><option value="admin">Admin</option><option value="tech">Tech</option><option value="read_only">Read-only</option></select></div><button type="button" class="btn-primary btn-compact" data-action="invite-member" data-account-id="' + escapeAttr(account.id) + '">Invite</button></div></div>' : "";
var accessChangeRolePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Change roles on the roster</h4><p>Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.</p></div></div>' + accessRoleGuide;
var accessRemovePanel = '<div class="access-job-note-panel"><div class="access-panel-heading"><h4>Remove stale access</h4><p>Use removal only when this person should no longer be on this hosted account. Owners may still be protected when they are the last owner.</p></div><div class="access-remove-points"><div class="access-remove-point"><strong>Pick the exact person</strong><span>Use the roster to remove one account member at a time.</span></div><div class="access-remove-point"><strong>Keep current owners safe</strong><span>The last owner cannot be removed until another owner exists.</span></div></div></div>';
return '<section class="account-content-panel account-content-panel-access"><section class="access-management-panel access-section access-section-shell" id="access-section-' + escapeAttr(account.id) + '" data-actor-role="' + escapeAttr(account.role) + '" data-can-manage="' + escapeAttr(account.can_manage ? "true" : "false") + '"><div class="access-management-header"><div><div class="account-panel-kicker">Access</div><h3>' + accessHeaderTitle + "</h3><p>" + accessHeaderCopy + "</p>" + accessTaskStrip + '</div></div><div class="access-management-stats" id="access-stats-' + escapeAttr(account.id) + '"></div><div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '"><div class="access-shell-main"><div class="access-roster-column"><div class="access-roster"><div class="access-panel-heading"><h4>People on this account</h4><p>' + (account.can_manage ? "Review the hosted roster here, then open the exact access job you need." : "Review the hosted roster here. An owner or admin must make changes.") + '</p></div><div class="access-roster-list" id="access-list-' + escapeAttr(account.id) + '"><div class="access-list-message">Loading\u2026</div></div></div></div></div>' + (account.can_manage ? '<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden><div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden><div class="access-task-header"><div><div class="account-panel-kicker">Access task</div><h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4><p id="access-task-copy-' + escapeAttr(account.id) + '"></p></div><button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button></div><div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' + accessInvitePanel + accessRoleGuide + '</div><div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' + accessChangeRolePanel + '</div><div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' + accessRemovePanel + "</div></div></div>" : "") + "</div></section></section>";
}
function renderHostedBillingCards(accounts) {
var hostedBillingAccounts = accounts.filter(function(account) {
@@ -14,8 +14,9 @@ describe('account controller', function() {
selectWorkspace: vi.fn(),
clearWorkspaceSelection: vi.fn(),
openBilling: vi.fn(),
toggleAccess: vi.fn(),
ensureAccessVisible: vi.fn(),
setAccessJob: vi.fn(),
clearAccessJob: vi.fn(),
inviteMember: vi.fn(),
createWorkspace: vi.fn(),
manageWorkspaceAction: vi.fn(),
@@ -31,6 +32,8 @@ describe('account controller', function() {
'<button id="toggle" data-action="toggle-add-workspace" data-account-id="acct_1">Toggle</button>' +
'<button id="billing" data-action="open-billing" data-account-id="acct_1">Billing</button>' +
'<button id="team" data-action="show-access" data-account-id="acct_1">Team</button>' +
'<button id="job" data-action="set-access-job" data-account-id="acct_1" data-access-job="invite">Invite job</button>' +
'<button id="job-close" data-action="clear-access-job" data-account-id="acct_1">Close access job</button>' +
'<button id="invite" data-action="invite-member" data-account-id="acct_1">Invite</button>' +
'<button id="create" data-action="create-workspace" data-account-id="acct_1">Create</button>' +
'<button id="select" data-action="select-workspace" data-account-id="acct_1" data-workspace-id="ws_1">Manage</button>' +
@@ -50,6 +53,12 @@ describe('account controller', function() {
expect(setShellSection).toHaveBeenCalledWith('access');
expect(runtime.ensureAccessVisible).toHaveBeenCalledWith('acct_1');
document.getElementById('job')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.setAccessJob).toHaveBeenCalledWith('acct_1', 'invite');
document.getElementById('job-close')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.clearAccessJob).toHaveBeenCalledWith('acct_1');
document.getElementById('invite')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
expect(runtime.inviteMember).toHaveBeenCalledWith('acct_1');
@@ -30,6 +30,16 @@ export function installAccountController(deps: AccountControllerDeps): void {
deps.setShellSection('access');
deps.runtime.ensureAccessVisible(accountID);
return;
case 'set-access-job':
event.preventDefault();
deps.setShellSection('access');
void deps.runtime.setAccessJob(accountID, (actionEl.getAttribute('data-access-job') || '') as 'invite' | 'change_role' | 'remove');
return;
case 'clear-access-job':
event.preventDefault();
deps.setShellSection('access');
deps.runtime.clearAccessJob(accountID);
return;
case 'invite-member':
event.preventDefault();
void deps.runtime.inviteMember(accountID);
@@ -158,15 +158,18 @@ describe('account runtime', function() {
selectedWorkspaceID: '',
manageWorkspace: { pending: false, error: '' },
accessVisible: true,
activeAccessJob: 'invite',
accessQuery: { status: 'idle', error: '', data: [] },
});
entry.addWorkspaceOpen = true;
entry.accessVisible = true;
entry.activeAccessJob = 'invite';
}, { notify: false });
runtime.selectWorkspace('acct_1', 'ws_2');
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('ws_2');
expect(deps.store.getAccountState().byAccountID.acct_1.accessVisible).toBe(false);
expect(deps.store.getAccountState().byAccountID.acct_1.activeAccessJob).toBe('');
expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false);
expect(document.getElementById('workspace-operations-shell-acct_1')?.classList.contains('workspace-operations-shell-selected')).toBe(true);
expect(document.getElementById('workspace-management-title-acct_1')?.textContent).toContain('Alpha Workspace');
@@ -326,6 +329,77 @@ describe('account runtime', function() {
expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(true);
});
it('reveals the invite panel when an access job opens below the viewport', async function() {
deps.store.updateAccountState(function(accountState) {
accountState.byAccountID.acct_1 = {
addWorkspaceOpen: false,
createWorkspace: { pending: false, error: '' },
selectedWorkspaceID: '',
manageWorkspace: { pending: false, error: '' },
accessVisible: true,
activeAccessJob: '',
accessQuery: {
status: 'ready',
error: '',
data: [{ email: 'owner@example.com', role: 'owner', user_id: 'u1' }],
},
};
}, { notify: false });
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
'<div id="access-shell-acct_1"></div>' +
'<div id="access-detail-acct_1" hidden></div>' +
'<div id="access-task-panel-acct_1" hidden></div>' +
'<div id="access-task-title-acct_1"></div>' +
'<div id="access-task-copy-acct_1"></div>' +
'<button id="access-task-invite-acct_1"></button>' +
'<button id="access-task-change_role-acct_1"></button>' +
'<button id="access-task-remove-acct_1"></button>' +
'<div id="access-task-body-invite-acct_1"></div>' +
'<div id="access-task-body-change_role-acct_1"></div>' +
'<div id="access-task-body-remove-acct_1"></div>' +
'<div id="access-stats-acct_1"></div>' +
'<div id="access-list-acct_1"></div>' +
'<input id="invite-email-acct_1" value="">' +
'</div>';
var detail = document.getElementById('access-detail-acct_1') as HTMLElement | null;
expect(detail).not.toBeNull();
if (!detail) return;
var scrollIntoView = vi.fn();
var requestAnimationFrame = vi.fn(function(callback: FrameRequestCallback) {
callback(0);
return 1;
});
Object.defineProperty(window, 'innerHeight', { configurable: true, value: 664 });
Object.defineProperty(window, 'requestAnimationFrame', { configurable: true, value: requestAnimationFrame });
Object.defineProperty(detail, 'scrollIntoView', { configurable: true, value: scrollIntoView });
Object.defineProperty(detail, 'getBoundingClientRect', {
configurable: true,
value: function() {
return {
top: 924,
bottom: 1410,
left: 0,
right: 320,
width: 320,
height: 486,
x: 0,
y: 924,
toJSON: function() { return {}; },
};
},
});
await runtime.setAccessJob('acct_1', 'invite');
expect(requestAnimationFrame).toHaveBeenCalledTimes(1);
expect(scrollIntoView).toHaveBeenCalledWith({ block: 'start', inline: 'nearest' });
expect(deps.store.getAccountState().byAccountID.acct_1.activeAccessJob).toBe('invite');
expect(document.activeElement?.id).toBe('invite-email-acct_1');
});
it('loads and updates team membership from runtime actions', async function() {
vi.stubGlobal(
'fetch',
@@ -338,6 +412,17 @@ describe('account runtime', function() {
document.body.innerHTML =
'<div id="workspace-management-acct_1" class="workspace-management-panel"><button id="workspace-management-close-acct_1"></button><div id="workspace-management-empty-acct_1"></div><div id="workspace-management-content-acct_1" hidden><div id="workspace-management-meta-acct_1"></div><h4 id="workspace-management-title-acct_1"></h4><p id="workspace-management-summary-acct_1"></p><div id="workspace-management-health-acct_1"></div><div id="workspace-management-lifecycle-acct_1"></div><div id="workspace-management-created-acct_1"></div><div id="workspace-management-guidance-acct_1"></div><button id="workspace-management-action-acct_1"></button></div></div>' +
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
'<div id="access-shell-acct_1"></div>' +
'<div id="access-detail-acct_1"></div>' +
'<div id="access-task-panel-acct_1"></div>' +
'<div id="access-task-title-acct_1"></div>' +
'<div id="access-task-copy-acct_1"></div>' +
'<button id="access-task-invite-acct_1"></button>' +
'<button id="access-task-change_role-acct_1"></button>' +
'<button id="access-task-remove-acct_1"></button>' +
'<div id="access-task-body-invite-acct_1"></div>' +
'<div id="access-task-body-change_role-acct_1"></div>' +
'<div id="access-task-body-remove-acct_1"></div>' +
'<div id="access-stats-acct_1"></div>' +
'<table><tbody id="access-list-acct_1"></tbody></table>' +
'<input id="invite-email-acct_1">' +
@@ -351,18 +436,20 @@ describe('account runtime', function() {
selectedWorkspaceID: 'ws_1',
manageWorkspace: { pending: false, error: '' },
accessVisible: false,
activeAccessJob: '',
accessQuery: { status: 'idle', error: '', data: [] },
});
entry.addWorkspaceOpen = true;
entry.selectedWorkspaceID = 'ws_1';
}, { notify: false });
runtime.toggleAccess('acct_1');
await runtime.setAccessJob('acct_1', 'change_role');
await flushAsync();
expect(deps.store.getAccountState().byAccountID.acct_1.accessVisible).toBe(true);
expect(deps.store.getAccountState().byAccountID.acct_1.selectedWorkspaceID).toBe('');
expect(deps.store.getAccountState().byAccountID.acct_1.addWorkspaceOpen).toBe(false);
expect(deps.store.getAccountState().byAccountID.acct_1.activeAccessJob).toBe('change_role');
expect(deps.store.getAccountState().byAccountID.acct_1.accessQuery.status).toBe('ready');
expect(deps.store.getAccountState().byAccountID.acct_1.accessQuery.data).toHaveLength(1);
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Members');
@@ -4,7 +4,7 @@ import type { PortalAPI } from './api';
import { focusElement, getElement, renderAccountUI as renderAccountUIState } from './account_view';
import { ensurePortalAccountUIEntry } from './state';
import type { PortalStore } from './store';
import type { PortalAccessMember } from './types';
import type { PortalAccessJob, PortalAccessMember } from './types';
export interface AccountRuntimeDeps {
api: PortalAPI;
@@ -18,8 +18,9 @@ export interface AccountRuntime {
selectWorkspace(accountID: string, workspaceID: string): void;
clearWorkspaceSelection(accountID: string): void;
openBilling(accountID: string): Promise<void>;
toggleAccess(accountID: string): void;
ensureAccessVisible(accountID: string): void;
setAccessJob(accountID: string, job: PortalAccessJob): Promise<void>;
clearAccessJob(accountID: string): void;
inviteMember(accountID: string): Promise<void>;
createWorkspace(accountID: string): Promise<void>;
manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise<void>;
@@ -114,6 +115,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
entry.addWorkspaceOpen = !entry.addWorkspaceOpen;
if (entry.addWorkspaceOpen) {
entry.accessVisible = false;
entry.activeAccessJob = '';
entry.selectedWorkspaceID = '';
}
shouldFocus = entry.addWorkspaceOpen;
@@ -132,6 +134,7 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
entry.selectedWorkspaceID = entry.selectedWorkspaceID === workspaceID ? '' : workspaceID;
if (entry.selectedWorkspaceID) {
entry.accessVisible = false;
entry.activeAccessJob = '';
entry.addWorkspaceOpen = false;
}
selectedWorkspaceID = entry.selectedWorkspaceID;
@@ -234,31 +237,15 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
}
};
var toggleAccess = function(accountID: string): void {
var nextVisible = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.accessVisible = !entry.accessVisible;
if (entry.accessVisible) {
entry.selectedWorkspaceID = '';
entry.addWorkspaceOpen = false;
}
nextVisible = entry.accessVisible;
});
if (nextVisible) {
void loadAccessRoster(accountID);
}
};
var ensureAccessVisible = function(accountID: string): void {
var shouldLoad = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
if (!entry.accessVisible) {
entry.accessVisible = true;
entry.selectedWorkspaceID = '';
entry.addWorkspaceOpen = false;
}
entry.selectedWorkspaceID = '';
entry.addWorkspaceOpen = false;
shouldLoad = entry.accessQuery.status === 'idle' || entry.accessQuery.status === 'error';
});
if (shouldLoad) {
@@ -266,6 +253,37 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
}
};
var setAccessJob = async function(accountID: string, job: PortalAccessJob): Promise<void> {
var nextJob = '' as PortalAccessJob;
var shouldLoad = false;
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.accessVisible = true;
entry.selectedWorkspaceID = '';
entry.addWorkspaceOpen = false;
entry.activeAccessJob = entry.activeAccessJob === job ? '' : job;
nextJob = entry.activeAccessJob;
shouldLoad = entry.accessQuery.status === 'idle' || entry.accessQuery.status === 'error';
});
if (shouldLoad) {
await loadAccessRoster(accountID);
}
if (nextJob) {
revealElementWhenReady('access-detail-' + accountID, function() {
if (nextJob === 'invite') {
focusElement('invite-email-' + accountID);
}
});
}
};
var clearAccessJob = function(accountID: string): void {
deps.store.updateAccountState(function(accountState) {
var entry = ensurePortalAccountUIEntry(accountState, accountID);
entry.activeAccessJob = '';
});
};
var inviteMember = async function(accountID: string): Promise<void> {
var emailEl = getElement<HTMLInputElement>('invite-email-' + accountID);
var roleEl = getElement<HTMLSelectElement>('invite-role-' + accountID);
@@ -334,8 +352,9 @@ export function installAccountRuntime(deps: AccountRuntimeDeps): AccountRuntime
selectWorkspace: selectWorkspace,
clearWorkspaceSelection: clearWorkspaceSelection,
openBilling: openBilling,
toggleAccess: toggleAccess,
ensureAccessVisible: ensureAccessVisible,
setAccessJob: setAccessJob,
clearAccessJob: clearAccessJob,
inviteMember: inviteMember,
createWorkspace: createWorkspace,
manageWorkspaceAction: manageWorkspaceAction,
@@ -16,6 +16,7 @@ function createEntry(overrides: Partial<PortalAccountUIEntry> = {}): PortalAccou
error: '',
},
accessVisible: false,
activeAccessJob: '',
accessQuery: {
status: 'idle',
error: '',
@@ -138,12 +139,71 @@ describe('account view', function() {
},
})
);
expect(document.querySelector('[data-action="change-role"]')).not.toBeNull();
expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull();
expect(document.querySelector('[data-action="change-role"]')).toBeNull();
expect(document.querySelector('[data-action="remove-member"]')).toBeNull();
expect(document.getElementById('access-list-acct_1')?.textContent).toContain('Review only');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('Members');
expect(document.getElementById('access-stats-acct_1')?.textContent).toContain('2');
});
it('renders access controls only for the active access job', function() {
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
'<div id="access-shell-acct_1"></div>' +
'<div id="access-detail-acct_1"></div>' +
'<div id="access-task-panel-acct_1"></div>' +
'<div id="access-task-title-acct_1"></div>' +
'<div id="access-task-copy-acct_1"></div>' +
'<button id="access-task-invite-acct_1"></button>' +
'<button id="access-task-change_role-acct_1"></button>' +
'<button id="access-task-remove-acct_1"></button>' +
'<div id="access-task-body-invite-acct_1"></div>' +
'<div id="access-task-body-change_role-acct_1"></div>' +
'<div id="access-task-body-remove-acct_1"></div>' +
'<div id="access-stats-acct_1"></div>' +
'<div id="access-list-acct_1"></div>' +
'</div>';
renderAccessSection(
'acct_1',
createEntry({
accessVisible: true,
activeAccessJob: 'change_role',
accessQuery: {
status: 'ready',
error: '',
data: [
{ email: 'owner@example.com', role: 'owner', user_id: 'u1' },
],
},
})
);
expect(document.querySelector('[data-action="change-role"]')).not.toBeNull();
expect(document.querySelector('[data-action="remove-member"]')).toBeNull();
expect(document.getElementById('access-task-title-acct_1')?.textContent).toContain('Change roles');
expect(document.getElementById('access-detail-acct_1')?.hidden).toBe(false);
renderAccessSection(
'acct_1',
createEntry({
accessVisible: true,
activeAccessJob: 'remove',
accessQuery: {
status: 'ready',
error: '',
data: [
{ email: 'owner@example.com', role: 'owner', user_id: 'u1' },
],
},
})
);
expect(document.querySelector('[data-action="change-role"]')).toBeNull();
expect(document.querySelector('[data-action="remove-member"]')).not.toBeNull();
expect(document.getElementById('access-task-title-acct_1')?.textContent).toContain('Remove access');
});
it('normalizes legacy member roles into the current read-only operator model', function() {
document.body.innerHTML =
'<div id="access-section-acct_1" class="access-section" data-actor-role="owner" data-can-manage="true">' +
@@ -155,6 +215,7 @@ describe('account view', function() {
'acct_1',
createEntry({
accessVisible: true,
activeAccessJob: 'change_role',
accessQuery: {
status: 'ready',
error: '',
@@ -1,4 +1,5 @@
import type {
PortalAccessJob,
PortalAccountState,
PortalAccountSummary,
PortalAccountUIEntry,
@@ -207,6 +208,32 @@ function countMembersByRole(members: PortalAccessMember[], role: string): number
return count;
}
function accessJobTitle(job: PortalAccessJob): string {
switch (job) {
case 'invite':
return 'Invite people';
case 'change_role':
return 'Change roles';
case 'remove':
return 'Remove access';
default:
return '';
}
}
function accessJobCopy(job: PortalAccessJob): string {
switch (job) {
case 'invite':
return 'Add one person with the minimum role they need on this account.';
case 'change_role':
return 'Use the roster to change one person at a time and keep each person on the smallest role they need.';
case 'remove':
return 'Use removal only when this person should no longer be on this hosted account.';
default:
return '';
}
}
function renderAccessStats(accountID: string, entry: PortalAccountUIEntry, canManage: boolean): void {
var stats = getElement<HTMLElement>('access-stats-' + accountID);
if (!stats) return;
@@ -241,10 +268,10 @@ function createAccessControlCell(className: string): HTMLDivElement {
return cell;
}
function renderAccessRoleControl(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean): HTMLElement {
function renderAccessRoleControl(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob): HTMLElement {
var currentRole = normalizedAccessRole(member.role);
var group = createAccessControlCell('access-control-cell-role');
if (!canManage) {
if (!canManage || activeJob !== 'change_role') {
var badge = document.createElement('span');
badge.className = 'access-role-badge';
badge.textContent = roleLabel(currentRole);
@@ -276,22 +303,28 @@ function renderAccessRoleControl(accountID: string, member: PortalAccessMember,
return group;
}
function renderAccessMemberAction(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean): HTMLElement | null {
function renderAccessMemberAction(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob): HTMLElement | null {
var group = createAccessControlCell('access-control-cell-access');
if (!canManage) {
var readonly = createAccessControlCell('access-control-cell-access');
var readonlyText = document.createElement('span');
readonlyText.className = 'access-control-locked';
readonlyText.textContent = 'View only';
readonly.appendChild(readonlyText);
return readonly;
group.appendChild(readonlyText);
return group;
}
if (activeJob !== 'remove') {
var idleText = document.createElement('span');
idleText.className = 'access-control-locked';
idleText.textContent = activeJob === 'change_role' ? 'Role change' : 'Review only';
group.appendChild(idleText);
return group;
}
if (normalizedAccessRole(member.role) === 'owner' && !isOwner) {
var locked = createAccessControlCell('access-control-cell-access');
var lockedText = document.createElement('span');
lockedText.className = 'access-control-locked';
lockedText.textContent = 'Locked';
locked.appendChild(lockedText);
return locked;
group.appendChild(lockedText);
return group;
}
var btn = document.createElement('button');
@@ -302,12 +335,11 @@ function renderAccessMemberAction(accountID: string, member: PortalAccessMember,
btn.setAttribute('data-account-id', accountID);
btn.setAttribute('data-user-id', member.user_id);
btn.setAttribute('data-member-email', member.email);
var group = createAccessControlCell('access-control-cell-access');
group.appendChild(btn);
return group;
}
function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean): HTMLElement {
function renderAccessMemberRow(accountID: string, member: PortalAccessMember, isOwner: boolean, canManage: boolean, activeJob: PortalAccessJob): HTMLElement {
var row = document.createElement('div');
row.className = 'access-member-row';
@@ -335,20 +367,18 @@ function renderAccessMemberRow(accountID: string, member: PortalAccessMember, is
identity.appendChild(caption);
row.appendChild(identity);
row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage));
row.appendChild(renderAccessMemberAction(accountID, member, isOwner, canManage) || createAccessControlCell('access-control-cell-access'));
row.appendChild(renderAccessRoleControl(accountID, member, isOwner, canManage, activeJob));
row.appendChild(renderAccessMemberAction(accountID, member, isOwner, canManage, activeJob) || createAccessControlCell('access-control-cell-access'));
return row;
}
function ensureAccessRosterHead(container: HTMLElement): void {
var existing = container.querySelector('.access-roster-head');
if (existing) return;
function renderAccessRosterHead(container: HTMLElement, activeJob: PortalAccessJob): void {
var head = document.createElement('div');
head.className = 'access-roster-head';
head.innerHTML =
'<span>Operator</span>' +
'<span>Role</span>' +
'<span>Access</span>';
'<span>' + (activeJob === 'change_role' ? 'New role' : 'Role') + '</span>' +
'<span>' + (activeJob === 'remove' ? 'Remove' : 'Action') + '</span>';
container.appendChild(head);
}
@@ -367,12 +397,42 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
var roster = getElement<HTMLElement>('access-list-' + accountID);
if (!section || !roster) return;
var rosterPanel = roster.closest('.access-roster') as HTMLElement | null;
var shell = getElement<HTMLElement>('access-shell-' + accountID);
var detail = getElement<HTMLElement>('access-detail-' + accountID);
var taskPanel = getElement<HTMLElement>('access-task-panel-' + accountID);
var taskTitle = getElement<HTMLElement>('access-task-title-' + accountID);
var taskCopy = getElement<HTMLElement>('access-task-copy-' + accountID);
var taskButtons = {
invite: getElement<HTMLElement>('access-task-invite-' + accountID),
change_role: getElement<HTMLElement>('access-task-change_role-' + accountID),
remove: getElement<HTMLElement>('access-task-remove-' + accountID),
};
var taskBodies = {
invite: getElement<HTMLElement>('access-task-body-invite-' + accountID),
change_role: getElement<HTMLElement>('access-task-body-change_role-' + accountID),
remove: getElement<HTMLElement>('access-task-body-remove-' + accountID),
};
var actorRole = section.getAttribute('data-actor-role') || '';
var isOwner = actorRole === 'owner';
var canManage = section.getAttribute('data-can-manage') === 'true';
var activeJob = canManage ? entry.activeAccessJob : '';
section.classList.toggle('visible', entry.accessVisible);
renderAccessStats(accountID, entry, canManage);
if (shell) {
shell.classList.toggle('access-shell-job-open', !!activeJob);
shell.classList.toggle('access-shell-idle', !activeJob);
}
if (detail) detail.hidden = !activeJob;
if (taskPanel) taskPanel.hidden = !activeJob;
if (taskTitle) taskTitle.textContent = accessJobTitle(activeJob);
if (taskCopy) taskCopy.textContent = accessJobCopy(activeJob);
taskButtons.invite?.classList.toggle('is-active', activeJob === 'invite');
taskButtons.change_role?.classList.toggle('is-active', activeJob === 'change_role');
taskButtons.remove?.classList.toggle('is-active', activeJob === 'remove');
if (taskBodies.invite) taskBodies.invite.hidden = activeJob !== 'invite';
if (taskBodies.change_role) taskBodies.change_role.hidden = activeJob !== 'change_role';
if (taskBodies.remove) taskBodies.remove.hidden = activeJob !== 'remove';
if (!entry.accessVisible) {
return;
@@ -403,10 +463,10 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt
roster.textContent = '';
roster.classList.remove('state-only');
if (rosterPanel) rosterPanel.classList.remove('state-only');
ensureAccessRosterHead(roster);
renderAccessRosterHead(roster, activeJob);
for (var i = 0; i < entry.accessQuery.data.length; i += 1) {
var member = entry.accessQuery.data[i];
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage));
roster.appendChild(renderAccessMemberRow(accountID, member, isOwner, canManage, activeJob));
}
}
@@ -191,7 +191,10 @@ describe('shell view', function() {
expect(html).toContain('Invite people');
expect(html).toContain('Change roles');
expect(html).toContain('Remove access');
expect(html).toContain('Pick the access job');
expect(html).toContain('Review the hosted roster, then open one access job at a time.');
expect(html).toContain('data-action="set-access-job"');
expect(html).toContain('Access task');
expect(html).toContain('id="access-detail-acct_1" hidden');
expect(html).toContain('Choose the smallest role');
expect(html).toContain('Full account, billing, and access control.');
expect(html).toContain('Workspace control, billing, and roster management.');
@@ -199,6 +202,7 @@ describe('shell view', function() {
expect(html).toContain('Review access without control-plane changes.');
expect(html).toContain('People on this account');
expect(html).toContain('data-can-manage="true"');
expect(html).toContain('Remove stale access');
expect(html).toContain('data-action="workspace-action"');
expect(html).toContain('billing-action-row');
expect(html).toContain('billing-action-button');
@@ -335,10 +339,11 @@ describe('shell view', function() {
expect(html).toContain('Review access');
expect(html).toContain('Owner or admin required');
expect(html).toContain('This account is view-only for you');
expect(html).toContain('Need an access change?');
expect(html).toContain('Review who already has access to this hosted account. An owner or admin must make changes.');
expect(html).toContain('Review the hosted roster here. An owner or admin must make changes.');
expect(html).toContain('data-can-manage="false"');
expect(html).not.toContain('data-action="invite-member"');
expect(html).not.toContain('data-action="set-access-job"');
});
it('renders self-hosted overview copy when no hosted accounts are attached', function() {
@@ -895,12 +895,18 @@ function renderAccountWorkspaceSection(account: PortalAccountSummary, accountAPI
function renderAccountAccessSection(account: PortalAccountSummary): string {
var accessHeaderTitle = account.can_manage ? 'Manage access' : 'Review access';
var accessHeaderCopy = account.can_manage
? 'Invite people, change roles, and remove stale access from one hosted roster.'
? 'Review the hosted roster, then open one access job at a time.'
: 'Review who already has access to this hosted account. An owner or admin must make changes.';
var accessContextChips = account.can_manage
? ['Invite people', 'Change roles', 'Remove access']
: ['View roster', 'Owner or admin required'];
var accessPolicy =
var accessTaskStrip = account.can_manage
? (
'<div class="access-task-strip">' +
'<button type="button" class="access-task-button" id="access-task-invite-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="invite">Invite people</button>' +
'<button type="button" class="access-task-button" id="access-task-change_role-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="change_role">Change roles</button>' +
'<button type="button" class="access-task-button" id="access-task-remove-' + escapeAttr(account.id) + '" data-action="set-access-job" data-account-id="' + escapeAttr(account.id) + '" data-access-job="remove">Remove access</button>' +
'</div>'
)
: renderSectionContextChips(['View roster', 'Owner or admin required']);
var accessRoleGuide =
'<div class="access-policy-panel">' +
'<div class="access-panel-heading">' +
'<h4>' + (account.can_manage ? 'Choose the smallest role' : 'Role meanings') + '</h4>' +
@@ -915,7 +921,7 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'<div class="access-policy-row"><strong>Read-only</strong><span>Review access without control-plane changes.</span></div>' +
'</div>' +
'</div>';
var accessActions = account.can_manage
var accessInvitePanel = account.can_manage
? (
'<div class="access-invite-panel">' +
'<div class="access-panel-heading">' +
@@ -939,14 +945,26 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'</div>' +
'</div>'
)
: (
'<div class="access-invite-panel">' +
'<div class="access-panel-heading">' +
'<h4>Need an access change?</h4>' +
'<p>An owner or admin on this account must invite people, change roles, or remove access.</p>' +
'</div>' +
'</div>'
);
: '';
var accessChangeRolePanel =
'<div class="access-job-note-panel">' +
'<div class="access-panel-heading">' +
'<h4>Change roles on the roster</h4>' +
'<p>Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.</p>' +
'</div>' +
'</div>' +
accessRoleGuide;
var accessRemovePanel =
'<div class="access-job-note-panel">' +
'<div class="access-panel-heading">' +
'<h4>Remove stale access</h4>' +
'<p>Use removal only when this person should no longer be on this hosted account. Owners may still be protected when they are the last owner.</p>' +
'</div>' +
'<div class="access-remove-points">' +
'<div class="access-remove-point"><strong>Pick the exact person</strong><span>Use the roster to remove one account member at a time.</span></div>' +
'<div class="access-remove-point"><strong>Keep current owners safe</strong><span>The last owner cannot be removed until another owner exists.</span></div>' +
'</div>' +
'</div>';
return (
'<section class="account-content-panel account-content-panel-access">' +
@@ -962,42 +980,56 @@ function renderAccountAccessSection(account: PortalAccountSummary): string {
'<div class="account-panel-kicker">Access</div>' +
'<h3>' + accessHeaderTitle + '</h3>' +
'<p>' + accessHeaderCopy + '</p>' +
renderSectionContextChips(accessContextChips) +
accessTaskStrip +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" data-shell-action="activate-section" data-shell-section="workspaces">Back to workspaces</button>' +
'</div>' +
'<div class="access-management-stats" id="access-stats-' +
escapeAttr(account.id) +
'"></div>' +
'<div class="access-management-grid">' +
'<div class="access-roster-column">' +
'<div class="access-roster">' +
'<div class="access-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>Review the hosted roster here before you open, change, or remove access.</p>' +
'</div>' +
'<div class="access-roster-list" id="access-list-' +
escapeAttr(account.id) +
'">' +
'<div class="access-list-message">Loading…</div>' +
'</div>' +
'</div>' +
'</div>' +
'<div class="access-side-column">' +
'<div class="access-operations-panel">' +
'<div class="access-panel-heading access-panel-heading-tight">' +
'<div class="account-panel-kicker">Next action</div>' +
'<h4>' + (account.can_manage ? 'Pick the access job' : 'This account is view-only for you') + '</h4>' +
'<p>' + (account.can_manage
? 'Use this column to invite people, tighten roles, or remove access that no longer belongs here.'
: 'You can review the roster here, but access changes must be done by an owner or admin.') + '</p>' +
'</div>' +
'<div class="access-operations-grid">' +
accessActions +
accessPolicy +
'<div class="access-shell access-shell-idle" id="access-shell-' + escapeAttr(account.id) + '">' +
'<div class="access-shell-main">' +
'<div class="access-roster-column">' +
'<div class="access-roster">' +
'<div class="access-panel-heading">' +
'<h4>People on this account</h4>' +
'<p>' + (account.can_manage
? 'Review the hosted roster here, then open the exact access job you need.'
: 'Review the hosted roster here. An owner or admin must make changes.') + '</p>' +
'</div>' +
'<div class="access-roster-list" id="access-list-' +
escapeAttr(account.id) +
'">' +
'<div class="access-list-message">Loading…</div>' +
'</div>' +
'</div>' +
'</div>' +
'</div>' +
(account.can_manage
? (
'<div class="access-shell-detail" id="access-detail-' + escapeAttr(account.id) + '" hidden>' +
'<div class="access-task-panel" id="access-task-panel-' + escapeAttr(account.id) + '" hidden>' +
'<div class="access-task-header">' +
'<div>' +
'<div class="account-panel-kicker">Access task</div>' +
'<h4 id="access-task-title-' + escapeAttr(account.id) + '">Invite people</h4>' +
'<p id="access-task-copy-' + escapeAttr(account.id) + '"></p>' +
'</div>' +
'<button type="button" class="btn-secondary btn-compact" data-action="clear-access-job" data-account-id="' + escapeAttr(account.id) + '">Close panel</button>' +
'</div>' +
'<div class="access-task-body" id="access-task-body-invite-' + escapeAttr(account.id) + '" hidden>' +
accessInvitePanel +
accessRoleGuide +
'</div>' +
'<div class="access-task-body" id="access-task-body-change_role-' + escapeAttr(account.id) + '" hidden>' +
accessChangeRolePanel +
'</div>' +
'<div class="access-task-body" id="access-task-body-remove-' + escapeAttr(account.id) + '" hidden>' +
accessRemovePanel +
'</div>' +
'</div>' +
'</div>'
)
: '') +
'</div>' +
'</section>' +
'</section>'
@@ -79,6 +79,7 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc
selectedWorkspaceID: '',
manageWorkspace: createMutationState(),
accessVisible: false,
activeAccessJob: '',
accessQuery: createQueryState<PortalAccessMember[]>([]),
};
}
+90 -17
View File
@@ -2041,13 +2041,56 @@ header .logout-btn:hover,
font-size: 22px;
}
.access-management-grid {
.access-task-strip {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.access-task-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 32px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink-muted);
font-size: 11px;
font-weight: 800;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.access-task-button.is-active {
border-color: rgba(15, 109, 115, 0.22);
background: rgba(15, 109, 115, 0.08);
color: var(--accent-strong);
}
.access-shell {
display: grid;
grid-template-columns: minmax(0, 1.82fr) minmax(292px, 0.84fr);
gap: 14px;
align-items: start;
}
.access-shell-main,
.access-shell-detail {
min-width: 0;
}
.access-shell-detail {
position: sticky;
top: 14px;
}
.access-shell-idle {
grid-template-columns: 1fr;
}
.access-roster-column {
display: flex;
flex-direction: column;
@@ -2059,17 +2102,10 @@ header .logout-btn:hover,
box-shadow: var(--shadow-sm);
}
.access-side-column {
display: flex;
flex-direction: column;
align-self: start;
position: sticky;
top: 14px;
}
.access-roster,
.access-invite-panel,
.access-operations-panel {
.access-task-panel,
.access-job-note-panel {
background: #fff;
border-color: var(--line);
}
@@ -2088,7 +2124,8 @@ header .logout-btn:hover,
padding: 0;
}
.access-operations-panel {
.access-task-panel,
.access-job-note-panel {
display: flex;
flex-direction: column;
gap: 12px;
@@ -2097,12 +2134,17 @@ header .logout-btn:hover,
border-radius: 12px;
}
.access-operations-grid {
.access-task-header,
.access-task-body {
display: flex;
flex-direction: column;
gap: 14px;
}
.access-task-header {
gap: 12px;
}
.access-policy-panel {
border: 1px solid var(--line);
border-radius: 12px;
@@ -2413,6 +2455,32 @@ header .logout-btn:hover,
color: var(--ink-soft);
}
.access-remove-points {
display: flex;
flex-direction: column;
gap: 10px;
}
.access-remove-point {
display: flex;
flex-direction: column;
gap: 4px;
padding-left: 12px;
border-left: 3px solid rgba(11, 106, 114, 0.14);
}
.access-remove-point strong {
font-size: 13px;
line-height: 1.3;
color: var(--ink);
}
.access-remove-point span {
font-size: 12px;
line-height: 1.45;
color: var(--ink-soft);
}
.billing-action-list {
display: grid;
grid-template-columns: 1fr;
@@ -3015,7 +3083,7 @@ header .logout-btn:hover,
@media (max-width: 1180px) {
.account-management-grid,
.access-management-grid,
.access-shell,
.access-review-grid,
.account-overview-secondary,
.overview-task-grid,
@@ -3059,10 +3127,6 @@ header .logout-btn:hover,
grid-template-columns: 1fr;
}
.access-side-column {
position: static;
}
.workspace-list-head {
display: none;
}
@@ -3280,6 +3344,14 @@ header .logout-btn:hover,
order: 2;
}
.access-shell-job-open .access-shell-detail {
order: 1;
}
.access-shell-job-open .access-shell-main {
order: 2;
}
.workspace-management-header,
.access-management-header,
.billing-header,
@@ -3318,6 +3390,7 @@ header .logout-btn:hover,
.billing-shell,
.billing-empty-shell,
.billing-empty-intro,
.access-shell,
.workspace-operations-shell,
.overview-task-grid,
.account-overview-card,
@@ -61,12 +61,15 @@ export interface PortalMutationState {
error: string;
}
export type PortalAccessJob = '' | 'invite' | 'change_role' | 'remove';
export interface PortalAccountUIEntry {
addWorkspaceOpen: boolean;
createWorkspace: PortalMutationState;
selectedWorkspaceID: string;
manageWorkspace: PortalMutationState;
accessVisible: boolean;
activeAccessJob: PortalAccessJob;
accessQuery: PortalQueryState<PortalAccessMember[]>;
}