diff --git a/apps/controller/src/console/agents/detail.mjs b/apps/controller/src/console/agents/detail.mjs index 45138c381a..6d3eaa42d2 100644 --- a/apps/controller/src/console/agents/detail.mjs +++ b/apps/controller/src/console/agents/detail.mjs @@ -900,9 +900,18 @@ function createVersionDeploymentRecord( // it never shows the waiting line (finding 1714). const DIAGNOSTICS_WAITING_DELAY_MS = 250; -function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus = () => null) { +function createVersionDiagnosticsPanel( + context, + path, + revisionId, + recordedStatus = () => null, + operateAllowed = null, +) { const section = element("section", { className: "agent-card version-diagnostics" }); let diagnostics = null; + // Set when the caller's permission answer denies Agent operate, which diagnostics need: the + // button is disabled with the reason, as Stop and Delete are (finding 1992). + let operateDenied = false; let error = null; let loading = false; // Set once a run outlasts DIAGNOSTICS_WAITING_DELAY_MS: the waiting line replaces the last @@ -985,6 +994,10 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus : section.lastElementChild.firstElementChild; focusSaveOutcome(section, [runButton], outcome); outcomeId = runButton.getAttribute("aria-describedby") ? outcome.id : null; + // Focus stays on the button; a refusal below it still comes into view (finding 1992). + if (error) { + outcome?.scrollIntoView({ block: "nearest" }); + } } } } @@ -1093,10 +1106,19 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus // below is the one announcement, and a changed label inside the Agent view's live // region was announced as well (finding 1698). button("Run diagnostics for this version", () => void run(), { - disabled: waitingShown, + disabled: waitingShown || operateDenied, ...(outcomeId ? { "aria-describedby": outcomeId } : {}), }), ), + ...(operateDenied + ? [ + statusBlock( + "info", + null, + "Your access does not include running diagnostics for this Agent.", + ), + ] + : []), element( "p", { className: "muted" }, @@ -1127,6 +1149,12 @@ function createVersionDiagnosticsPanel(context, path, revisionId, recordedStatus } render(); + void operateAllowed?.then((permitted) => { + if (permitted === false && context.isCurrent()) { + operateDenied = true; + render(); + } + }); return { section, render }; } @@ -2621,8 +2649,12 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null () => diagnosticsPanel?.render(), { shared: activityRevisionId === selected && !latestDeploymentError }, ); - diagnosticsPanel = createVersionDiagnosticsPanel(context, path, selected, () => - versionRecord.current(), + diagnosticsPanel = createVersionDiagnosticsPanel( + context, + path, + selected, + () => versionRecord.current(), + callerPermissionsRead.then((permissions) => permissions?.operate), ); versionEvidence.append(versionRecord.section, diagnosticsPanel.section); if (latestDeploymentRecord?.revisionId === selected) { @@ -4184,6 +4216,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null editorError.clear(); feedback.textContent = ""; setStatusTone(feedback, null); + feedback.setAttribute("role", "status"); reloadRequired = false; setDraftEditorState({ dirty: false, @@ -4280,6 +4313,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null editorError.clear(); feedback.textContent = ""; setStatusTone(feedback, null); + feedback.setAttribute("role", "status"); feedbackLocked = false; reloadRequired = false; updateState(); @@ -4288,6 +4322,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null if (draftEditorNavigationBlock) { feedback.textContent = draftEditorNavigationBlock; setStatusTone(feedback, null); + feedback.setAttribute("role", "status"); feedbackLocked = true; } }; @@ -4304,6 +4339,7 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null pending = true; feedback.textContent = "Checking Configuration…"; setStatusTone(feedback, null); + feedback.setAttribute("role", "status"); feedbackLocked = true; const saveFocused = document.activeElement === save; updateState(); @@ -4360,6 +4396,8 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null context.onExpired(); return; } + // Every refusal below is a danger block: an alert before its text is written. + feedback.setAttribute("role", "alert"); if (!configurationSaved) { refusedSave(error, "configuration", mutationStarted); } @@ -4381,10 +4419,19 @@ export async function renderAgentDetail(context, { agent: preloadedAgent = null pending = false; updateState(); render(); - if (container.isConnected) { + const refused = feedback.dataset.tone === "danger"; + feedback.setAttribute("role", refused ? "alert" : "status"); + if (container.isConnected && refused && !outcomeUnknown && !reloadRequired) { + // A refusal Reload cannot fix (a 403, say) is an alert that takes focus and comes + // into view; under the fold, nothing seemed to happen (finding 1991). + revealStatus(feedback, container); + } else if (container.isConnected) { // A conflict or failure leaves Reload draft (or Save again) as the next step // (finding 1481). focusSaveOutcome(container, [reload, save], feedback); + if (refused && container.contains(document.activeElement)) { + feedback.scrollIntoView({ block: "nearest" }); + } } } } diff --git a/scripts/console-storybook/pages-agent-detail.stories.mjs b/scripts/console-storybook/pages-agent-detail.stories.mjs index ac97b1b0b0..4faf84858e 100644 --- a/scripts/console-storybook/pages-agent-detail.stories.mjs +++ b/scripts/console-storybook/pages-agent-detail.stories.mjs @@ -9,6 +9,7 @@ export const NewVersion = story("newVersion"); export const LongVersionHistory = story("longVersionHistory"); export const FirstDeployment = story("draftAutomaticCredentials"); export const ConfigurationEditor = { ...story("configurationEditor"), name: "Edit Configuration" }; +export const ConfigurationSaveRefused = story("configurationSaveRefused"); export const GatewayPasswordAccess = story("gatewayPasswordAccess"); export const GatewayPasswordEnabled = story("gatewayPasswordEnabled"); export const GatewayPasswordSaveDenied = story("gatewayPasswordSaveDenied"); @@ -65,6 +66,8 @@ export const DeploymentSucceeded = story("deploymentSucceeded"); export const DeploymentUnavailable = story("deploymentUnavailable"); export const DiagnosticsSuccess = story("diagnosticsSuccess"); export const DiagnosticsFast = story("diagnosticsFast"); +export const DiagnosticsReader = story("diagnosticsReader"); +export const DiagnosticsRefused = story("diagnosticsRefused"); export const DiagnosticsUnknown = story("diagnosticsUnknown"); export const DiagnosticsUnavailable = story("diagnosticsUnavailable"); export const RuntimeLogs = story("runtimeLogs"); diff --git a/scripts/console-storybook/public/scenarios.mjs b/scripts/console-storybook/public/scenarios.mjs index b0254f8085..8bebd1eb44 100644 --- a/scripts/console-storybook/public/scenarios.mjs +++ b/scripts/console-storybook/public/scenarios.mjs @@ -3237,6 +3237,22 @@ export const scenarios = { "Edit and save once more. Move focus to the Agents link before the save finishes; focus stays on the link.", ], }, + configurationSaveRefused: { + group: "Pages/Agent detail", + name: "Edit Configuration refused", + path: draft, + rules: [ + { + method: "PATCH", + suffix: "/configurations/cfg_00000000-0000-4000-8000-000000000001", + status: 403, + code: "FORBIDDEN", + }, + ], + actions: [click("Enable gateway password access"), click("Save Configuration")], + description: + "A viewer without Configuration update saves: the refusal is an alert that takes focus and comes into view.", + }, gatewayPasswordAccess: { group: "Pages/Agent detail", name: "Enable gateway password access", @@ -4138,6 +4154,28 @@ export const scenarios = { description: "Diagnostics that answer within 250 ms replace the panel's result directly, without painting the waiting line or moving focus.", }, + diagnosticsReader: { + group: "Pages/Agent detail", + name: "Current observations for a reader without operate", + path: candidateVersion, + deployed: true, + candidateDeploymentStatus: "succeeded", + agentPermissionSummary: { update: true, deploy: true, operate: false, delete: true }, + description: + "Diagnostics need Agent operate. For a viewer whose permission answer denies it, Run diagnostics is disabled and says why, as Stop Agent does.", + gap: "The permission answer is simulated; the API still authorizes every request.", + }, + diagnosticsRefused: { + group: "Pages/Agent detail", + name: "Current observations refused", + path: candidateVersion, + deployed: true, + candidateDeploymentStatus: "succeeded", + rules: [{ suffix: "/diagnostics", method: "POST", status: 403, code: "FORBIDDEN" }], + actions: [click("Run diagnostics for this version")], + description: + "The API refuses diagnostics. The refusal comes into view below the button, which keeps focus.", + }, diagnosticsUnknown: { group: "Pages/Agent detail", name: "Unknown observation for v6", diff --git a/scripts/console-storybook/status-block-workflow.md b/scripts/console-storybook/status-block-workflow.md index f52780eba0..40d87c074d 100644 --- a/scripts/console-storybook/status-block-workflow.md +++ b/scripts/console-storybook/status-block-workflow.md @@ -31,3 +31,13 @@ at 390. The partial-save message is a warning block. 10. Open **Pages / Agent detail / Repository access save refused**. The refusal under the editor is a danger block with focus. +11. Open **Pages / Agent detail / Edit Configuration refused**. The refusal + under the editor is a danger block with `role="alert"`. It has focus and + is fully on screen at 1440 and 390. +12. Open **Pages / Agent detail / Current observations for a reader without + operate**. Run diagnostics for this version is disabled, and an info + block says "Your access does not include running diagnostics for this + Agent.", as Stop Agent does. +13. Open **Pages / Agent detail / Current observations refused**. Scroll + until Run diagnostics sits at the bottom of the screen and click it. The + refusal below it comes into view; focus stays on the button. diff --git a/tests/browser/console-status-block.test.mjs b/tests/browser/console-status-block.test.mjs index 87ce02584d..d93d63e787 100644 --- a/tests/browser/console-status-block.test.mjs +++ b/tests/browser/console-status-block.test.mjs @@ -11,7 +11,10 @@ import { newPage, selectSecret, } from "./console-agents-browser-helpers.mjs"; -import { createRepositoryLaunchFixture } from "./console-agents-test-support.mjs"; +import { + createRepositoryLaunchFixture, + openAdvancedSettings, +} from "./console-agents-test-support.mjs"; // Errors, warnings and notices share one status block (UX plan C1): a stripe and wash in the // tone's design-token colours, with the text in the body colour. A refusal after a request takes @@ -366,3 +369,146 @@ test("a refused repository access save is a focused danger block", async (t) => assert.equal(view.focused, true, "the refusal has focus"); assert.equal(view.inView, true, "the refusal is on screen"); }); + +// A member who reads the Agent, its versions and its Configuration, and may change none of them. +function createReader(fixture, namespace, name) { + return fixture.createAccountWithPolicy(name, (principal) => { + fixture.policy.roles.push({ + id: `role-${name}`, + namespaceId: namespace.id, + permissions: ["namespace", "agent", "agent_revision", "configuration"].map( + (resourceKind) => ({ action: "read", resourceKind }), + ), + }); + fixture.policy.bindings.push({ + id: `binding-${name}`, + namespaceId: namespace.id, + subjectKind: "identity", + subjectId: principal.id, + roleId: `role-${name}`, + }); + }); +} + +const CONFIGURATION_REFUSAL = + "You do not have permission to save this Configuration. Ask an administrator for Configuration update access, plus Secret operate on each Secret it binds."; + +// A reader's refused Configuration save was a status line under the editor, neither announced +// as an alert nor focused, and cut by the fold at 1440 (finding 1991). +for (const viewport of [ + { width: 1440, height: 900 }, + { width: 390, height: 844 }, +]) { + test(`a reader's refused Configuration save is a focused alert on screen at ${viewport.width}px`, async (t) => { + const fixture = await createConsoleAppFixture(t); + await fixture.bootstrap(); + const namespace = await fixture.createNamespace("Status config refusal", { ready: true }); + const agent = await fixture.createAgent(namespace.id, "Status config", nativeValues("cfg")); + const reader = await createReader(fixture, namespace, "status-config-reader"); + const { page } = await newPage(t, fixture, { browser, context: { viewport } }); + await login( + page, + fixture, + detailUrl(fixture, namespace.id, agent.id, "draft", "configuration"), + reader.credentials, + ); + await page.getByRole("button", { name: "Edit Configuration" }).click(); + await openAdvancedSettings(page); + const editor = page.getByLabel("Configuration JSON"); + await editor.fill(JSON.stringify(nativeValues("edited by a reader"), null, 2)); + await page.getByRole("button", { name: "Save Configuration" }).click(); + const alert = page.getByRole("alert").filter({ hasText: CONFIGURATION_REFUSAL }); + await alert.waitFor(); + const view = await refusalView(alert); + t.diagnostic(JSON.stringify(view)); + assert.equal(view.focused, true, "the refusal has focus"); + assert.equal(view.inView, true, "the refusal is on screen"); + assertPaint(await statusPaint(alert), "danger", "Configuration refusal"); + // An edit clears it; the line is a status again for the next save's progress. + await editor.fill(JSON.stringify(nativeValues("edited again"), null, 2)); + assert.equal( + await page.locator("#configuration-editor-feedback").getAttribute("role"), + "status", + ); + }); +} + +// Run diagnostics needs Agent operate. A reader without it saw the button enabled beside a +// disabled Stop and Delete, and its 403 landed below the fold at 1440 (finding 1992). +test("Run diagnostics is disabled with its reason for a reader without operate", async (t) => { + const fixture = await createConsoleAppFixture(t); + await fixture.bootstrap(); + const namespace = await fixture.createNamespace("Status diagnostics reader", { ready: true }); + const agent = await fixture.createAgent(namespace.id, "Diagnostics reader", nativeValues("dr")); + const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const reader = await createReader(fixture, namespace, "status-diagnostics-reader"); + const { page } = await newPage(t, fixture, { browser }); + await login( + page, + fixture, + detailUrl(fixture, namespace.id, agent.id, active.revision.id, "configuration"), + reader.credentials, + ); + // The same answer that disables Stop disables Run diagnostics. + await page.getByText("Your access does not include stopping this Agent.").waitFor(); + const observations = page.locator(".version-diagnostics"); + const reason = observations + .getByRole("status") + .filter({ hasText: "Your access does not include running diagnostics for this Agent." }); + await reason.waitFor(); + assertPaint(await statusPaint(reason), "info", "diagnostics reason"); + const run = observations.getByRole("button", { name: "Run diagnostics for this version" }); + assert.equal(await run.isDisabled(), true); +}); + +// A 403 that still happens (the permission answer allowed it, the API refused) comes into view +// below the button, which keeps focus (since #2779). +for (const viewport of [ + { width: 1440, height: 900 }, + { width: 390, height: 844 }, +]) { + test(`a refused Run diagnostics brings its alert on screen at ${viewport.width}px`, async (t) => { + const fixture = await createConsoleAppFixture(t); + await fixture.bootstrap(); + const namespace = await fixture.createNamespace("Status diagnostics refusal", { ready: true }); + const agent = await fixture.createAgent(namespace.id, "Diagnostics denied", nativeValues("dd")); + const active = await fixture.seedActiveAgentRevision(namespace.id, agent.id); + const { page } = await newPage(t, fixture, { browser, context: { viewport } }); + await page.route("**/deployments/*/diagnostics", (route) => + route.fulfill({ + status: 403, + contentType: "application/json", + body: JSON.stringify({ error: { code: "FORBIDDEN", message: "Forbidden" } }), + }), + ); + await login( + page, + fixture, + detailUrl(fixture, namespace.id, agent.id, active.revision.id, "configuration"), + ); + const observations = page.locator(".version-diagnostics"); + const run = observations.getByRole("button", { name: "Run diagnostics for this version" }); + await run.waitFor(); + // As the live walk did: the button at the bottom of the screen, pressed by pointer. + await run.evaluate((node) => node.scrollIntoView({ block: "end" })); + await run.click(); + const alert = observations + .getByRole("alert") + .filter({ hasText: "Diagnostics require Agent read and operate access" }); + await alert.waitFor(); + const view = await refusalView(alert); + t.diagnostic(JSON.stringify(view)); + assert.equal(view.inView, true, "the refusal is on screen"); + assert.deepEqual( + await run.evaluate((node) => { + const { top, bottom } = node.getBoundingClientRect(); + return { + focused: globalThis.document.activeElement === node, + inView: top >= 0 && bottom <= globalThis.innerHeight, + }; + }), + { focused: true, inView: true }, + "the button keeps focus, on screen", + ); + }); +}