diff --git a/.pyrit_conf_example b/.pyrit_conf_example index 5f45135bfd..978c8a293f 100644 --- a/.pyrit_conf_example +++ b/.pyrit_conf_example @@ -72,7 +72,7 @@ operation: op_trash_panda # Initialization Scripts # ---------------------- -# List of paths to custom Python scripts containing PyRITInitializer subclasses. +# List of local paths to Python scripts containing PyRITInitializer subclasses. # Paths can be absolute or relative to the current working directory. # # Behavior: @@ -123,6 +123,12 @@ max_concurrent_scenario_runs: 3 # Default: false allow_custom_initializers: false +# Optional storage for custom initializer Python scripts. This may be a local +# directory or an Azure Blob container URI with an optional blob prefix. +# Container URIs may include a SAS; otherwise DefaultAzureCredential is used. Defaults to +# ~/.pyrit/custom_initializers. +# custom_initializers_source: https://account.blob.core.windows.net/container/custom_initializers + # Local Backend Server # -------------------- # Client settings used by pyrit_scan when connecting to or launching a backend. diff --git a/doc/getting_started/pyrit_conf.md b/doc/getting_started/pyrit_conf.md index 10032e1590..fb498a9bec 100644 --- a/doc/getting_started/pyrit_conf.md +++ b/doc/getting_started/pyrit_conf.md @@ -143,9 +143,19 @@ initializers: Full preload can take several minutes and may require network access, provider credentials, or acceptance of gated dataset licenses. When preloading during local backend startup, increase `server.startup_timeout` if the configured timeout is not long enough. +### `custom_initializers_source` + +Stores custom initializer Python files in a local directory or Azure Blob container. An Azure URI may include a blob-name prefix, which behaves like a folder: + +```yaml +custom_initializers_source: https://account.blob.core.windows.net/pyrit-storage/custom-initializers +``` + +With this configuration, PyRIT reads and writes scripts directly under the `custom-initializers/` prefix in the `pyrit-storage` container. A SAS query string may be included; otherwise, PyRIT uses `DefaultAzureCredential`. The default is `~/.pyrit/custom_initializers`. + ### `initialization_scripts` -Paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory. +Local paths to custom Python scripts containing `PyRITInitializer` subclasses. Paths can be absolute or relative to the current working directory. | Value | Behavior | | ----------------- | ---------------------------------- | diff --git a/doc/gui/0_gui.md b/doc/gui/0_gui.md index b21c31701f..85097721fd 100644 --- a/doc/gui/0_gui.md +++ b/doc/gui/0_gui.md @@ -16,6 +16,10 @@ pyrit_backend Then open `http://localhost:8000` in your browser. +Authentication-disabled local servers deny administrator operations by default. To enable configuration and initializer +administration for a trusted local development server, set `PYRIT_ALLOW_UNAUTHENTICATED_ADMIN=true`. Never use this +setting on a network-accessible deployment. + ### Docker CoPyRIT is also available as a Docker container. See the [Docker setup](https://github.com/microsoft/PyRIT/blob/main/docker/) for details. @@ -173,6 +177,16 @@ For `AzureMLChatTarget`, additional fields are available: **Max New Tokens**, ** Targets can also be auto-populated by adding the `target` initializer to your `~/.pyrit/.pyrit_conf` file. This reads endpoints from your `.env` and `.env.local` files. See [.pyrit_conf_example](https://github.com/microsoft/PyRIT/blob/main/.pyrit_conf_example) for details. +### Configuration Editor + +The **Configuration** page provides administrator-only editing for the files and scripts used to configure PyRIT. It has three tabs: + +- **PyRIT Configuration** edits the active `.pyrit_conf` YAML file. The source may be a local file or an Azure Blob URI. Saving validates the configuration before replacing it. +- **Environment & Secrets** lists the configured local dotenv files and Azure Key Vault bootstrap secrets. Content is loaded only after selecting a source. Saves validate the dotenv document and reject the update if the source changed since it was loaded. +- **Custom Initializers** registers or removes Python initializer scripts. This tab requires `allow_custom_initializers: true`; scripts are stored in the configured local directory or Azure Blob container and must define a concrete `PyRITInitializer` subclass. + +Use **Reload** to discard local edits and fetch the latest source content. Saved configuration and environment changes take effect after restarting PyRIT. Custom initializer scripts execute under the backend service identity, so only trusted administrators should manage them. + ### Initializers The **Initializers** page (in the left navigation) lets you review and extend how PyRIT sets itself up at startup — for example, the `target` initializer's `tags` and `auto_group` settings. diff --git a/docker/start.sh b/docker/start.sh index 927edaf6d4..728c190475 100644 --- a/docker/start.sh +++ b/docker/start.sh @@ -37,16 +37,15 @@ fi echo "Checking PyRIT installation..." python -c "import pyrit; print(f'Running PyRIT version: {pyrit.__version__}')" -# Write .env from the Container App's `env-file` secret. deploy_instance.py -# supplies it inline. The optional Key Vault-backed Bicep path requires both -# Key Vault Secrets User and an explicit vault network path because ACA is not -# currently listed in Key Vault's trusted-services firewall bypass. +# Write .env when deploy_instance.py supplies inline content. Otherwise the +# generated PyRIT config uses the Key Vault URL from PYRIT_ENV_AKV_REF so the +# backend can read and update that environment source through managed identity. if [ -n "$PYRIT_ENV_CONTENTS" ]; then mkdir -p ~/.pyrit echo "$PYRIT_ENV_CONTENTS" > ~/.pyrit/.env echo "Wrote .env file from PYRIT_ENV_CONTENTS ($(wc -l < ~/.pyrit/.env) lines)" else - echo "No PYRIT_ENV_CONTENTS set — using system environment variables only" + echo "No inline PYRIT_ENV_CONTENTS set — using configured environment sources" fi # Start the appropriate service based on PYRIT_MODE @@ -57,28 +56,41 @@ if [ "$PYRIT_MODE" = "jupyter" ]; then exec jupyter lab --ip=0.0.0.0 --port=8888 --no-browser --allow-root --notebook-dir=/app/notebooks elif [ "$PYRIT_MODE" = "gui" ]; then echo "Starting PyRIT GUI on port 8000..." - # The thin backend only takes --host/--port/--config-file/--log-level. - # Translate AZURE_SQL_SERVER and PYRIT_INITIALIZER into a runtime config file - # so the FastAPI lifespan (ConfigurationLoader) picks them up on startup. - RUNTIME_CONFIG=/tmp/pyrit_runtime.yaml - { - if [ -n "$AZURE_SQL_SERVER" ]; then - echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" >&2 - echo "memory_db_type: AzureSQL" - else - echo "Using SQLite database (AZURE_SQL_SERVER not set)" >&2 - echo "memory_db_type: SQLite" + if [ -n "${PYRIT_CONFIG_FILE:-}" ]; then + CONFIG_FILE="$PYRIT_CONFIG_FILE" + echo "Using external PyRIT configuration" + if [ -n "${PYRIT_ENV_AKV_REF:-}" ]; then + echo "WARNING: Ignoring PYRIT_ENV_AKV_REF because the external PyRIT configuration controls environment sources" >&2 fi - if [ -n "$PYRIT_INITIALIZER" ]; then - echo "Using initializer: $PYRIT_INITIALIZER" >&2 - echo "initializers:" - # Split comma-separated initializer names into a YAML list. - IFS=',' read -ra INIT_NAMES <<<"$PYRIT_INITIALIZER" - for name in "${INIT_NAMES[@]}"; do - echo " - $(echo "$name" | xargs)" - done - fi - } >"$RUNTIME_CONFIG" + else + # Translate deployment settings into a runtime config file so the FastAPI + # lifespan (ConfigurationLoader) picks them up on startup. + RUNTIME_CONFIG=/tmp/pyrit_runtime.yaml + { + if [ -n "$AZURE_SQL_SERVER" ]; then + echo "Using Azure SQL database (server: $AZURE_SQL_SERVER)" >&2 + echo "memory_db_type: AzureSQL" + else + echo "Using SQLite database (AZURE_SQL_SERVER not set)" >&2 + echo "memory_db_type: SQLite" + fi + if [ -n "$PYRIT_INITIALIZER" ]; then + echo "Using initializer: $PYRIT_INITIALIZER" >&2 + echo "initializers:" + # Split comma-separated initializer names into a YAML list. + IFS=',' read -ra INIT_NAMES <<<"$PYRIT_INITIALIZER" + for name in "${INIT_NAMES[@]}"; do + echo " - $(echo "$name" | xargs)" + done + fi + if [ -n "$PYRIT_ENV_AKV_REF" ]; then + echo "Using Azure Key Vault environment reference" >&2 + echo "env_akv_ref:" + echo " - $PYRIT_ENV_AKV_REF" + fi + } >"$RUNTIME_CONFIG" + CONFIG_FILE="$RUNTIME_CONFIG" + fi # Pick the launcher module. PR #1753 moved the launcher from # ``pyrit.cli.pyrit_backend`` to ``pyrit.backend.pyrit_backend``. The PyPI @@ -99,7 +111,7 @@ elif [ "$PYRIT_MODE" = "gui" ]; then exec python -m "$BACKEND_MODULE" \ --host 0.0.0.0 \ --port 8000 \ - --config-file "$RUNTIME_CONFIG" + --config-file "$CONFIG_FILE" else echo "ERROR: Invalid PYRIT_MODE '$PYRIT_MODE'. Must be 'jupyter' or 'gui'" exit 1 diff --git a/frontend/e2e/accessibility.spec.ts b/frontend/e2e/accessibility.spec.ts index b4e437eab4..a47a16d286 100644 --- a/frontend/e2e/accessibility.spec.ts +++ b/frontend/e2e/accessibility.spec.ts @@ -120,8 +120,8 @@ test.describe("Accessibility", () => { }); }); - // Navigate to config, set active, return to chat so input is enabled - await page.getByTitle("Configuration").click(); + // Navigate to targets, set active, return to chat so input is enabled + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); await expect(setActiveBtn).toBeVisible({ timeout: 5000 }); @@ -146,8 +146,8 @@ test.describe("Accessibility", () => { const chatBtn = page.getByTitle("Chat"); await expect(chatBtn).toBeVisible(); - // Configuration button - const configBtn = page.getByTitle("Configuration"); + // Targets button + const configBtn = page.getByTitle("Targets"); await expect(configBtn).toBeVisible(); // Theme toggle button (now a menu trigger with "Theme: " title) @@ -219,8 +219,8 @@ test.describe("Accessibility", () => { }); }); - // Navigate to config, set active, return to chat so input is enabled - await page.getByTitle("Configuration").click(); + // Navigate to targets, set active, return to chat so input is enabled + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); await expect(setActiveBtn).toBeVisible({ timeout: 5000 }); @@ -239,7 +239,7 @@ test.describe("Accessibility", () => { await expect(input).toBeFocused(); }); - test("should have accessible target table in config view", async ({ page }) => { + test("should have accessible target table in targets view", async ({ page }) => { // Mock targets API for consistent test await page.route(/\/api\/targets/, async (route) => { await route.fulfill({ @@ -265,8 +265,8 @@ test.describe("Accessibility", () => { }); }); - // Navigate to config - await page.getByTitle("Configuration").click(); + // Navigate to targets + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible(); // Table should exist @@ -289,7 +289,7 @@ test.describe("Accessibility", () => { const views = [ { button: "Attack History", heading: "Attack History" }, - { button: "Configuration", heading: "Target Configuration" }, + { button: "Targets", heading: "Target Configuration" }, { button: "Chat", heading: "Chat" }, ]; @@ -334,7 +334,7 @@ test.describe("Accessibility", () => { }); }); - await page.getByRole("button", { name: "Configuration" }).click(); + await page.getByRole("button", { name: "Targets" }).click(); await expect( page.getByRole("heading", { level: 1, name: "Target Configuration" }) ).toBeVisible(); diff --git a/frontend/e2e/chat.spec.ts b/frontend/e2e/chat.spec.ts index 107927320e..68d3613c74 100644 --- a/frontend/e2e/chat.spec.ts +++ b/frontend/e2e/chat.spec.ts @@ -128,10 +128,10 @@ async function mockBackendAPIs(page: Page) { }); } -/** Navigate to config, set the mock target as active, then return to chat. */ +/** Navigate to targets, set the mock target as active, then return to chat. */ async function activateMockTarget(page: Page) { - // Click Configuration button in sidebar - await page.getByTitle("Configuration").click(); + // Click Targets button in sidebar + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); // Set the mock target active @@ -853,7 +853,7 @@ test.describe("Target type scenarios", () => { }); await page.goto("/"); - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); await expect(page.locator("table").getByText("OpenAIChatTarget")).toBeVisible(); @@ -878,7 +878,7 @@ test.describe("Target type scenarios", () => { }); await page.goto("/"); - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("dall-e-3")).toBeVisible({ timeout: 10000 }); // Activate the DALL-E target (second row) diff --git a/frontend/e2e/config.spec.ts b/frontend/e2e/config.spec.ts index e47b071db6..6115e7ccc4 100644 --- a/frontend/e2e/config.spec.ts +++ b/frontend/e2e/config.spec.ts @@ -153,10 +153,10 @@ async function expectWithin( ); } -/** Navigate to the config view. */ -async function goToConfig(page: Page) { +/** Navigate to the targets view. */ +async function goToTargets(page: Page) { await page.goto("/"); - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); } @@ -185,7 +185,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill(mockTargetsList(SAMPLE_TARGETS)); }); - await goToConfig(page); + await goToTargets(page); // Table should appear with both targets await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); @@ -199,7 +199,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill(mockTargetsList([])); }); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText("No Targets Configured")).toBeVisible(); await expect(page.getByRole("button", { name: /create first target/i })).toBeVisible(); @@ -210,7 +210,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill({ status: 500, body: "Internal Server Error" }); }); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText(/error/i)).toBeVisible({ timeout: 10000 }); }); @@ -220,7 +220,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill(mockTargetsList(SAMPLE_TARGETS)); }); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); // Both rows should have a "Set Active" button initially @@ -237,7 +237,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill(mockTargetsList([])); }); - await goToConfig(page); + await goToTargets(page); // Click the "New Target" button in the header await page.getByRole("button", { name: /new target/i }).click(); @@ -257,7 +257,7 @@ test.describe("Target Configuration Page", () => { await route.fulfill(mockTargetsList(items)); }); - await goToConfig(page); + await goToTargets(page); // First load shows one target await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); await expect(page.getByText("dall-e-3")).not.toBeVisible(); @@ -294,7 +294,7 @@ test.describe("Create Target Dialog", () => { await route.fulfill(mockTargetsList([])); }); - await goToConfig(page); + await goToTargets(page); await page.getByRole("button", { name: /new target/i }).click(); const dialog = page.getByRole("dialog"); @@ -356,7 +356,7 @@ test.describe("Create Target Dialog", () => { } }); - await goToConfig(page); + await goToTargets(page); // Click "New Target" button await page.getByRole("button", { name: /new target/i }).click(); @@ -393,7 +393,7 @@ test.describe("Create Target Dialog", () => { await route.fulfill(mockTargetsList([])); }); - await goToConfig(page); + await goToTargets(page); // Open dialog await page.getByRole("button", { name: /new target/i }).click(); @@ -432,7 +432,7 @@ test.describe("Responsive Target Configuration", () => { height: viewport.height, }); await routeResponsiveTargetData(page, LONG_NAME_TARGETS); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible(); const config = page.getByTestId("target-config"); @@ -460,7 +460,7 @@ test.describe("Responsive Target Configuration", () => { height: viewport.height, }); await routeResponsiveTargetData(page, LONG_NAME_TARGETS); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText("gpt-4o-responsive").first()).toBeVisible(); await page.getByRole("button", { name: /new target/i }).click(); @@ -523,7 +523,7 @@ test.describe("Target Config ↔ Chat Navigation", () => { await route.fulfill(mockTargetsList(SAMPLE_TARGETS)); }); - await goToConfig(page); + await goToTargets(page); await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); // Set first target active @@ -551,8 +551,8 @@ test.describe("Target Config ↔ Chat Navigation", () => { await page.getByTitle("Chat").click(); await expect(page.getByTestId("no-target-banner")).toBeVisible(); - // Go to config, set a target - await page.getByTitle("Configuration").click(); + // Go to targets, set a target + await page.getByTitle("Targets").click(); await expect(page.getByText("gpt-4o")).toBeVisible({ timeout: 10000 }); await page.getByRole("button", { name: /set active/i }).first().click(); diff --git a/frontend/e2e/converters.spec.ts b/frontend/e2e/converters.spec.ts index b64ced7e7e..439aa483a5 100644 --- a/frontend/e2e/converters.spec.ts +++ b/frontend/e2e/converters.spec.ts @@ -407,9 +407,9 @@ async function mockBackendAPIs(page: Page) { }); } -/** Navigate to config, set the mock target as active, then return to chat. */ +/** Navigate to targets, set the mock target as active, then return to chat. */ async function activateMockTarget(page: Page) { - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000 }); const setActiveBtn = page.getByRole("button", { name: /set active/i }); diff --git a/frontend/e2e/errors.spec.ts b/frontend/e2e/errors.spec.ts index 84afa9953c..a5e0ff87cc 100644 --- a/frontend/e2e/errors.spec.ts +++ b/frontend/e2e/errors.spec.ts @@ -166,9 +166,9 @@ async function mockAllAPIs( }); } -/** Navigate to config, set mock target active, return to chat. */ +/** Navigate to targets, set mock target active, return to chat. */ async function activateMockTarget(page: Page) { - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10000, }); diff --git a/frontend/e2e/flows.spec.ts b/frontend/e2e/flows.spec.ts index 249e9cd5cf..646fd3c402 100644 --- a/frontend/e2e/flows.spec.ts +++ b/frontend/e2e/flows.spec.ts @@ -196,12 +196,12 @@ async function createConversation( return body.conversation_id; } -/** Activate an exact target instance via the Configuration view. */ +/** Activate an exact target instance via the Targets view. */ async function activateTarget( page: Page, targetRegistryName: string, ): Promise { - await page.getByTitle("Configuration").click(); + await page.getByTitle("Targets").click(); await expect(page.getByText("Target Configuration")).toBeVisible({ timeout: 10_000 }); const row = page.getByTestId(`target-row-${targetRegistryName}`); await expect(row).toBeVisible({ timeout: 10_000 }); diff --git a/frontend/e2e/history.spec.ts b/frontend/e2e/history.spec.ts index 18e7ef09fc..a269b2ebeb 100644 --- a/frontend/e2e/history.spec.ts +++ b/frontend/e2e/history.spec.ts @@ -367,7 +367,7 @@ test.describe("Attack History empty state", () => { await expect(configureTargetButton).toBeFocused(); await configureTargetButton.press("Enter"); - await expect(page).toHaveURL(/\/config$/); + await expect(page).toHaveURL(/\/targets$/); await expect(page.getByRole("heading", { level: 1, name: "Target Configuration" })).toBeVisible(); await page.goBack(); diff --git a/frontend/e2e/onboarding-tour.spec.ts b/frontend/e2e/onboarding-tour.spec.ts index fc971ef2a1..b0d3efa615 100644 --- a/frontend/e2e/onboarding-tour.spec.ts +++ b/frontend/e2e/onboarding-tour.spec.ts @@ -14,7 +14,7 @@ test.describe("Onboarding tour", () => { await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(dialog).toContainText( - "target selection happens in Configuration" + "target selection happens in Targets" ); await expect(dialog).toContainText("choose Configure a target"); await expect(dialog).toContainText("use Set Active there"); @@ -23,13 +23,13 @@ test.describe("Onboarding tour", () => { await page .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/config$/); + await expect(page).toHaveURL(/\/targets$/); await expect( page.getByRole("heading", { name: "Target Configuration" }) ).toBeVisible(); await expect(dialog).toBeVisible(); await expect(dialog).toContainText( - "target selection happens in Configuration" + "target selection happens in Targets" ); await dialog.getByRole("button", { name: "Back", exact: true }).click(); @@ -42,7 +42,7 @@ test.describe("Onboarding tour", () => { await page .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/config$/); + await expect(page).toHaveURL(/\/targets$/); await expect(dialog).toBeVisible(); await dialog.getByRole("button", { name: "Next", exact: true }).click(); @@ -91,7 +91,7 @@ test.describe("Onboarding tour", () => { await page.goto("/"); await page - .getByRole("button", { name: "Configuration", exact: true }) + .getByRole("button", { name: "Targets", exact: true }) .click(); await expect( page.getByRole("heading", { name: "Target Configuration" }) @@ -106,13 +106,13 @@ test.describe("Onboarding tour", () => { await dialog.getByRole("button", { name: "Next", exact: true }).click(); await expect(dialog).toContainText("target currently active for Chat"); - await expect(dialog).toContainText("use Set Active in Configuration"); + await expect(dialog).toContainText("use Set Active in Targets"); await expect(page.locator('[data-tour="target-card"]')).toBeVisible(); await page .getByRole("button", { name: "Manage targets", exact: true }) .click(); - await expect(page).toHaveURL(/\/config$/); + await expect(page).toHaveURL(/\/targets$/); await expect( page.getByRole("heading", { name: "Target Configuration" }) ).toBeVisible(); @@ -168,7 +168,7 @@ test.describe("Onboarding tour", () => { .getByRole("button", { name: "Configure a target", exact: true }) .click(); - await expect(page).toHaveURL(/\/config$/); + await expect(page).toHaveURL(/\/targets$/); await expect(dialog).toBeVisible(); await page.getByRole("button", { name: "Set Active", exact: true }).click(); await expect(page.getByText("Active", { exact: true }).first()).toBeVisible(); diff --git a/frontend/e2e/touch-targets.spec.ts b/frontend/e2e/touch-targets.spec.ts index 34b706206c..5975483d78 100644 --- a/frontend/e2e/touch-targets.spec.ts +++ b/frontend/e2e/touch-targets.spec.ts @@ -359,7 +359,7 @@ async function expectNoDocumentOverflow(page: Page): Promise { } async function startChatWithMessages(page: Page): Promise { - await page.getByRole("button", { name: "Configuration", exact: true }).click(); + await page.getByRole("button", { name: "Targets", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await page.getByRole("button", { name: "Set Active" }).first().click(); await page.getByRole("button", { name: "Chat", exact: true }).click(); @@ -379,7 +379,7 @@ test.beforeEach(async ({ page }) => { test.describe("Mobile touch targets", () => { test.use({ viewport: MOBILE_VIEWPORT, hasTouch: true }); - test("keeps Home, Configuration, and History controls at least 44px", async ({ + test("keeps Home, Targets, and History controls at least 44px", async ({ page, }) => { await page.goto("/"); @@ -401,7 +401,7 @@ test.describe("Mobile touch targets", () => { await expectNoDocumentOverflow(page); await page - .getByRole("button", { name: "Configuration", exact: true }) + .getByRole("button", { name: "Targets", exact: true }) .click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); @@ -476,7 +476,7 @@ test.describe("Mobile touch targets", () => { page.getByTestId("toggle-objective-header-btn") ).toBeVisible(); - await page.getByRole("button", { name: "Configuration", exact: true }).click(); + await page.getByRole("button", { name: "Targets", exact: true }).click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await page.getByRole("button", { name: "Set Active" }).first().click(); await page.goBack(); @@ -641,7 +641,7 @@ test("preserves compact desktop controls and existing sidebar dimensions", async ); await page - .getByRole("button", { name: "Configuration", exact: true }) + .getByRole("button", { name: "Targets", exact: true }) .click(); await expect(page.getByText("gpt-4o-mobile")).toBeVisible(); await expectCompactDesktopTarget( diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 58c1cf62c6..828db28311 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -13,6 +13,7 @@ "@fluentui/react-components": "9.74.6", "@fluentui/react-icons": "2.0.335", "axios": "1.19.0", + "prismjs": "^1.30.0", "react": "19.2.8", "react-dom": "19.2.8", "react-error-boundary": "6.1.2", @@ -30,6 +31,7 @@ "@testing-library/user-event": "14.6.4", "@types/jest": "30.0.0", "@types/node": "26.2.0", + "@types/prismjs": "^1.26.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@typescript-eslint/eslint-plugin": "8.67.0", @@ -4365,6 +4367,13 @@ "undici-types": "~8.3.0" } }, + "node_modules/@types/prismjs": { + "version": "1.26.6", + "resolved": "https://registry.npmjs.org/@types/prismjs/-/prismjs-1.26.6.tgz", + "integrity": "sha1-bqJ8Em1kUxmuT3BV7aY6noNcAYc=", + "dev": true, + "license": "MIT" + }, "node_modules/@types/react": { "version": "19.2.18", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz", @@ -10158,6 +10167,15 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/prismjs": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz", + "integrity": "sha1-2XCZadnU4WQD9vNIxjVTsZ8Jdak=", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/property-information": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2a5db9683b..2d4acaa632 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -27,6 +27,7 @@ "@fluentui/react-components": "9.74.6", "@fluentui/react-icons": "2.0.335", "axios": "1.19.0", + "prismjs": "^1.30.0", "react": "19.2.8", "react-dom": "19.2.8", "react-error-boundary": "6.1.2", @@ -44,6 +45,7 @@ "@testing-library/user-event": "14.6.4", "@types/jest": "30.0.0", "@types/node": "26.2.0", + "@types/prismjs": "^1.26.6", "@types/react": "19.2.18", "@types/react-dom": "19.2.4", "@typescript-eslint/eslint-plugin": "8.67.0", diff --git a/frontend/src/App.test.tsx b/frontend/src/App.test.tsx index bf5f1fc780..b4e587f93c 100644 --- a/frontend/src/App.test.tsx +++ b/frontend/src/App.test.tsx @@ -100,7 +100,7 @@ jest.mock("./components/Layout/MainLayout", () => { - ) : ( - )} @@ -299,7 +304,7 @@ jest.mock("./components/Home/Home", () => {
{activeTarget ? "yes" : "no"} {JSON.stringify(labels)} -
+ {children} + + ) +} diff --git a/frontend/src/components/Configuration/Configuration.styles.ts b/frontend/src/components/Configuration/Configuration.styles.ts new file mode 100644 index 0000000000..86bb19a95f --- /dev/null +++ b/frontend/src/components/Configuration/Configuration.styles.ts @@ -0,0 +1,150 @@ +import { makeStyles, tokens } from '@fluentui/react-components' + +import { + MINIMUM_TOUCH_TARGET_SIZE, + NARROW_VIEWPORT_QUERY, + TOUCH_INPUT_QUERY, +} from '@/styles/touchTargets' + +export const useConfigurationStyles = makeStyles({ + root: { + display: 'flex', + flexDirection: 'column', + boxSizing: 'border-box', + height: '100%', + width: '100%', + minWidth: 0, + maxWidth: '100%', + gap: tokens.spacingVerticalL, + padding: tokens.spacingVerticalXXL, + overflow: 'auto', + backgroundColor: tokens.colorNeutralBackground2, + '@media (max-width: 600px)': { + padding: `${tokens.spacingVerticalL} ${tokens.spacingHorizontalM}`, + }, + }, + header: { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + flexWrap: 'wrap', + gap: tokens.spacingVerticalM, + }, + actions: { + display: 'flex', + alignItems: 'center', + gap: tokens.spacingHorizontalS, + [NARROW_VIEWPORT_QUERY]: { + width: '100%', + }, + }, + action: { + [NARROW_VIEWPORT_QUERY]: { + flex: '1 1 8rem', + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + [TOUCH_INPUT_QUERY]: { + minHeight: MINIMUM_TOUCH_TARGET_SIZE, + }, + }, + editorField: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + minHeight: 0, + maxWidth: '100%', + }, + editor: { + position: 'relative', + flex: 1, + minHeight: '24rem', + }, + editorHighlight: { + position: 'absolute', + inset: 0, + boxSizing: 'border-box', + minHeight: '24rem', + margin: 0, + padding: tokens.spacingHorizontalL, + overflow: 'hidden', + pointerEvents: 'none', + border: `1px solid ${tokens.colorNeutralStroke1}`, + borderRadius: `0 0 ${tokens.borderRadiusMedium} ${tokens.borderRadiusMedium}`, + backgroundColor: tokens.colorNeutralBackground1, + color: tokens.colorNeutralForeground1, + whiteSpace: 'pre', + tabSize: 2, + fontFamily: 'Consolas, "Courier New", monospace', + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + '& .token.comment': { + color: tokens.colorNeutralForeground3, + fontStyle: 'italic', + }, + '& .token.key, & .token.atrule, & .token.tag': { + color: tokens.colorPaletteBlueForeground2, + }, + '& .token.string, & .token.scalar': { + color: tokens.colorPaletteGreenForeground1, + }, + '& .token.number, & .token.boolean, & .token.null, & .token.important': { + color: tokens.colorPaletteDarkOrangeForeground1, + }, + '& .token.anchor, & .token.alias': { + color: tokens.colorPalettePurpleForeground2, + }, + '& .token.punctuation': { + color: tokens.colorNeutralForeground2, + }, + }, + editorInput: { + position: 'relative', + zIndex: 1, + display: 'block', + boxSizing: 'border-box', + width: '100%', + height: '100%', + minHeight: '24rem', + margin: 0, + padding: tokens.spacingHorizontalL, + overflow: 'auto', + resize: 'vertical', + border: '1px solid transparent', + borderRadius: `0 0 ${tokens.borderRadiusMedium} ${tokens.borderRadiusMedium}`, + backgroundColor: 'transparent', + color: 'transparent', + caretColor: tokens.colorNeutralForeground1, + whiteSpace: 'pre', + tabSize: 2, + fontFamily: 'Consolas, "Courier New", monospace', + fontSize: tokens.fontSizeBase300, + lineHeight: tokens.lineHeightBase300, + '&:focus-visible': { + outline: `2px solid ${tokens.colorStrokeFocus2}`, + outlineOffset: '-2px', + }, + '&::selection': { + backgroundColor: tokens.colorBrandBackground2, + }, + }, + loadingState: { + display: 'flex', + flex: 1, + alignItems: 'center', + justifyContent: 'center', + minHeight: '16rem', + }, + message: { + width: '100%', + }, + environmentSection: { + display: 'flex', + flexDirection: 'column', + flex: 1, + minWidth: 0, + minHeight: 0, + maxWidth: '100%', + gap: tokens.spacingVerticalM, + }, +}) diff --git a/frontend/src/components/Configuration/Configuration.test.tsx b/frontend/src/components/Configuration/Configuration.test.tsx new file mode 100644 index 0000000000..6f11cc7a39 --- /dev/null +++ b/frontend/src/components/Configuration/Configuration.test.tsx @@ -0,0 +1,202 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { configurationApi, initializersApi } from '@/services/api' + +import Configuration from './Configuration' + +jest.mock('@/services/api', () => ({ + configurationApi: { + getContent: jest.fn(), + updateContent: jest.fn(), + listEnvironmentFiles: jest.fn(), + getEnvironmentFile: jest.fn(), + updateEnvironmentFile: jest.fn(), + }, + initializersApi: { + listCustom: jest.fn(), + register: jest.fn(), + unregister: jest.fn(), + }, +})) + +const mockedConfigurationApi = jest.mocked(configurationApi) +const mockedInitializersApi = jest.mocked(initializersApi) + +function renderPage(): void { + render( + + + , + ) +} + +describe('Configuration', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedConfigurationApi.getContent.mockResolvedValue({ + content: 'operator: alice\n', + source: 'C:/Users/test/.pyrit/config.yaml', + }) + mockedConfigurationApi.listEnvironmentFiles.mockResolvedValue({ + items: [ + { id: '0', name: '.env', path: 'C:/Users/test/.pyrit/.env', content: '', exists: true, version: 'v1' }, + { id: '1', name: '.env.local', path: 'C:/Users/test/.pyrit/.env.local', content: '', exists: false, version: 'v1' }, + ], + }) + mockedConfigurationApi.getEnvironmentFile.mockResolvedValue({ + id: '0', + name: '.env', + path: 'C:/Users/test/.pyrit/.env', + content: 'API_KEY=value\n', + exists: true, + version: 'v1', + }) + mockedInitializersApi.listCustom.mockResolvedValue({ + source: 'C:/Users/test/.pyrit/custom_initializers', + items: [{ + initializer_name: 'custom_target', + script_content: 'class CustomTargetInitializer: pass', + source: 'C:/Users/test/.pyrit/custom_initializers/custom_target.py', + }], + }) + mockedInitializersApi.register.mockResolvedValue() + mockedInitializersApi.unregister.mockResolvedValue() + }) + + it('should load and display configuration content', async () => { + renderPage() + + expect(screen.getByRole('heading', { level: 1, name: 'Configuration' })).toBeInTheDocument() + expect(await screen.findByLabelText('Configuration YAML')).toHaveValue('operator: alice\n') + expect(screen.getByRole('navigation', { name: 'Configuration files' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /\.pyrit_conf/i })).toHaveAttribute('aria-current', 'page') + expect(screen.getByText('C:/Users/test/.pyrit/config.yaml', { selector: 'label' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Copy YAML source' })).toBeInTheDocument() + expect(screen.getByTestId('yaml-highlight').innerHTML).toContain('token key atrule') + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled() + }) + + it('should save edited configuration content', async () => { + const user = userEvent.setup() + mockedConfigurationApi.updateContent.mockResolvedValue({ + content: 'operator: bob\n', + source: 'C:/Users/test/.pyrit/config.yaml', + }) + renderPage() + + const editor = await screen.findByLabelText('Configuration YAML') + await user.clear(editor) + await user.type(editor, 'operator: bob\n') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(mockedConfigurationApi.updateContent).toHaveBeenCalledWith({ content: 'operator: bob\n' }) + expect(await screen.findByText(/restart PyRIT/i)).toBeInTheDocument() + }) + + it('should show a load error', async () => { + mockedConfigurationApi.getContent.mockRejectedValue(new Error('Configuration unavailable')) + renderPage() + + expect(await screen.findByText('Configuration unavailable')).toBeInTheDocument() + }) + + it('should edit and save a selected environment file with dotenv highlighting', async () => { + const user = userEvent.setup() + mockedConfigurationApi.updateEnvironmentFile.mockResolvedValue({ + id: '0', + name: '.env', + path: 'C:/Users/test/.pyrit/.env', + content: 'API_KEY=updated\n', + exists: true, + version: 'v2', + }) + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Environment & Secrets' })) + const editor = await screen.findByLabelText('Environment file contents') + expect(screen.getByText('C:/Users/test/.pyrit/.env', { selector: 'label' })).toBeInTheDocument() + expect(screen.getByTitle('C:/Users/test/.pyrit/.env')).toBeInTheDocument() + expect(editor).toHaveValue('API_KEY=value\n') + expect(screen.getByTestId('dotenv-highlight').innerHTML).toContain('token key atrule') + expect(screen.getByRole('button', { name: 'Copy dotenv source' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: /\.env\.local/i })).toHaveTextContent('(new)') + + await user.clear(editor) + await user.type(editor, 'API_KEY=updated\n') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(mockedConfigurationApi.updateEnvironmentFile).toHaveBeenCalledWith('0', { + content: 'API_KEY=updated\n', + version: 'v1', + }) + }) + + it('should display and update an AKV environment source', async () => { + const user = userEvent.setup() + const secretUrl = 'https://vault.vault.azure.net/secrets/bootstrap' + mockedConfigurationApi.listEnvironmentFiles.mockResolvedValue({ + items: [ + { id: 'akv:0', name: 'AKV: bootstrap', path: secretUrl, content: '', exists: true, version: 'v1' }, + ], + }) + mockedConfigurationApi.getEnvironmentFile.mockResolvedValue({ + id: 'akv:0', + name: 'AKV: bootstrap', + path: secretUrl, + content: 'API_KEY=before\n', + exists: true, + version: 'v1', + }) + mockedConfigurationApi.updateEnvironmentFile.mockResolvedValue({ + id: 'akv:0', + name: 'AKV: bootstrap', + path: secretUrl, + content: 'API_KEY=after\n', + exists: true, + version: 'v2', + }) + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Environment & Secrets' })) + expect(await screen.findByRole('button', { name: /AKV: bootstrap/i })).toBeInTheDocument() + expect(screen.getByTitle(secretUrl)).toBeInTheDocument() + const editor = await screen.findByLabelText('Environment file contents') + expect(screen.getByText(secretUrl, { selector: 'label' })).toBeInTheDocument() + await user.clear(editor) + await user.type(editor, 'API_KEY=after\n') + await user.click(screen.getByRole('button', { name: 'Save' })) + + expect(mockedConfigurationApi.updateEnvironmentFile).toHaveBeenCalledWith('akv:0', { + content: 'API_KEY=after\n', + version: 'v1', + }) + }) + + it('should list and register custom initializers', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('tab', { name: 'Custom Initializers' })) + expect(await screen.findByText( + 'C:/Users/test/.pyrit/custom_initializers/custom_target.py', + { selector: 'label' }, + )).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Add initializer' })) + const dialog = screen.getByRole('dialog', { name: 'Add custom initializer' }) + await user.type(within(dialog).getByRole('textbox', { name: /Initializer name/ }), 'new_custom') + fireEvent.change(within(dialog).getByRole('textbox', { name: 'Python source' }), { + target: { value: 'class NewCustom: pass' }, + }) + await user.click(within(dialog).getByRole('button', { name: 'Add' })) + + await waitFor(() => { + expect(mockedInitializersApi.register).toHaveBeenCalledWith({ + name: 'new_custom', + script_content: 'class NewCustom: pass', + }) + }) + }) + +}) diff --git a/frontend/src/components/Configuration/Configuration.tsx b/frontend/src/components/Configuration/Configuration.tsx new file mode 100644 index 0000000000..720b1c0451 --- /dev/null +++ b/frontend/src/components/Configuration/Configuration.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState } from 'react' + +import { + Button, + Field, + MessageBar, + MessageBarBody, + Spinner, + Tab, + TabList, + Text, +} from '@fluentui/react-components' +import type { SelectTabData, SelectTabEvent } from '@fluentui/react-components' +import { ArrowSyncRegular, SaveRegular } from '@fluentui/react-icons' + +import { configurationApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import EditorWorkspace from '@/components/EditorWorkspace' + +import { useConfigurationStyles } from './Configuration.styles' +import CustomInitializerFiles from './CustomInitializerFiles' +import EnvironmentFiles from './EnvironmentFiles' +import YamlEditor from './YamlEditor' + +interface StatusMessage { + intent: 'success' | 'error' | 'warning' + text: string +} + +type ConfigurationTab = 'configuration' | 'environment' | 'custom-initializers' + +export default function Configuration() { + const styles = useConfigurationStyles() + const [content, setContent] = useState('') + const [savedContent, setSavedContent] = useState('') + const [source, setSource] = useState('') + const [loading, setLoading] = useState(true) + const [saving, setSaving] = useState(false) + const [reloadCount, setReloadCount] = useState(0) + const [statusMessage, setStatusMessage] = useState(null) + const [selectedTab, setSelectedTab] = useState('configuration') + + useEffect(() => { + let cancelled = false + + const loadContentAsync = async (): Promise => { + setLoading(true) + setStatusMessage(null) + try { + const response = await configurationApi.getContent() + if (!cancelled) { + setContent(response.content) + setSavedContent(response.content) + setSource(response.source) + } + } catch (error) { + if (!cancelled) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void loadContentAsync() + return () => { + cancelled = true + } + }, [reloadCount]) + + const handleReload = (): void => { + setReloadCount((currentCount: number) => currentCount + 1) + } + + const handleSave = async (): Promise => { + setSaving(true) + setStatusMessage(null) + try { + const response = await configurationApi.updateContent({ content }) + setContent(response.content) + setSavedContent(response.content) + setSource(response.source) + setStatusMessage({ + intent: 'success', + text: 'Configuration saved. Restart PyRIT to apply these changes.', + }) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setSaving(false) + } + } + + const hasUnsavedChanges = content !== savedContent + + const handleTabSelect = (_: SelectTabEvent, data: SelectTabData): void => { + if ( + data.value === 'configuration' + || data.value === 'environment' + || data.value === 'custom-initializers' + ) { + setSelectedTab(data.value) + } + } + + return ( +
+
+ Configuration +
+ + + PyRIT Configuration + Environment & Secrets + Custom Initializers + + + {selectedTab === 'configuration' && statusMessage && ( + + {statusMessage.text} + + )} + + {selectedTab === 'custom-initializers' ? ( + + ) : selectedTab === 'environment' ? ( + + ) : loading ? ( +
+ +
+ ) : ( + + + + + )} + > + + + + + )} +
+ ) +} diff --git a/frontend/src/components/Configuration/CustomInitializerFiles.test.tsx b/frontend/src/components/Configuration/CustomInitializerFiles.test.tsx new file mode 100644 index 0000000000..91e5b9e6a4 --- /dev/null +++ b/frontend/src/components/Configuration/CustomInitializerFiles.test.tsx @@ -0,0 +1,58 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import { initializersApi } from '@/services/api' + +import CustomInitializerFiles from './CustomInitializerFiles' + +jest.mock('@/services/api', () => ({ + initializersApi: { + listCustom: jest.fn(), + register: jest.fn(), + unregister: jest.fn(), + }, +})) + +const mockedInitializersApi = jest.mocked(initializersApi) +const customInitializer = { + initializer_name: 'custom_target', + script_content: 'class CustomTarget: pass', + source: 'C:/custom/custom_target.py', +} + +function renderFiles(): void { + render( + + + , + ) +} + +describe('CustomInitializerFiles', () => { + beforeEach(() => { + jest.clearAllMocks() + mockedInitializersApi.listCustom.mockResolvedValue({ source: 'C:/custom', items: [customInitializer] }) + mockedInitializersApi.unregister.mockResolvedValue() + }) + + it('should load and remove a custom initializer', async () => { + const user = userEvent.setup() + renderFiles() + + expect(await screen.findByText(customInitializer.source, { selector: 'label' })).toBeInTheDocument() + await user.click(screen.getByRole('button', { name: 'Remove' })) + const dialog = screen.getByRole('dialog', { name: 'Remove custom initializer' }) + await user.click(within(dialog).getByRole('button', { name: 'Remove' })) + + expect(mockedInitializersApi.unregister).toHaveBeenCalledWith('custom_target') + expect(await screen.findByText('Removed custom_target.')).toBeInTheDocument() + }) + + it('should show an error when custom initializers cannot be loaded', async () => { + mockedInitializersApi.listCustom.mockRejectedValue(new Error('Custom initializers unavailable')) + renderFiles() + + expect(await screen.findByText('Custom initializers unavailable')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/Configuration/CustomInitializerFiles.tsx b/frontend/src/components/Configuration/CustomInitializerFiles.tsx new file mode 100644 index 0000000000..9a50c0aef0 --- /dev/null +++ b/frontend/src/components/Configuration/CustomInitializerFiles.tsx @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react' + +import { MessageBar, MessageBarBody, Spinner } from '@fluentui/react-components' + +import CustomInitializers from '@/components/Initializers/CustomInitializers' +import { initializersApi } from '@/services/api' +import { toApiError } from '@/services/errors' +import type { CustomInitializer } from '@/types' + +import { useConfigurationStyles } from './Configuration.styles' + +interface StatusMessage { + intent: 'success' | 'error' + text: string +} + +export default function CustomInitializerFiles() { + const styles = useConfigurationStyles() + const [items, setItems] = useState([]) + const [loading, setLoading] = useState(true) + const [registering, setRegistering] = useState(false) + const [deletingName, setDeletingName] = useState(null) + const [statusMessage, setStatusMessage] = useState(null) + + useEffect(() => { + let cancelled = false + + const loadAsync = async (): Promise => { + setLoading(true) + try { + const response = await initializersApi.listCustom() + if (!cancelled) { + setItems(response.items) + } + } catch (error) { + if (!cancelled) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } + } finally { + if (!cancelled) { + setLoading(false) + } + } + } + + void loadAsync() + return () => { + cancelled = true + } + }, []) + + const reload = async (): Promise => { + const response = await initializersApi.listCustom() + setItems(response.items) + } + + const handleRegister = async (name: string, scriptContent: string): Promise => { + setRegistering(true) + setStatusMessage(null) + try { + await initializersApi.register({ name, script_content: scriptContent }) + await reload() + setStatusMessage({ intent: 'success', text: `Added and registered ${name}.` }) + return true + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + return false + } finally { + setRegistering(false) + } + } + + const handleDelete = async (name: string): Promise => { + setDeletingName(name) + setStatusMessage(null) + try { + await initializersApi.unregister(name) + await reload() + setStatusMessage({ intent: 'success', text: `Removed ${name}.` }) + } catch (error) { + setStatusMessage({ intent: 'error', text: toApiError(error).detail }) + } finally { + setDeletingName(null) + } + } + + if (loading) { + return + } + + return ( +
+ {statusMessage && ( + + {statusMessage.text} + + )} + +
+ ) +} diff --git a/frontend/src/components/Configuration/DotenvEditor.test.tsx b/frontend/src/components/Configuration/DotenvEditor.test.tsx new file mode 100644 index 0000000000..8c5f757f66 --- /dev/null +++ b/frontend/src/components/Configuration/DotenvEditor.test.tsx @@ -0,0 +1,33 @@ +import { FluentProvider, webLightTheme } from '@fluentui/react-components' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' + +import DotenvEditor from './DotenvEditor' + +function renderEditor(props: React.ComponentProps): void { + render( + + + , + ) +} + +describe('DotenvEditor', () => { + it('should highlight dotenv content and report edits', async () => { + const user = userEvent.setup() + const onChange = jest.fn() + renderEditor({ value: 'ENABLED=true\n', disabled: false, onChange }) + + expect(screen.getByTestId('dotenv-highlight').innerHTML).toContain('token key atrule') + + await user.type(screen.getByRole('textbox', { name: 'Environment file contents' }), '#') + + expect(onChange).toHaveBeenCalledWith('ENABLED=true\n#') + }) + + it('should disable editing when requested', () => { + renderEditor({ value: '', disabled: true, onChange: jest.fn() }) + + expect(screen.getByRole('textbox', { name: 'Environment file contents' })).toBeDisabled() + }) +}) diff --git a/frontend/src/components/Configuration/DotenvEditor.tsx b/frontend/src/components/Configuration/DotenvEditor.tsx new file mode 100644 index 0000000000..a2a581bdcc --- /dev/null +++ b/frontend/src/components/Configuration/DotenvEditor.tsx @@ -0,0 +1,76 @@ +import { useRef } from 'react' + +import Prism from 'prismjs' + +import CodeEditorFrame from '@/components/CodeEditorFrame' + +import { useConfigurationStyles } from './Configuration.styles' + +interface DotenvEditorProps { + value: string + disabled: boolean + onChange: (value: string) => void +} + +Prism.languages.dotenv = { + comment: /(^|[^\\])#.*/m, + key: { + pattern: /(^\s*(?:export\s+)?)[A-Za-z_][A-Za-z0-9_]*(?=\s*=)/m, + lookbehind: true, + alias: 'atrule', + }, + interpolation: { + pattern: /\$\{?[A-Za-z_][A-Za-z0-9_]*\}?/, + alias: 'variable', + }, + string: { + pattern: /(^\s*(?:export\s+)?[A-Za-z_][A-Za-z0-9_]*\s*=\s*)(?:"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*')/m, + lookbehind: true, + greedy: true, + }, + boolean: /\b(?:true|false)\b/i, + number: /\b(?:0x[\dA-Fa-f]+|\d+(?:\.\d+)?)\b/, + operator: /=/, +} + +function highlightDotenv(value: string): string { + return Prism.highlight(value, Prism.languages.dotenv, 'dotenv') +} + +export default function DotenvEditor({ value, disabled, onChange }: DotenvEditorProps) { + const styles = useConfigurationStyles() + const highlightRef = useRef(null) + + const handleScroll = (event: React.UIEvent): void => { + if (highlightRef.current) { + highlightRef.current.scrollTop = event.currentTarget.scrollTop + highlightRef.current.scrollLeft = event.currentTarget.scrollLeft + } + } + + return ( + +
+ +