diff --git a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md index c15d6ada8..4c5f1e87a 100644 --- a/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md +++ b/docs/release-control/v6/internal/PULSE_ACCOUNT_PORTAL_SPEC.md @@ -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 diff --git a/docs/release-control/v6/internal/subsystems/api-contracts.md b/docs/release-control/v6/internal/subsystems/api-contracts.md index 80e73d7df..3da7676d9 100644 --- a/docs/release-control/v6/internal/subsystems/api-contracts.md +++ b/docs/release-control/v6/internal/subsystems/api-contracts.md @@ -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. diff --git a/docs/release-control/v6/internal/subsystems/cloud-paid.md b/docs/release-control/v6/internal/subsystems/cloud-paid.md index 9f18f2071..dfbe7fc6a 100644 --- a/docs/release-control/v6/internal/subsystems/cloud-paid.md +++ b/docs/release-control/v6/internal/subsystems/cloud-paid.md @@ -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. diff --git a/internal/cloudcp/portal/dist/build_manifest.json b/internal/cloudcp/portal/dist/build_manifest.json index d4cc3a84c..477efcbee 100644 --- a/internal/cloudcp/portal/dist/build_manifest.json +++ b/internal/cloudcp/portal/dist/build_manifest.json @@ -1,5 +1,5 @@ { - "source_hash": "136eb40c8f56e03e067c9eca7fb20713dbecf8ee60efa8f47b318bb7954ac641", + "source_hash": "32735365beff496cbfb64b485b1a89d40dcd459e036a09ead2bfe337370c6dda", "build_inputs": [ "package.json", "tsconfig.json", diff --git a/internal/cloudcp/portal/dist/portal_app.css b/internal/cloudcp/portal/dist/portal_app.css index c39aa1695..5a379a06f 100644 --- a/internal/cloudcp/portal/dist/portal_app.css +++ b/internal/cloudcp/portal/dist/portal_app.css @@ -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, diff --git a/internal/cloudcp/portal/dist/portal_app.js b/internal/cloudcp/portal/dist/portal_app.js index 6ad379c1b..f17dc88d0 100644 --- a/internal/cloudcp/portal/dist/portal_app.js +++ b/internal/cloudcp/portal/dist/portal_app.js @@ -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 = "OperatorRoleAccess"; + head.innerHTML = "Operator" + (activeJob === "change_role" ? "New role" : "Role") + "" + (activeJob === "remove" ? "Remove" : "Action") + ""; 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 = '

' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "

" + (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.") + '

OwnerFull account, billing, and access control.
AdminWorkspace control, billing, and roster management.
TechWorkspace control without billing or roster ownership.
Read-onlyReview access without control-plane changes.
'; - var accessActions = account.can_manage ? '

Invite people

Add one person with the minimum role they need on this account.

' : '

Need an access change?

An owner or admin on this account must invite people, change roles, or remove access.

'; - return '

' + accessHeaderTitle + "

" + accessHeaderCopy + "

" + renderSectionContextChips(accessContextChips) + '

People on this account

Review the hosted roster here before you open, change, or remove access.

Loading\u2026

' + (account.can_manage ? "Pick the access job" : "This account is view-only for you") + "

" + (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.") + '

' + accessActions + accessPolicy + "
"; + 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 ? '
' : renderSectionContextChips(["View roster", "Owner or admin required"]); + var accessRoleGuide = '

' + (account.can_manage ? "Choose the smallest role" : "Role meanings") + "

" + (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.") + '

OwnerFull account, billing, and access control.
AdminWorkspace control, billing, and roster management.
TechWorkspace control without billing or roster ownership.
Read-onlyReview access without control-plane changes.
'; + var accessInvitePanel = account.can_manage ? '

Invite people

Add one person with the minimum role they need on this account.

' : ""; + var accessChangeRolePanel = '

Change roles on the roster

Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.

' + accessRoleGuide; + var accessRemovePanel = '

Remove stale access

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.

Pick the exact personUse the roster to remove one account member at a time.
Keep current owners safeThe last owner cannot be removed until another owner exists.
'; + return '

' + accessHeaderTitle + "

" + accessHeaderCopy + "

" + accessTaskStrip + '

People on this account

' + (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.") + '

Loading\u2026
' + (account.can_manage ? '" : "") + "
"; } function renderHostedBillingCards(accounts) { var hostedBillingAccounts = accounts.filter(function(account) { diff --git a/internal/cloudcp/portal/frontend/src/account_controller.test.ts b/internal/cloudcp/portal/frontend/src/account_controller.test.ts index 9de2d90c0..f7d348bf9 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.test.ts @@ -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() { '' + '' + '' + + '' + + '' + '' + '' + '' + @@ -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'); diff --git a/internal/cloudcp/portal/frontend/src/account_controller.ts b/internal/cloudcp/portal/frontend/src/account_controller.ts index a3a13bfbd..7f0fd3d77 100644 --- a/internal/cloudcp/portal/frontend/src/account_controller.ts +++ b/internal/cloudcp/portal/frontend/src/account_controller.ts @@ -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); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts index 0f766a395..d17d62dab 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.test.ts @@ -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 = + '
' + + '
' + + '' + + '' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '
'; + + 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 = '
' + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + '
' + '
' + '' + @@ -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'); diff --git a/internal/cloudcp/portal/frontend/src/account_runtime.ts b/internal/cloudcp/portal/frontend/src/account_runtime.ts index b30081ff5..38db6175f 100644 --- a/internal/cloudcp/portal/frontend/src/account_runtime.ts +++ b/internal/cloudcp/portal/frontend/src/account_runtime.ts @@ -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; - toggleAccess(accountID: string): void; ensureAccessVisible(accountID: string): void; + setAccessJob(accountID: string, job: PortalAccessJob): Promise; + clearAccessJob(accountID: string): void; inviteMember(accountID: string): Promise; createWorkspace(accountID: string): Promise; manageWorkspaceAction(accountID: string, tenantID: string, action: string, name: string): Promise; @@ -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 { + 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 { var emailEl = getElement('invite-email-' + accountID); var roleEl = getElement('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, diff --git a/internal/cloudcp/portal/frontend/src/account_view.test.ts b/internal/cloudcp/portal/frontend/src/account_view.test.ts index 0ebc49e14..eab41fcd3 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.test.ts @@ -16,6 +16,7 @@ function createEntry(overrides: Partial = {}): 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 = + '
' + + '
' + + '
' + + '
' + + '
' + + '
' + + '' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + + '
' + + '
'; + + 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 = '
' + @@ -155,6 +215,7 @@ describe('account view', function() { 'acct_1', createEntry({ accessVisible: true, + activeAccessJob: 'change_role', accessQuery: { status: 'ready', error: '', diff --git a/internal/cloudcp/portal/frontend/src/account_view.ts b/internal/cloudcp/portal/frontend/src/account_view.ts index 4f9de95e5..33b796f02 100644 --- a/internal/cloudcp/portal/frontend/src/account_view.ts +++ b/internal/cloudcp/portal/frontend/src/account_view.ts @@ -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('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 = 'Operator' + - 'Role' + - 'Access'; + '' + (activeJob === 'change_role' ? 'New role' : 'Role') + '' + + '' + (activeJob === 'remove' ? 'Remove' : 'Action') + ''; container.appendChild(head); } @@ -367,12 +397,42 @@ export function renderAccessSection(accountID: string, entry: PortalAccountUIEnt var roster = getElement('access-list-' + accountID); if (!section || !roster) return; var rosterPanel = roster.closest('.access-roster') as HTMLElement | null; + 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; @@ -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)); } } diff --git a/internal/cloudcp/portal/frontend/src/shell_view.test.ts b/internal/cloudcp/portal/frontend/src/shell_view.test.ts index d3aa5a5d3..16d985515 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.test.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.test.ts @@ -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() { diff --git a/internal/cloudcp/portal/frontend/src/shell_view.ts b/internal/cloudcp/portal/frontend/src/shell_view.ts index 58f120877..b8a073f8e 100644 --- a/internal/cloudcp/portal/frontend/src/shell_view.ts +++ b/internal/cloudcp/portal/frontend/src/shell_view.ts @@ -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 + ? ( + '
' + + '' + + '' + + '' + + '
' + ) + : renderSectionContextChips(['View roster', 'Owner or admin required']); + var accessRoleGuide = '
' + '
' + '

' + (account.can_manage ? 'Choose the smallest role' : 'Role meanings') + '

' + @@ -915,7 +921,7 @@ function renderAccountAccessSection(account: PortalAccountSummary): string { '
Read-onlyReview access without control-plane changes.
' + '
' + '
'; - var accessActions = account.can_manage + var accessInvitePanel = account.can_manage ? ( '
' + '
' + @@ -939,14 +945,26 @@ function renderAccountAccessSection(account: PortalAccountSummary): string { '
' + '
' ) - : ( - '
' + - '
' + - '

Need an access change?

' + - '

An owner or admin on this account must invite people, change roles, or remove access.

' + - '
' + - '
' - ); + : ''; + var accessChangeRolePanel = + '
' + + '
' + + '

Change roles on the roster

' + + '

Use the role column in the roster to change one person at a time. Keep each person on the smallest role they need.

' + + '
' + + '
' + + accessRoleGuide; + var accessRemovePanel = + '
' + + '
' + + '

Remove stale access

' + + '

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.

' + + '
' + + '
' + + '
Pick the exact personUse the roster to remove one account member at a time.
' + + '
Keep current owners safeThe last owner cannot be removed until another owner exists.
' + + '
' + + '
'; return ( '
' + - '' + '
' + '
' + - '
' + - '
' + - '
' + - '
' + - '

People on this account

' + - '

Review the hosted roster here before you open, change, or remove access.

' + - '
' + - '
' + - '
Loading…
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '
' + - '' + - '

' + (account.can_manage ? 'Pick the access job' : 'This account is view-only for you') + '

' + - '

' + (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.') + '

' + - '
' + - '
' + - accessActions + - accessPolicy + + '
' + + '
' + + '
' + + '
' + + '
' + + '

People on this account

' + + '

' + (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.') + '

' + + '
' + + '
' + + '
Loading…
' + + '
' + '
' + '
' + '
' + + (account.can_manage + ? ( + '' + ) + : '') + '
' + '' + '' diff --git a/internal/cloudcp/portal/frontend/src/state.ts b/internal/cloudcp/portal/frontend/src/state.ts index fefd03932..d70b014c9 100644 --- a/internal/cloudcp/portal/frontend/src/state.ts +++ b/internal/cloudcp/portal/frontend/src/state.ts @@ -79,6 +79,7 @@ export function ensurePortalAccountUIEntry(accountState: PortalAccountState, acc selectedWorkspaceID: '', manageWorkspace: createMutationState(), accessVisible: false, + activeAccessJob: '', accessQuery: createQueryState([]), }; } diff --git a/internal/cloudcp/portal/frontend/src/styles.css b/internal/cloudcp/portal/frontend/src/styles.css index 6b8fa1efd..5e899e82d 100644 --- a/internal/cloudcp/portal/frontend/src/styles.css +++ b/internal/cloudcp/portal/frontend/src/styles.css @@ -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, diff --git a/internal/cloudcp/portal/frontend/src/types.ts b/internal/cloudcp/portal/frontend/src/types.ts index c4cadd118..3e6954ac3 100644 --- a/internal/cloudcp/portal/frontend/src/types.ts +++ b/internal/cloudcp/portal/frontend/src/types.ts @@ -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; }