diff --git a/frontend/e2e/labels-operation-picker.spec.ts b/frontend/e2e/labels-operation-picker.spec.ts index 15621580b7..6bd0687692 100644 --- a/frontend/e2e/labels-operation-picker.spec.ts +++ b/frontend/e2e/labels-operation-picker.spec.ts @@ -135,7 +135,7 @@ async function openOperationPicker(page: Page) { } test.describe("operation picker placement", () => { - test("highlights the placeholder operator and requires resolving a proposed operation", async ({ page }) => { + test("requires resolving a proposed operation", async ({ page }) => { await setupMocks(page, [], { defaultLabels: { operator: "roakey" }, defaultOperation: "op_trash_panda", @@ -143,24 +143,15 @@ test.describe("operation picker placement", () => { }); await page.goto("/"); - const operator = page.getByTestId("edit-label-operator").locator(".."); - const operation = page.getByTestId("edit-label-operation").locator(".."); - await expect(operator).toHaveCSS("background-color", "rgb(254, 247, 178)"); await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_trash_panda"); - await expect(operation).toHaveCSS("background-color", "rgb(254, 247, 178)"); await expect(page.getByTestId("edit-label-operation")).toHaveAccessibleDescription("Not registered"); await expect(page.getByRole("button", { name: "Register", exact: true })).toBeVisible(); await expect(page.getByRole("button", { name: "Choose existing", exact: true })).toHaveCount(0); - await page.getByTestId("edit-label-operator").fill("alice"); - await page.getByTestId("edit-label-operator").press("Enter"); - await expect(operator).not.toHaveCSS("background-color", "rgb(254, 247, 178)"); - await page.getByTestId("edit-label-operation").click(); await page.getByRole("option", { name: "op_real", exact: true }).click(); await expect(page.getByTestId("edit-label-operation")).toHaveValue("op_real"); await expect(page.getByText("Not registered", { exact: true })).toHaveCount(0); - await expect(operation).not.toHaveCSS("background-color", "rgb(254, 247, 178)"); }); test("keeps metadata editable in a narrow bar without duplicating it in Default Labels", async ({ page }) => { diff --git a/frontend/src/App.labels.test.tsx b/frontend/src/App.labels.test.tsx index aa82c8fe7d..fa603f3dd7 100644 --- a/frontend/src/App.labels.test.tsx +++ b/frontend/src/App.labels.test.tsx @@ -409,7 +409,11 @@ describe('Shared new run labels', () => { const picker = await within(currentLabels()).findByRole('combobox', { name: 'Operation' }) await waitFor(() => expect(picker).toHaveValue(proposed)) expect(within(currentLabels()).getByText('Not registered')).toBeInTheDocument() - expect(await screen.findByText(/Choose a saved Operation, register, or clear this one/)).toBeInTheDocument() + expect(screen.queryByText(/Choose a saved Operation, register, or clear this one/)).not.toBeInTheDocument() + await user.click(within(currentLabels()).getByRole('button', { name: 'Show warnings' })) + expect(await screen.findByRole('heading', { name: 'Warnings' })).toBeInTheDocument() + expect(screen.getByText(/Choose a saved Operation, register, or clear this one/)).toBeInTheDocument() + await user.keyboard('{Escape}') await user.selectOptions(await screen.findByRole('combobox', { name: 'Objective Target' }), 'test_target') expect(screen.getByRole('button', { name: 'Launch scan' })).toBeDisabled() expect(operationsApi.create).not.toHaveBeenCalled() diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3e76f74937..18627c646f 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -67,6 +67,7 @@ import { } from './utils/targetIdentity' import { attacksApi, authApi, versionApi } from './services/api' import { toApiError } from './services/errors' +import { getOperationWarning } from '@/utils/operationWarnings' import { useTour } from './hooks/useTour' import { ChatDraftProvider, useChatDraftHandoff } from './hooks/useChatDraftHandoff' import { @@ -297,7 +298,7 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { const operationId = operation.state.status === 'resolved' ? operation.state.operation.id : null const newActivityReady = defaultsReady && operationReady const operationBlockedReason = defaultsReady && !operationReady && operation.state.status !== 'loading' - ? 'Choose a saved Operation, register, or clear this one to start new attacks and scans.' + ? getOperationWarning(operation.state) : undefined const { remember: rememberOperation } = operation const handleOperationChange = useCallback((selected: Operation | null) => { @@ -846,11 +847,6 @@ function AppContent({ operatorAlias }: { operatorAlias: string | null }) { )} - {operationBlockedReason && ( - - {operationBlockedReason} - - )} {preferenceError && ( {preferenceError} diff --git a/frontend/src/components/Labels/LabelsBar.operations.test.tsx b/frontend/src/components/Labels/LabelsBar.operations.test.tsx index d9011be201..84c2fba366 100644 --- a/frontend/src/components/Labels/LabelsBar.operations.test.tsx +++ b/frontend/src/components/Labels/LabelsBar.operations.test.tsx @@ -161,7 +161,10 @@ describe('saved operation workflow', () => { ] as OperationSelectionState[])('chooses an existing operation or clears from %j', async (initial: OperationSelectionState) => { const user = userEvent.setup() renderBar(initial) - if (initial.status === 'missing') expect(screen.getByText('Saved operation not found')).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Show warnings' })) + expect(screen.getByText(initial.status === 'missing' + ? /The saved operation was not found/ : /Choose a saved Operation/)).toBeInTheDocument() + await user.keyboard('{Escape}') await user.click(screen.getByRole('combobox', { name: 'Operation' })) await user.click(await screen.findByRole('option', { name: SAVED.name })) expect(onSelect).toHaveBeenLastCalledWith(SAVED) @@ -174,7 +177,9 @@ describe('saved operation workflow', () => { it('keeps the selection and offers retry when the selected operation cannot be loaded', async () => { const user = userEvent.setup() renderBar({ status: 'error', error: 'Storage unavailable' }) + await user.click(screen.getByRole('button', { name: 'Show warnings' })) expect(screen.getByText(/Could not load the selected operation: Storage unavailable/)).toBeInTheDocument() + await user.keyboard('{Escape}') await user.click(screen.getByRole('button', { name: 'Retry' })) expect(onRetry).toHaveBeenCalledTimes(1) expect(onSelect).not.toHaveBeenCalled() diff --git a/frontend/src/components/Labels/LabelsBar.styles.ts b/frontend/src/components/Labels/LabelsBar.styles.ts index d4f42ef875..2e3cd848c8 100644 --- a/frontend/src/components/Labels/LabelsBar.styles.ts +++ b/frontend/src/components/Labels/LabelsBar.styles.ts @@ -35,8 +35,8 @@ export const useLabelsBarStyles = makeStyles({ }, metadata: { display: 'flex', flexWrap: 'wrap', gap: tokens.spacingHorizontalM, minWidth: 0 }, metadataField: { display: 'flex', alignItems: 'center', gap: tokens.spacingHorizontalXS, minWidth: 0 }, - metadataPlaceholder: { - backgroundColor: tokens.colorPaletteYellowBackground2, + metadataWarning: { + backgroundColor: tokens.colorPaletteYellowBackground1, border: `1px solid ${tokens.colorPaletteYellowBorder1}`, }, srOnly: { @@ -199,6 +199,8 @@ export const useLabelsBarStyles = makeStyles({ }, warningIcon: { color: tokens.colorPaletteYellowForeground2, + backgroundColor: tokens.colorPaletteYellowBackground1, + border: `1px solid ${tokens.colorPaletteYellowBorder1}`, display: 'flex', alignItems: 'center', flexShrink: 0, diff --git a/frontend/src/components/Labels/LabelsBar.test.tsx b/frontend/src/components/Labels/LabelsBar.test.tsx index c090c2de1f..7847968716 100644 --- a/frontend/src/components/Labels/LabelsBar.test.tsx +++ b/frontend/src/components/Labels/LabelsBar.test.tsx @@ -47,16 +47,6 @@ describe('LabelsBar', () => { expect(screen.getByTestId('edit-label-operation')).toHaveValue('op_saved') }) - it('should show warning icon for dummy values', () => { - render( - - - - ) - - expect(screen.getByTestId('labels-warning')).toBeInTheDocument() - }) - it.each([ ['a placeholder', { operator: 'roakey' }, false, 'Placeholder value — type to change'], ['the signed-in account', { operator: 'alice' }, true, 'Derived from your signed-in account'], @@ -105,10 +95,13 @@ describe('LabelsBar', () => { expect(onChange).not.toHaveBeenCalled() }) - it('should not show warning when values are customized', () => { + it.each([ + { operator: 'alice', operatorReadOnly: false }, + { operator: 'roakey', operatorReadOnly: true }, + ])('should not warn for operator $operator when read-only is $operatorReadOnly', ({ operator, operatorReadOnly }) => { render( - ) @@ -121,6 +114,7 @@ describe('LabelsBar', () => { labels: { ...DEFAULT_GLOBAL_LABELS }, message: 'Set operator in the bar. The current value is a placeholder.', }, + { labels: {}, message: 'Set operator in the bar. No operator is set.' }, ])('should explain the warning: $message', async ({ labels, message }: { labels: Record message: string diff --git a/frontend/src/components/Labels/LabelsBar.tsx b/frontend/src/components/Labels/LabelsBar.tsx index 746f257b5c..c89a190a5f 100644 --- a/frontend/src/components/Labels/LabelsBar.tsx +++ b/frontend/src/components/Labels/LabelsBar.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback, useRef, useMemo } from 'react' +import { useState, useEffect, useRef, useMemo } from 'react' import { Text, Button, @@ -18,8 +18,10 @@ import { import type { OperationSelectionState } from '@/hooks/useOperationSelection' import { labelsApi } from '@/services/api' import type { Operation } from '@/types' +import { getOperationWarning } from '@/utils/operationWarnings' import OperationPicker from './OperationPicker' import { useLabelsBarStyles } from './LabelsBar.styles' +import { DEFAULT_GLOBAL_LABELS } from './labelDefaults' const validateValue = (value: string): string | null => { @@ -30,10 +32,6 @@ const validateValue = (value: string): string | null => { return null } -const DUMMY_VALUES: Record = { - operator: 'roakey', -} - const METADATA_KEYS = new Set(['operator', 'operation']) export interface OperationControlProps { @@ -107,22 +105,19 @@ export default function LabelsBar({ .catch(() => setError('Could not load label suggestions.')) }, []) - const isDummyValue = useCallback((key: string, value: string): boolean => { - return DUMMY_VALUES[key] === value - }, []) - - const placeholderKeys = Object.entries(labels) - .filter(([key, value]) => isDummyValue(key, value)) - .map(([key]) => key) + const operatorMissing = !labels.operator?.trim() + const operatorPlaceholder = labels.operator === DEFAULT_GLOBAL_LABELS.operator + const operatorWarning = operatorReadOnly ? null + : operatorMissing ? 'Set operator in the bar. No operator is set.' + : operatorPlaceholder ? 'Set operator in the bar. The current value is a placeholder.' : null + const operationWarning = getOperationWarning(operation) const warnings = [ - ...(placeholderKeys.length > 0 - ? [`Set ${placeholderKeys.join(' and ')} in the bar. ${ - placeholderKeys.length === 1 ? 'The current value is a placeholder.' : 'The current values are placeholders.' - }`] : []), - ...(operation.status === 'empty' ? ['Set operation in the bar. No operation is selected.'] : []), + ...(operatorWarning ? [operatorWarning] : []), + ...(operationWarning ? [operationWarning] : []), ] const operatorTip = operatorReadOnly ? 'Derived from your signed-in account' - : isDummyValue('operator', labels.operator ?? '') ? 'Placeholder value — type to change' : null + : operatorMissing ? 'No operator is set. Type your operator name.' + : operatorPlaceholder ? 'Placeholder value — type to change' : null const validateKey = (key: string): string | null => { if (!key) return 'Key is required' @@ -593,7 +588,7 @@ export default function LabelsBar({ Operation: - + {headerEntries diff --git a/frontend/src/components/Labels/OperationPicker.tsx b/frontend/src/components/Labels/OperationPicker.tsx index 5f02ec3c94..ee1c788577 100644 --- a/frontend/src/components/Labels/OperationPicker.tsx +++ b/frontend/src/components/Labels/OperationPicker.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from 'react' -import { Button, Combobox, MessageBar, MessageBarBody, mergeClasses, Option, Text, useId } from '@fluentui/react-components' +import { Button, Combobox, MessageBar, MessageBarBody, mergeClasses, Option, useId } from '@fluentui/react-components' import { DismissRegular } from '@fluentui/react-icons' import OperationCreateDialog from '@/components/Operations/OperationCreateDialog' @@ -13,11 +13,12 @@ const MAX_LISTED = 200 interface OperationPickerProps { selection: OperationSelectionState + warning?: string onSelect: (operation: Operation | null) => void onRetry: () => void } -export default function OperationPicker({ selection, onSelect, onRetry }: OperationPickerProps) { +export default function OperationPicker({ selection, warning, onSelect, onRetry }: OperationPickerProps) { const styles = useLabelsBarStyles() const unregisteredId = useId('operation-unregistered') const rootRef = useRef(null) @@ -71,8 +72,7 @@ export default function OperationPicker({ selection, onSelect, onRetry }: Operat
Not registered } - {selection.status === 'missing' && Saved operation not found} - {(selection.status === 'unregistered' || selection.status === 'missing') && ( + {selection.status === 'error' && ( + + )} + {(selection.status === 'unregistered' || selection.status === 'missing' || selection.status === 'error') && ( )}
- {selection.status === 'error' && ( - - Could not load the selected operation: {selection.error}{' '} - - - - )} {!loading && error && ( {error}{' '} diff --git a/frontend/src/utils/operationWarnings.ts b/frontend/src/utils/operationWarnings.ts new file mode 100644 index 0000000000..4868f113af --- /dev/null +++ b/frontend/src/utils/operationWarnings.ts @@ -0,0 +1,16 @@ +import type { OperationSelectionState } from '@/hooks/useOperationSelection' + +export function getOperationWarning(selection: OperationSelectionState): string | undefined { + switch (selection.status) { + case 'empty': + return 'Set operation in the bar. No operation is selected.' + case 'unregistered': + return 'Choose a saved Operation, register, or clear this one to start new attacks and scans.' + case 'missing': + return 'The saved operation was not found. Select another operation or clear it to start new attacks and scans.' + case 'error': + return `Could not load the selected operation: ${selection.error}. Retry or clear it in the bar to start new attacks and scans.` + default: + return undefined + } +}