mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-09 18:15:50 +00:00
Make access task-driven
This commit is contained in:
@@ -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
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"source_hash": "136eb40c8f56e03e067c9eca7fb20713dbecf8ee60efa8f47b318bb7954ac641",
|
||||
"source_hash": "32735365beff496cbfb64b485b1a89d40dcd459e036a09ead2bfe337370c6dda",
|
||||
"build_inputs": [
|
||||
"package.json",
|
||||
"tsconfig.json",
|
||||
|
||||
+77
-15
@@ -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
@@ -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[]>([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user