Skip to content

FEAT: URL-driven views and shareable deep links in the GUI#1994

Open
adrian-gavrila wants to merge 7 commits into
microsoft:mainfrom
adrian-gavrila:adrian-gavrila/gui-shareable-links
Open

FEAT: URL-driven views and shareable deep links in the GUI#1994
adrian-gavrila wants to merge 7 commits into
microsoft:mainfrom
adrian-gavrila:adrian-gavrila/gui-shareable-links

Conversation

@adrian-gavrila

@adrian-gavrila adrian-gavrila commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

Description

Today all view state in the frontend GUI lives in React useState in App.tsx, so refreshing the page resets you back to Home and there is no way to share a link to a specific attack or conversation. This PR makes the GUI URL-driven: the current view and the data being viewed are encoded in the browser URL, so refreshes restore exactly what you were looking at and links are copy-pasteable.

The change consists of these main components:

  • Routing layer. Adds react-router-dom@7 with a path-based BrowserRouter (not HashRouter, since MSAL uses the URL hash). Top-level views map to /, /chat, /history, /config.
  • Attack deep links. /attacks/<id> and /attacks/<id>/conversations/<conv> hydrate the chat window directly from the URL. App.tsx becomes the URL<->state orchestrator while preserving the existing behaviors: the cross-target guard, operator-label locking, isLoadingAttack handling, related-conversation selection, and the "re-opening the same attack" fetch optimization. Invalid/missing ids render a not-found view.
  • History filters in the query string. Outcome/operator filters round-trip through the URL so a filtered history view is itself shareable and survives reload.
  • Refresh survival in production. A SPAStaticFiles subclass serves index.html for unmatched non-API paths so a hard refresh on a deep link does not 404 behind the FastAPI static mount.
  • Login redirect restore. MSAL Browser v5 removed navigateToLoginRequestUrl, so deep-link restore across the login round-trip is done manually: the requested path is captured as MSAL state and restored after handleRedirectPromise. Because BrowserRouter mounts inside AuthProvider, the URL is corrected before the router reads it, so no full reload is needed. An isSafeInternalPath guard restricts restore to same-origin root-relative paths to avoid an open redirect.

Notes for reviewers

  • The MSAL restore path was the riskiest interaction; it is correct only because the router mounts after AuthProvider gates on the initialized MSAL instance.
  • The SPAStaticFiles api-path guard normalizes os.sep to / before matching: on Windows the path arrives with backslashes, so a naive startswith("api/") check silently leaks the SPA for real /api/... 404s. The accompanying test pins this boundary.

Tests and Documentation

  • New Jest coverage: AttackNotFound.test.tsx, historyFilters.test.ts, and AuthProvider tests for state capture, deep-link restore, and the open-redirect guard (including a backslash-prefixed state). Updated App.test.tsx for the routed structure. Full frontend suite: 636+ passing.
  • New Playwright spec frontend/e2e/routing.spec.ts (first e2e coverage that asserts on the browser URL): history-filter round-trip + reload, deep-link hydration, not-found, and back/forward navigation. 4/4 passing.
  • New backend tests in tests/unit/backend/test_main.py::TestSPAStaticFiles covering SPA fallback, the /api 404 boundary, and the /apikeys-style prefix case.
  • npm run lint, npm run type-check, npm test, and the affected backend pytest all pass. No doc/JupyText samples are affected by this change.

adrian-gavrila and others added 6 commits June 11, 2026 13:36
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…fresh

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@adrian-gavrila adrian-gavrila force-pushed the adrian-gavrila/gui-shareable-links branch from 5c9f2fe to 8db537a Compare June 11, 2026 17:40
Resolve dependency conflicts: keep main's React 19 bump and re-add react-router-dom. Silence the new react-hooks/set-state-in-effect rule on the intentional loadedAttack cleanup.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@adrian-gavrila adrian-gavrila marked this pull request as ready for review June 11, 2026 18:24
@adrian-gavrila adrian-gavrila requested a review from Copilot June 11, 2026 20:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes the PyRIT GUI URL-driven so view state (including selected attacks/conversations and history filters) survives refreshes and can be shared via deep links, while also ensuring production refreshes on deep routes don’t 404 behind the FastAPI static mount.

Changes:

  • Introduces react-router-dom routing and refactors App.tsx to derive view/attack state from the URL (including deep links to attacks/conversations).
  • Persists History filters in the query string with encode/decode helpers and adds unit/e2e coverage.
  • Adds SPAStaticFiles to serve index.html for unmatched non-API paths, preserving SPA refresh behavior, plus backend tests for /api vs non-API boundaries.

Reviewed changes

Copilot reviewed 15 out of 16 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
tests/unit/backend/test_main.py Adds unit coverage for SPA static fallback behavior and /api namespace boundary.
pyrit/backend/main.py Adds SPAStaticFiles and mounts it for production frontend serving to support deep-link refresh.
frontend/src/setupTests.ts Polyfills TextEncoder/TextDecoder for jsdom compatibility with router import-time usage.
frontend/src/main.tsx Wraps the app with BrowserRouter (inside AuthProvider).
frontend/src/components/History/historyFilters.ts Adds query-string encode/decode helpers for shareable History filters.
frontend/src/components/History/historyFilters.test.ts Unit tests for History filter URL round-tripping behavior.
frontend/src/components/Chat/AttackNotFound.tsx Adds a not-found UI for invalid/missing attack deep links.
frontend/src/components/Chat/AttackNotFound.test.tsx Unit tests for the Attack-not-found UI behavior.
frontend/src/components/Chat/AttackNotFound.styles.ts Styles for the Attack-not-found view.
frontend/src/auth/AuthProvider.tsx Captures requested URL into MSAL state and restores it post-login with an internal-path guard.
frontend/src/auth/AuthProvider.test.tsx Adds tests for state capture, deep-link restore, and open-redirect guard behavior.
frontend/src/App.tsx Major refactor: URL ↔ state orchestrator for views, deep-linked attacks, and history query filters.
frontend/src/App.test.tsx Updates tests for routed structure and adds coverage for deep-link hydration and filter URL behavior.
frontend/package.json Adds react-router-dom dependency.
frontend/package-lock.json Locks react-router(-dom) transitive deps and engines.
frontend/e2e/routing.spec.ts New Playwright e2e coverage asserting address-bar behavior: deep links, reload, back/forward, filters.
Files not reviewed (1)
  • frontend/package-lock.json: Language not supported

Comment on lines +57 to +62
* open redirect. Backslashes are treated as slashes because the URL parser
* normalizes "\" to "/".
*/
function isSafeInternalPath(path: string): boolean {
return path.startsWith('/') && !path.startsWith('//') && !path.startsWith('/\\')
}
Comment thread frontend/src/App.tsx
Comment on lines +193 to +209
attacksApi
.getAttack(routeAttackId)
.then(attack => {
if (cancelled) return
setLoadedAttack({
id: routeAttackId,
mainConversationId: attack.conversation_id,
labels: attack.labels ?? {},
target: attack.target ?? null,
relatedConversationIds: attack.related_conversation_ids ?? [],
status: 'success',
})
})
.catch(() => {
if (cancelled) return
setLoadedAttack({
id: routeAttackId,
Comment on lines +186 to +190
def test_unknown_api_path_still_404(self, spa_client: TestClient) -> None:
"""Test that an unknown /api path stays a real 404 instead of being masked by index.html."""
resp = spa_client.get("/api/bogus")
assert resp.status_code == 404
assert "spa-index" not in resp.text
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants