diff --git a/backend/src/control_center/api/routes_platform_interactions_proxy.py b/backend/src/control_center/api/routes_platform_interactions_proxy.py
new file mode 100644
index 0000000..8467e96
--- /dev/null
+++ b/backend/src/control_center/api/routes_platform_interactions_proxy.py
@@ -0,0 +1,75 @@
+from __future__ import annotations
+
+import os
+
+import httpx
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse, Response
+
+# PR-B5-B (Control Center Interaction Admin View). Same reasoning as
+# every other proxy file in this directory (routes_audit_proxy.py in
+# particular, whose shape this mirrors exactly): no authorization
+# decision is made here -- omnibioai-auth's own
+# require_permission(manage_all_orgs) on GET /platform/interactions
+# (PR-B5-A) decides every request, pre-existing (reused, not new) and
+# unmodified by this PR. A pure relay: never inspects, caches, or logs
+# the response bodies it forwards -- matters here because an
+# interaction's own metadata could in principle be large or
+# sensitive-looking even though the write path (interaction_service.py's
+# own _redact_metadata, reused by app/workers/interaction_consumer.py)
+# already strips secret-shaped keys before persistence; see
+# frontend/cc-ui/src/interactions.ts's own maskSensitiveFields for the
+# UI-side defense-in-depth layer this PR adds on top, not instead of,
+# that guarantee.
+router = APIRouter()
+
+IAM_URL = os.environ.get("IAM_URL", "http://auth-service:8001")
+
+
+async def _proxy(method: str, path: str, request: Request) -> Response:
+ body = await request.body()
+ headers = {"Content-Type": "application/json"}
+ auth_header = request.headers.get("authorization")
+ if auth_header:
+ headers["Authorization"] = auth_header
+
+ try:
+ async with httpx.AsyncClient(timeout=10) as client:
+ r = await client.request(
+ method,
+ f"{IAM_URL}{path}",
+ params=request.query_params,
+ content=body if method in ("POST", "PATCH", "PUT") else None,
+ headers=headers,
+ )
+ except httpx.RequestError as e:
+ return JSONResponse(
+ {"error": f"auth-service unreachable: {type(e).__name__}: {e}"}, status_code=503,
+ )
+
+ if not r.content:
+ return Response(status_code=r.status_code)
+
+ try:
+ payload = r.json()
+ except ValueError:
+ payload = {"error": "auth-service returned a non-JSON response"}
+ return JSONResponse(payload, status_code=r.status_code)
+
+
+# ---------------- Interactions (read-only, platform-admin only) -----------
+
+# GET only -- no PATCH/PUT/DELETE route is proxied here because
+# omnibioai-auth exposes none (PR-B5-A is GET /platform/interactions[/{id}]
+# only). Interactions are a durable ledger, immutable through this proxy
+# by simply never defining any other verb -- same convention
+# routes_audit_proxy.py already established for AuditEvent.
+
+@router.get("/platform/interactions")
+async def list_interactions_proxy(request: Request) -> Response:
+ return await _proxy("GET", "/platform/interactions", request)
+
+
+@router.get("/platform/interactions/{interaction_id}")
+async def get_interaction_proxy(interaction_id: str, request: Request) -> Response:
+ return await _proxy("GET", f"/platform/interactions/{interaction_id}", request)
diff --git a/backend/src/control_center/main.py b/backend/src/control_center/main.py
index de3323a..280f1e0 100644
--- a/backend/src/control_center/main.py
+++ b/backend/src/control_center/main.py
@@ -48,6 +48,7 @@
from control_center.api.routes_workflow_bundles_proxy import router as workflow_bundles_proxy_router
from control_center.api.routes_rag_proxy import router as rag_proxy_router
from control_center.api.routes_platform_config_proxy import router as platform_config_proxy_router
+from control_center.api.routes_platform_interactions_proxy import router as platform_interactions_proxy_router
from control_center.api.routes_cloud import router as cloud_router
from control_center.core.auth import require_permission
from control_center.api.routes_config import router as config_router
@@ -166,6 +167,7 @@ def _setup_logging() -> logging.Logger:
app.include_router(workflow_bundles_proxy_router)
app.include_router(rag_proxy_router)
app.include_router(platform_config_proxy_router)
+app.include_router(platform_interactions_proxy_router)
# No blanket permission dependency here, unlike summary/docker/config/
# services above -- routes_dashboard.py's own docstring explains why:
# each section of its one response is authorized independently, either
diff --git a/backend/tests/test_routes_platform_interactions_proxy.py b/backend/tests/test_routes_platform_interactions_proxy.py
new file mode 100644
index 0000000..f509f96
--- /dev/null
+++ b/backend/tests/test_routes_platform_interactions_proxy.py
@@ -0,0 +1,304 @@
+"""
+tests/test_routes_platform_interactions_proxy.py
+
+Unit tests for:
+ - control_center.api.routes_platform_interactions_proxy
+ (GET /platform/interactions, GET /platform/interactions/{interaction_id})
+
+Mirrors test_routes_sessions_proxy.py's / test_routes_audit_proxy.py's
+exact conventions -- this route is a thin relay, no authorization
+decision is made here (that's entirely omnibioai-auth's job:
+GET /platform/interactions is platform-admin-only,
+require_permission(manage_all_orgs), PR-B5-A).
+"""
+
+from __future__ import annotations
+
+import unittest
+from unittest.mock import AsyncMock, MagicMock, patch
+
+import httpx
+from fastapi.testclient import TestClient
+
+from control_center.main import app
+
+client = TestClient(app)
+
+
+def _mock_response(status_code: int, json_body=None, raise_json_error: bool = False, content: bytes = b"x") -> MagicMock:
+ resp = MagicMock()
+ resp.status_code = status_code
+ resp.content = content
+ if raise_json_error:
+ resp.json.side_effect = ValueError("not json")
+ else:
+ resp.json.return_value = json_body
+ return resp
+
+
+def _mock_async_client(response: MagicMock = None, side_effect=None):
+ mock_client = MagicMock()
+ mock_request = AsyncMock()
+ if side_effect is not None:
+ mock_request.side_effect = side_effect
+ else:
+ mock_request.return_value = response
+ mock_client.request = mock_request
+
+ mock_ctx = MagicMock()
+ mock_ctx.__aenter__ = AsyncMock(return_value=mock_client)
+ mock_ctx.__aexit__ = AsyncMock(return_value=False)
+ return mock_ctx
+
+
+_INTERACTION = {
+ "id": 1,
+ "interaction_id": "becbce38-0ada-427c-a29c-4c1bdfd95095",
+ "organization_id": 339,
+ "user_id": 630,
+ "session_id": None,
+ "trace_id": "verify-trace-1",
+ "service": "rag",
+ "interaction_type": "query",
+ "action": "rag.query",
+ "resource_type": "study",
+ "resource_id": "default",
+ "status": "success",
+ "decision": None,
+ "metadata": {"mode": "rag", "top_k": 3},
+ "created_at": "2026-08-10T01:41:38",
+}
+
+_LIST_RESPONSE = {
+ "items": [_INTERACTION],
+ "total": 1,
+ "page": 1,
+ "page_size": 20,
+ "total_pages": 1,
+}
+
+
+class TestListInteractionsProxy(unittest.TestCase):
+ def test_forwards_success_response(self) -> None:
+ upstream = _mock_response(200, _LIST_RESPONSE)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 200)
+ body = resp.json()
+ self.assertEqual(body["items"][0]["interaction_id"], _INTERACTION["interaction_id"])
+ # Never leaks anything token/secret-shaped, even indirectly.
+ body_text = resp.text
+ for forbidden in ("access_token", "refresh_token", "hashed_password", "jwt", "cookie"):
+ self.assertNotIn(forbidden, body_text.lower())
+
+ def test_envelope_preserved_exactly(self) -> None:
+ upstream = _mock_response(200, _LIST_RESPONSE)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ body = resp.json()
+ self.assertEqual(set(body.keys()), {"items", "total", "page", "page_size", "total_pages"})
+ self.assertEqual(body["total"], 1)
+ self.assertEqual(body["page"], 1)
+ self.assertEqual(body["page_size"], 20)
+ self.assertEqual(body["total_pages"], 1)
+
+ def test_forwards_authorization_header(self) -> None:
+ upstream = _mock_response(200, _LIST_RESPONSE)
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ client.get("/platform/interactions", headers={"Authorization": "Bearer my-token-123"})
+ call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs
+ self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer my-token-123")
+
+ def test_missing_authorization_header_is_not_forged(self) -> None:
+ # No Authorization header on the incoming request -- the proxy
+ # must not invent one; omnibioai-auth's own get_current_user is
+ # what actually rejects the request (mocked here as a 401, same
+ # as test_upstream_401_is_forwarded below covers end-to-end).
+ upstream = _mock_response(200, _LIST_RESPONSE)
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ client.get("/platform/interactions")
+ call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs
+ self.assertNotIn("Authorization", call_kwargs["headers"])
+
+ def test_query_parameters_forwarded_unchanged(self) -> None:
+ upstream = _mock_response(200, _LIST_RESPONSE)
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ client.get(
+ "/platform/interactions",
+ params={
+ "page": 2, "page_size": 50, "organization_id": 339, "user_id": 630,
+ "service": "rag", "interaction_type": "query", "status": "success",
+ "start_date": "2026-01-01T00:00:00", "end_date": "2026-12-31T00:00:00",
+ },
+ headers={"Authorization": "Bearer tok"},
+ )
+ call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs
+ forwarded_params = call_kwargs["params"]
+ self.assertEqual(forwarded_params["page"], "2")
+ self.assertEqual(forwarded_params["page_size"], "50")
+ self.assertEqual(forwarded_params["organization_id"], "339")
+ self.assertEqual(forwarded_params["user_id"], "630")
+ self.assertEqual(forwarded_params["service"], "rag")
+ self.assertEqual(forwarded_params["interaction_type"], "query")
+ self.assertEqual(forwarded_params["status"], "success")
+ self.assertEqual(forwarded_params["start_date"], "2026-01-01T00:00:00")
+ self.assertEqual(forwarded_params["end_date"], "2026-12-31T00:00:00")
+
+ def test_upstream_401_is_forwarded(self) -> None:
+ upstream = _mock_response(401, {"detail": "Not authenticated"})
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions")
+ self.assertEqual(resp.status_code, 401)
+
+ def test_upstream_403_is_forwarded(self) -> None:
+ # A valid token lacking manage_all_orgs -- omnibioai-auth's own
+ # require_permission is the only authority here; this proxy makes
+ # no RBAC decision and must not turn a 403 into anything else.
+ upstream = _mock_response(403, {"detail": "Forbidden"})
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 403)
+
+ def test_auth_service_unreachable_returns_503(self) -> None:
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 503)
+ self.assertIn("auth-service unreachable", resp.json()["error"])
+
+ def test_network_timeout_returns_503(self) -> None:
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(side_effect=httpx.TimeoutException("timed out")),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 503)
+
+ def test_non_json_upstream_response_handled(self) -> None:
+ upstream = _mock_response(500, raise_json_error=True)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 500)
+ self.assertIn("non-JSON", resp.json()["error"])
+
+ def test_upstream_5xx_is_forwarded(self) -> None:
+ upstream = _mock_response(500, {"detail": "Internal Server Error"})
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 500)
+
+ def test_empty_upstream_body_preserved(self) -> None:
+ upstream = MagicMock()
+ upstream.status_code = 200
+ upstream.content = b""
+ upstream.json.side_effect = ValueError("no content to parse")
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ resp = client.get("/platform/interactions", headers={"Authorization": "Bearer tok"})
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.content, b"")
+
+
+class TestGetInteractionProxy(unittest.TestCase):
+ def test_forwards_success_response(self) -> None:
+ upstream = _mock_response(200, _INTERACTION)
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ resp = client.get(
+ f"/platform/interactions/{_INTERACTION['interaction_id']}",
+ headers={"Authorization": "Bearer tok"},
+ )
+ self.assertEqual(resp.status_code, 200)
+ self.assertEqual(resp.json()["interaction_id"], _INTERACTION["interaction_id"])
+ forwarded_path = mock_ctx.__aenter__.return_value.request.call_args.args[1]
+ self.assertTrue(forwarded_path.endswith(f"/platform/interactions/{_INTERACTION['interaction_id']}"))
+
+ def test_upstream_404_for_unknown_interaction_id(self) -> None:
+ upstream = _mock_response(404, {"detail": "Interaction not found"})
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get(
+ "/platform/interactions/does-not-exist", headers={"Authorization": "Bearer tok"}
+ )
+ self.assertEqual(resp.status_code, 404)
+
+ def test_upstream_403_is_forwarded(self) -> None:
+ upstream = _mock_response(403, {"detail": "Forbidden"})
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(upstream),
+ ):
+ resp = client.get(
+ f"/platform/interactions/{_INTERACTION['interaction_id']}",
+ headers={"Authorization": "Bearer tok"},
+ )
+ self.assertEqual(resp.status_code, 403)
+
+ def test_forwards_authorization_header(self) -> None:
+ upstream = _mock_response(200, _INTERACTION)
+ mock_ctx = _mock_async_client(upstream)
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=mock_ctx,
+ ):
+ client.get(
+ f"/platform/interactions/{_INTERACTION['interaction_id']}",
+ headers={"Authorization": "Bearer owner-token"},
+ )
+ call_kwargs = mock_ctx.__aenter__.return_value.request.call_args.kwargs
+ self.assertEqual(call_kwargs["headers"]["Authorization"], "Bearer owner-token")
+
+ def test_auth_service_unreachable_returns_503(self) -> None:
+ with patch(
+ "control_center.api.routes_platform_interactions_proxy.httpx.AsyncClient",
+ return_value=_mock_async_client(side_effect=httpx.ConnectError("refused")),
+ ):
+ resp = client.get(
+ f"/platform/interactions/{_INTERACTION['interaction_id']}",
+ headers={"Authorization": "Bearer tok"},
+ )
+ self.assertEqual(resp.status_code, 503)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/frontend/cc-ui/src/apps/AdminApp.test.tsx b/frontend/cc-ui/src/apps/AdminApp.test.tsx
index 03f5731..44e74d3 100644
--- a/frontend/cc-ui/src/apps/AdminApp.test.tsx
+++ b/frontend/cc-ui/src/apps/AdminApp.test.tsx
@@ -102,6 +102,7 @@ vi.mock('../pages/identity/ServiceAccountsPage', () => ({
vi.mock('../pages/audit/AuditLogsPage', () => ({ default: () =>
}))
// PR-C (Control Center Sessions Integration).
vi.mock('../pages/security/SessionsPage', () => ({ default: () =>
}))
+vi.mock('../pages/InteractionsPage', () => ({ default: () =>
}))
// PR14.5C.
vi.mock('../pages/billing/BillingPage', () => ({
default: ({ orgId }: { orgId: number }) =>
,
@@ -726,6 +727,53 @@ describe('AdminApp auth gate', () => {
expect(screen.getAllByText('Sessions')).toHaveLength(1)
})
+ // ── PR-B5-B (Control Center Interaction Admin View) ───────────────────
+
+ it('reaches Interactions via the sidebar, no longer Coming Soon', async () => {
+ vi.mocked(auth.getToken).mockReturnValue('token-interactions1')
+ vi.mocked(auth.ensureSession).mockResolvedValue(admin)
+ vi.mocked(auth.getSessionUser).mockReturnValue(admin)
+ vi.mocked(auth.hasAdminAccess).mockReturnValue(true)
+ vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true)
+ vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true)
+
+ render( )
+ await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument())
+
+ clickNav('Interactions')
+
+ expect(await screen.findByTestId('InteractionsPage')).toBeInTheDocument()
+ expect(screen.queryByText('Coming soon')).not.toBeInTheDocument()
+ })
+
+ it('hides the Interactions nav item for a user who is not a platform admin', async () => {
+ vi.mocked(auth.getToken).mockReturnValue('token-interactions2')
+ vi.mocked(auth.ensureSession).mockResolvedValue(orgOnlyUser)
+ vi.mocked(auth.getSessionUser).mockReturnValue(orgOnlyUser)
+ vi.mocked(auth.hasAdminAccess).mockReturnValue(false)
+ vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true)
+ vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(false)
+
+ render( )
+ await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument())
+
+ expect(screen.queryByText('Interactions')).not.toBeInTheDocument()
+ })
+
+ it('shows exactly one Interactions navigation entry in the sidebar (no duplicate)', async () => {
+ vi.mocked(auth.getToken).mockReturnValue('token-interactions3')
+ vi.mocked(auth.ensureSession).mockResolvedValue(admin)
+ vi.mocked(auth.getSessionUser).mockReturnValue(admin)
+ vi.mocked(auth.hasAdminAccess).mockReturnValue(true)
+ vi.mocked(auth.hasOrganizationsAccess).mockReturnValue(true)
+ vi.mocked(auth.hasPlatformAdminAccess).mockReturnValue(true)
+
+ render( )
+ await waitFor(() => expect(screen.getByTestId('DashboardPage')).toBeInTheDocument())
+
+ expect(screen.getAllByText('Interactions')).toHaveLength(1)
+ })
+
it('signing out via the profile menu returns to the login screen', async () => {
vi.mocked(auth.getToken).mockReturnValue('token-admin6')
vi.mocked(auth.ensureSession).mockResolvedValue(admin)
diff --git a/frontend/cc-ui/src/apps/AdminApp.tsx b/frontend/cc-ui/src/apps/AdminApp.tsx
index ceca811..d3bdf9a 100644
--- a/frontend/cc-ui/src/apps/AdminApp.tsx
+++ b/frontend/cc-ui/src/apps/AdminApp.tsx
@@ -31,6 +31,7 @@ import ServiceAccountsPage from '../pages/identity/ServiceAccountsPage'
import BillingPage from '../pages/billing/BillingPage'
import AuditLogsPage from '../pages/audit/AuditLogsPage'
import SessionsPage from '../pages/security/SessionsPage'
+import InteractionsPage from '../pages/InteractionsPage'
import SecurityDashboardPage from '../pages/security/SecurityDashboardPage'
import OrganizationMFAPolicyPage from '../pages/security/OrganizationMFAPolicyPage'
import AuthGate from './AuthGate'
@@ -123,6 +124,9 @@ function AdminDashboard() {
// manage_all_orgs-gated, not org-scoped, so this is a flat platform-
// wide page (no org-picker/deep-link, unlike 'iam'/'api-keys').
const canSeeAuditLogs = hasPlatformAdminAccess()
+ // PR-B5-B: same reasoning as canSeeAuditLogs -- GET /platform/
+ // interactions is manage_all_orgs-gated, not org-scoped.
+ const canSeeInteractions = hasPlatformAdminAccess()
// PR11.5.6: same reasoning as canSeeAuditLogs -- GET /platform/users,
// GET /platform/orgs, GET /platform/audit-events (everything the
// Security Dashboard reads) are all manage_all_orgs-gated.
@@ -320,7 +324,7 @@ function AdminDashboard() {
) : undefined}
>
{renderPage(active, {
- canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeSecurityOverview, refreshKey,
+ canSeeOps, canSeeOrganizations, canSeeUsers, canSeeAuditLogs, canSeeInteractions, canSeeSecurityOverview, refreshKey,
selectedOrgId, setSelectedOrgId, selectedUserId, setSelectedUserId,
teamsOrgHint, rolesOrgHint, onViewTeams: handleViewTeams, onViewRoles: handleViewRoles,
selectedSsoOrgId, setSelectedSsoOrgId, navigateToSsoSettings,
@@ -338,6 +342,7 @@ interface RenderCtx {
canSeeOrganizations: boolean
canSeeUsers: boolean
canSeeAuditLogs: boolean
+ canSeeInteractions: boolean
canSeeSecurityOverview: boolean
refreshKey: number
selectedOrgId: number | null
@@ -440,6 +445,13 @@ function renderPage(active: PageKey, ctx: RenderCtx) {
case 'sessions':
return
+ // PR-B5-B: flat platform-wide page, no org-picker/deep-link -- same
+ // shape as 'audit-logs' above (interactions span every organization,
+ // filtered in-page), unlike 'sessions' immediately above (self-service).
+ case 'interactions':
+ if (!ctx.canSeeInteractions) return null
+ return
+
// PR11.2: same gate as 'organizations' -- see navigation.ts.
case 'teams':
if (!ctx.canSeeOrganizations) return null
diff --git a/frontend/cc-ui/src/interactions.ts b/frontend/cc-ui/src/interactions.ts
new file mode 100644
index 0000000..acb6c63
--- /dev/null
+++ b/frontend/cc-ui/src/interactions.ts
@@ -0,0 +1,111 @@
+// PR-B5-B (Control Center Interaction Admin View). Data layer, mirroring
+// audit.ts's own shape exactly. Every call hits control-center's own
+// backend at a relative path (routes_platform_interactions_proxy.py
+// proxies to omnibioai-auth's GET /platform/interactions[/{id}] --
+// PR-B5-A); no function here makes an authorization decision -- that's
+// entirely omnibioai-auth's job (require_permission(manage_all_orgs),
+// reused unchanged, no new permission).
+import { authHeaders, reportUnauthorized } from './auth'
+
+async function apiFetch(path: string, init: RequestInit = {}): Promise {
+ const r = await fetch(path, {
+ ...init,
+ headers: { ...authHeaders(), ...(init.headers ?? {}) },
+ })
+ if (r.status === 401) {
+ reportUnauthorized()
+ }
+ return r
+}
+
+// Mirrors omnibioai-auth's InteractionOut (app/schemas/interaction_read.py)
+// exactly. No display-name enrichment exists on the upstream response
+// (organization_id/user_id are returned as raw IDs, not resolved to a
+// name/email -- unlike AuditEventOut's organization_name/actor_email) --
+// see this PR's own report for why that's not added here. metadata is
+// untyped JSON on purpose -- every service/interaction_type's payload
+// shape differs, same reasoning AuditEvent's own metadata field has.
+export interface Interaction {
+ id: number
+ interaction_id: string
+ organization_id: number
+ user_id: number | null
+ session_id: string | null
+ trace_id: string | null
+ service: string
+ interaction_type: string
+ action: string
+ resource_type: string | null
+ resource_id: string | null
+ status: string | null
+ decision: string | null
+ metadata: Record | null
+ created_at: string
+}
+
+export interface InteractionListResponse {
+ items: Interaction[]
+ total: number
+ page: number
+ page_size: number
+ total_pages: number
+}
+
+export interface InteractionFilters {
+ organizationId?: number
+ userId?: number
+ service?: string
+ interactionType?: string
+ status?: string
+ /** ISO 8601 -- passed straight through to the backend's start_date/
+ * end_date query params, which parse it the same way. */
+ startDate?: string
+ endDate?: string
+ page?: number
+ pageSize?: number
+}
+
+export async function fetchInteractions(filters: InteractionFilters = {}): Promise {
+ const qs = new URLSearchParams()
+ qs.set('page', String(filters.page ?? 1))
+ qs.set('page_size', String(filters.pageSize ?? 20))
+ if (filters.organizationId != null) qs.set('organization_id', String(filters.organizationId))
+ if (filters.userId != null) qs.set('user_id', String(filters.userId))
+ if (filters.service) qs.set('service', filters.service)
+ if (filters.interactionType) qs.set('interaction_type', filters.interactionType)
+ if (filters.status) qs.set('status', filters.status)
+ if (filters.startDate) qs.set('start_date', filters.startDate)
+ if (filters.endDate) qs.set('end_date', filters.endDate)
+
+ const r = await apiFetch(`/platform/interactions?${qs.toString()}`)
+ if (!r.ok) throw new Error(`/platform/interactions ${r.status}`)
+ return r.json()
+}
+
+export async function fetchInteraction(interactionId: string): Promise {
+ const r = await apiFetch(`/platform/interactions/${encodeURIComponent(interactionId)}`)
+ if (!r.ok) throw new Error(`/platform/interactions/${interactionId} ${r.status}`)
+ return r.json()
+}
+
+// ── Defense-in-depth secret masking for the detail view ─────────────────
+//
+// The backend never writes a secret into an Interaction's metadata in
+// the first place (interaction_service.py's own _redact_metadata,
+// reused by app/workers/interaction_consumer.py for stream-sourced
+// events -- verified directly, see PR-B5-B's own report). This is a
+// second, independent layer on top of that guarantee, not a substitute
+// for it -- identical in shape and intent to audit.ts's own
+// maskSensitiveFields (same pattern, not re-derived): any key whose name
+// merely *looks* sensitive is masked before ever reaching the DOM, so a
+// future call site's mistake fails safe here too.
+const SENSITIVE_KEY_PATTERN = /secret|token|password|api_?key|client_secret|hash/i
+
+export function maskSensitiveFields(obj: Record | null): Record | null {
+ if (!obj) return obj
+ const masked: Record = {}
+ for (const [key, value] of Object.entries(obj)) {
+ masked[key] = SENSITIVE_KEY_PATTERN.test(key) ? '••••••••' : value
+ }
+ return masked
+}
diff --git a/frontend/cc-ui/src/navigation.test.ts b/frontend/cc-ui/src/navigation.test.ts
index 41d298f..2766628 100644
--- a/frontend/cc-ui/src/navigation.test.ts
+++ b/frontend/cc-ui/src/navigation.test.ts
@@ -42,3 +42,39 @@ describe('navigation: Sessions placement', () => {
expect(auditItem.children).toBeUndefined()
})
})
+
+// PR-B5-B (Control Center Interaction Admin View). Same reasoning as the
+// Sessions block above.
+
+describe('navigation: Interactions placement', () => {
+ it('has exactly one "interactions" entry across the entire tree', () => {
+ const found: { sectionKey: string; parentKey?: string }[] = []
+ for (const section of NAVIGATION) {
+ for (const item of section.items) {
+ if (item.key === 'interactions') found.push({ sectionKey: section.key })
+ for (const child of item.children ?? []) {
+ if (child.key === 'interactions') found.push({ sectionKey: section.key, parentKey: item.key })
+ }
+ }
+ }
+ expect(found).toHaveLength(1)
+ })
+
+ it('places "interactions" under the Security section, functional and gated', () => {
+ const securitySection = NAVIGATION.find(s => s.key === 'security')
+ expect(securitySection).toBeDefined()
+
+ const interactionsItem = securitySection!.items.find(i => i.key === 'interactions')
+ expect(interactionsItem).toBeDefined()
+ expect(interactionsItem!.functional).toBe(true)
+ // Same gate audit-logs uses -- GET /platform/interactions is
+ // manage_all_orgs-gated, not org-scoped or self-service.
+ expect(interactionsItem!.visible).toBeDefined()
+ })
+
+ it('is a top-level Security item alongside Audit Logs, not nested under it', () => {
+ const securitySection = NAVIGATION.find(s => s.key === 'security')!
+ const interactionsItem = securitySection.items.find(i => i.key === 'interactions')!
+ expect(interactionsItem.children).toBeUndefined()
+ })
+})
diff --git a/frontend/cc-ui/src/navigation.ts b/frontend/cc-ui/src/navigation.ts
index 96e3e2d..0787d80 100644
--- a/frontend/cc-ui/src/navigation.ts
+++ b/frontend/cc-ui/src/navigation.ts
@@ -25,7 +25,7 @@ export type PageKey =
| 'health' | 'docker' | 'ecosystem' | 'config' | 'llms' | 'cloud'
| 'organizations' | 'users' | 'teams' | 'roles'
| 'infrastructure' | 'workflows' | 'tool-execution' | 'ai-models'
- | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'api-keys'
+ | 'security-overview' | 'mfa-policy' | 'iam' | 'audit-logs' | 'sessions' | 'interactions' | 'api-keys'
| 'billing'
| 'rag' | 'pubmed'
| 'integrations' | 'settings'
@@ -197,6 +197,21 @@ export const NAVIGATION: NavSection[] = [
// (hasConsoleAccess in AdminApp.tsx) already has an account whose
// own sessions are meaningfully theirs to see.
{ key: 'sessions', label: 'Sessions', functional: true },
+ // PR-B5-B (Control Center Interaction Admin View). functional: true
+ // because a real page now exists (InteractionsPage), reusing
+ // omnibioai-auth's GET /platform/interactions (PR-B5-A) via
+ // routes_platform_interactions_proxy.py. Gated by
+ // hasPlatformAdminAccess, the same gate 'audit-logs' above uses and
+ // for the identical reason -- the backend route this page reads is
+ // gated by manage_all_orgs specifically (no dedicated interactions
+ // permission exists, and this PR doesn't add one), so
+ // hasPlatformAdminAccess is the one gate that actually matches who
+ // can reach real data here. Placed in Security next to Audit Logs
+ // (its closest technical precedent: paginated, filtered, platform-
+ // admin-only, free-form JSON metadata) rather than a new section --
+ // see this PR's own report for the open question of whether a
+ // dedicated "Activity" section would fit better long-term.
+ { key: 'interactions', label: 'Interactions', functional: true, visible: hasPlatformAdminAccess },
// PR11.4: Service Accounts & API Keys Management UI. functional:
// true because a real page now exists (ServiceAccountsPage,
// reached via an organization picker -- API keys/OAuth clients
diff --git a/frontend/cc-ui/src/pages/InteractionsPage.test.tsx b/frontend/cc-ui/src/pages/InteractionsPage.test.tsx
new file mode 100644
index 0000000..119cd44
--- /dev/null
+++ b/frontend/cc-ui/src/pages/InteractionsPage.test.tsx
@@ -0,0 +1,289 @@
+import { render, screen, waitFor, within } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import InteractionsPage from './InteractionsPage'
+import * as interactions from '../interactions'
+import type { Interaction, InteractionListResponse } from '../interactions'
+import * as organizations from '../organizations'
+import type { PlatformOrgSummary } from '../organizations'
+
+vi.mock('../interactions', async () => {
+ const actual = await vi.importActual('../interactions')
+ return { ...actual, fetchInteractions: vi.fn() }
+})
+
+vi.mock('../organizations', async () => {
+ const actual = await vi.importActual('../organizations')
+ return { ...actual, fetchPlatformOrgs: vi.fn() }
+})
+
+const orgOptions: PlatformOrgSummary[] = [
+ { id: 3, name: 'Acme Corp', status: 'active', owner_email: null, member_count: 1, team_count: 0, api_key_count: 0, oauth_client_count: 0, license_count: 0, sso_enabled: false, mfa_policy_required: false, mfa_policy_configured: false, created_at: '2026-07-01T00:00:00' },
+]
+
+const ragQueryInteraction: Interaction = {
+ id: 1, interaction_id: 'becbce38-0ada-427c-a29c-4c1bdfd95095',
+ organization_id: 3, user_id: 630, session_id: null, trace_id: 'trace-1',
+ service: 'rag', interaction_type: 'query', action: 'rag.query',
+ resource_type: 'study', resource_id: 'default', status: 'success', decision: null,
+ metadata: { mode: 'rag', top_k: 3 },
+ created_at: '2026-08-10T01:41:38',
+}
+
+// Deliberately includes a sensitive-looking metadata key even though the
+// real backend (interaction_service.py's own _redact_metadata, reused
+// by app/workers/interaction_consumer.py) never writes one into a
+// persisted Interaction's metadata -- proves this page's own
+// defense-in-depth masking layer works, same reasoning
+// AuditLogsPage.test.tsx's own overrideCreatedEvent fixture gives.
+const secretShapedInteraction: Interaction = {
+ id: 2, interaction_id: '11111111-2222-3333-4444-555555555555',
+ organization_id: 3, user_id: 631, session_id: null, trace_id: 'trace-2',
+ service: 'rag', interaction_type: 'query', action: 'rag.query',
+ resource_type: 'study', resource_id: 'default', status: 'error', decision: null,
+ metadata: { mode: 'rag', client_secret: 'should-never-render' },
+ created_at: '2026-08-10T02:00:00',
+}
+
+function listResponse(items: Interaction[], overrides: Partial = {}): InteractionListResponse {
+ return { items, total: items.length, page: 1, page_size: 20, total_pages: 1, ...overrides }
+}
+
+describe('InteractionsPage', () => {
+ beforeEach(() => {
+ vi.mocked(interactions.fetchInteractions).mockReset()
+ vi.mocked(organizations.fetchPlatformOrgs).mockReset().mockResolvedValue({
+ items: orgOptions, total: 1, page: 1, page_size: 100, total_pages: 1,
+ })
+ })
+
+ it('shows a loading state while the interactions fetch is in flight', async () => {
+ vi.mocked(interactions.fetchInteractions).mockReturnValue(new Promise(() => {}))
+ render( )
+ expect(await screen.findByText('Loading interactions…')).toBeInTheDocument()
+ })
+
+ it('shows the empty state when no interactions exist yet', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([]))
+ render( )
+ expect(await screen.findByText('No interactions recorded yet.')).toBeInTheDocument()
+ })
+
+ it('renders the table with Interaction Type/Action/Status/Organization/User/Details columns', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ render( )
+
+ expect(await screen.findByRole('columnheader', { name: 'Interaction Type' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'Action' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'Status' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'Organization' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'User' })).toBeInTheDocument()
+ expect(screen.getByRole('columnheader', { name: 'Details' })).toBeInTheDocument()
+
+ expect(screen.getByRole('cell', { name: 'query' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'rag.query' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'success' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'Org #3' })).toBeInTheDocument()
+ expect(screen.getByRole('cell', { name: 'User #630' })).toBeInTheDocument()
+ })
+
+ it('re-fetches with the organization filter applied when changed', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.selectOptions(screen.getByLabelText('Filter by organization'), '3')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ organizationId: 3 }),
+ ))
+ })
+
+ it('re-fetches with the user filter applied when changed', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.type(screen.getByLabelText('Filter by user ID'), '630')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ userId: 630 }),
+ ))
+ })
+
+ it('re-fetches with the service filter applied when changed', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.type(screen.getByLabelText('Filter by service'), 'rag')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ service: 'rag' }),
+ ))
+ })
+
+ it('re-fetches with the interaction type filter applied when changed', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.type(screen.getByLabelText('Filter by interaction type'), 'query')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ interactionType: 'query' }),
+ ))
+ })
+
+ it('re-fetches with the status filter applied when changed', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.type(screen.getByLabelText('Filter by status'), 'success')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ status: 'success' }),
+ ))
+ })
+
+ it('re-fetches with multiple filters applied together', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.selectOptions(screen.getByLabelText('Filter by organization'), '3')
+ await user.type(screen.getByLabelText('Filter by service'), 'rag')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ organizationId: 3, service: 'rag' }),
+ ))
+ })
+
+ it('resets to page 1 after a filter changes', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(
+ listResponse([ragQueryInteraction], { page: 2, total_pages: 3, total: 41 }),
+ )
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.selectOptions(screen.getByLabelText('Filter by organization'), '3')
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ page: 1, organizationId: 3 }),
+ ))
+ })
+
+ it('clears all filters and reloads unfiltered', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.type(screen.getByLabelText('Filter by service'), 'rag')
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({ service: 'rag' }),
+ ))
+
+ await user.click(screen.getByRole('button', { name: 'Clear filters' }))
+
+ await waitFor(() => expect(interactions.fetchInteractions).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ organizationId: undefined, userId: undefined, service: undefined,
+ interactionType: undefined, status: undefined, startDate: undefined, endDate: undefined,
+ }),
+ ))
+ })
+
+ it('shows pagination controls reflecting the response', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(
+ listResponse([ragQueryInteraction], { page: 2, total_pages: 3, total: 41 }),
+ )
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+ expect(screen.getByText(/Page/)).toBeInTheDocument()
+ expect(screen.getByText('2', { selector: 'span' })).toBeInTheDocument()
+ })
+
+ it('shows a permission-denied state on a 403, not the table', async () => {
+ vi.mocked(interactions.fetchInteractions).mockRejectedValue(new Error('/platform/interactions 403'))
+ render( )
+
+ expect(await screen.findByText('Permission denied')).toBeInTheDocument()
+ expect(screen.queryByText('Interaction Type')).not.toBeInTheDocument()
+ })
+
+ it('shows an error state with retry for an unexpected failure', async () => {
+ vi.mocked(interactions.fetchInteractions).mockRejectedValueOnce(new Error('/platform/interactions 503'))
+ vi.mocked(interactions.fetchInteractions).mockResolvedValueOnce(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+
+ expect(await screen.findByText('Error')).toBeInTheDocument()
+ await user.click(screen.getByRole('button', { name: 'Retry' }))
+ expect(await screen.findByRole('cell', { name: 'query' })).toBeInTheDocument()
+ })
+
+ it('opens the detail modal on "View", showing all key fields', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.click(screen.getByRole('button', { name: 'View →' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'Interaction detail' })
+ expect(within(dialog).getByText(ragQueryInteraction.interaction_id)).toBeInTheDocument()
+ expect(within(dialog).getByText('trace-1')).toBeInTheDocument()
+ expect(within(dialog).getByText('rag.query')).toBeInTheDocument()
+ expect(within(dialog).getByText('study #default')).toBeInTheDocument()
+ expect(within(dialog).getByText('User #630')).toBeInTheDocument()
+ expect(within(dialog).getByText('Org #3')).toBeInTheDocument()
+ })
+
+ it('renders metadata as pretty-printed JSON in the detail view', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.click(screen.getByRole('button', { name: 'View →' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'Interaction detail' })
+ expect(within(dialog).getByText(/"mode": "rag"/)).toBeInTheDocument()
+ expect(within(dialog).getByText(/"top_k": 3/)).toBeInTheDocument()
+ })
+
+ it('closes the detail modal', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([ragQueryInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'query' })
+
+ await user.click(screen.getByRole('button', { name: 'View →' }))
+ expect(screen.getByRole('dialog', { name: 'Interaction detail' })).toBeInTheDocument()
+
+ await user.click(screen.getByRole('button', { name: 'Close' }))
+ expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
+ })
+
+ it('masks sensitive-looking metadata keys in the detail view', async () => {
+ vi.mocked(interactions.fetchInteractions).mockResolvedValue(listResponse([secretShapedInteraction]))
+ const user = userEvent.setup()
+ render( )
+ await screen.findByRole('cell', { name: 'error' })
+
+ await user.click(screen.getByRole('button', { name: 'View →' }))
+
+ const dialog = screen.getByRole('dialog', { name: 'Interaction detail' })
+ expect(within(dialog).queryByText(/should-never-render/)).not.toBeInTheDocument()
+ expect(within(dialog).getByText(/••••••••/)).toBeInTheDocument()
+ })
+})
diff --git a/frontend/cc-ui/src/pages/InteractionsPage.tsx b/frontend/cc-ui/src/pages/InteractionsPage.tsx
new file mode 100644
index 0000000..cfd0738
--- /dev/null
+++ b/frontend/cc-ui/src/pages/InteractionsPage.tsx
@@ -0,0 +1,280 @@
+import { useEffect, useState } from 'react'
+import { ShieldAlert } from 'lucide-react'
+import {
+ fetchInteractions, maskSensitiveFields,
+ type Interaction, type InteractionListResponse,
+} from '../interactions'
+import { fetchPlatformOrgs, type PlatformOrgSummary } from '../organizations'
+import { Card, SectionHeader, LoadingState, ErrorState, EmptyState, ActionToolbar, Button, DataTable, Pagination } from '../components/ui'
+import { formatDate } from '../format'
+
+// PR-B5-B (Control Center Interaction Admin View). Mirrors
+// AuditLogsPage.tsx's architecture exactly -- both read a paginated,
+// filtered, platform-admin-only (manage_all_orgs) ledger with a
+// free-form JSON metadata field, unlike SessionsPage.tsx's self-service,
+// unpaginated, filter-less shape (a small, inherently bounded dataset
+// Interactions has no equivalent of). See this PR's own report for why
+// Audit Logs, not Sessions, is the governing precedent here.
+//
+// Interaction.status/service/interaction_type have no fixed, backend-
+// enforced vocabulary (unlike AuditEvent.event_type, which has a
+// maintained KNOWN_EVENT_TYPES list) -- confirmed by reading
+// app/db/models.py::Interaction directly, plain unconstrained string
+// columns. Free-text filters for all three, not dropdowns: a hardcoded
+// vocabulary here (e.g. RAG's current "success"/"error"/"timeout")
+// would silently go stale the moment a second producer with different
+// values exists, and there is no source of truth to build a dropdown
+// from the way KNOWN_EVENT_TYPES / fetchPlatformOrgs provide one for
+// event_type / organization_id respectively.
+
+const PAGE_SIZE = 20
+
+const selectStyle: React.CSSProperties = {
+ fontSize: 12, padding: '7px 10px', borderRadius: 8,
+ border: '1px solid var(--border)', background: 'var(--surface)', color: 'var(--text)',
+}
+const fieldLabel: React.CSSProperties = { fontSize: 12, fontWeight: 600, color: 'var(--text2)', marginBottom: 4, display: 'block' }
+
+// ── Detail modal -- same page-local Modal shape AuditLogsPage.tsx's
+// EventDetailModal / SessionsPage.tsx's SessionDetailModal already
+// established; an Interaction isn't a manageable resource with its own
+// page (a record to inspect, not edit), so a full navigation is the
+// wrong shape here, same reasoning EventDetailModal's own comment
+// gives. ──────────────────────────────────────────────────────────────
+function InteractionDetailModal({ interaction, onClose }: { interaction: Interaction; onClose: () => void }) {
+ return (
+ { if (e.target === e.currentTarget) onClose() }}
+ >
+
+
+ {interaction.interaction_type}
+ ×
+
+
+
+ {formatDate(interaction.created_at)}
+ {interaction.interaction_id}
+ {interaction.service}
+ {interaction.action}
+ {interaction.status ?? '—'}
+ {interaction.decision ?? '—'}
+ {`Org #${interaction.organization_id}`}
+ {interaction.user_id != null ? `User #${interaction.user_id}` : '—'}
+ {interaction.resource_type ? `${interaction.resource_type}${interaction.resource_id ? ` #${interaction.resource_id}` : ''}` : '—'}
+ {interaction.session_id ?? '—'}
+ {interaction.trace_id ?? '—'}
+
+
+ {interaction.metadata && (
+
+ )}
+
+
+ Sensitive-looking fields (secrets, tokens, hashes) are masked here as a defense-in-depth
+ measure -- the backend never writes one into an interaction's metadata in the first place.
+
+
+
+ )
+}
+
+function Field({ title, children }: { title: string; children: React.ReactNode }) {
+ return (
+
+ )
+}
+
+function MetadataBlock({ title, value }: { title: string; value: Record | null }) {
+ if (!value || Object.keys(value).length === 0) return null
+ return (
+
+
{title}
+
+ {JSON.stringify(value, null, 2)}
+
+
+ )
+}
+
+export default function InteractionsPage() {
+ const [data, setData] = useState(null)
+ const [denied, setDenied] = useState(false)
+ const [error, setError] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [page, setPage] = useState(1)
+ const [orgOptions, setOrgOptions] = useState(null)
+ const [orgFilter, setOrgFilter] = useState('')
+ const [userFilter, setUserFilter] = useState('')
+ const [serviceFilter, setServiceFilter] = useState('')
+ const [interactionTypeFilter, setInteractionTypeFilter] = useState('')
+ const [statusFilter, setStatusFilter] = useState('')
+ const [startDate, setStartDate] = useState('')
+ const [endDate, setEndDate] = useState('')
+ const [selected, setSelected] = useState(null)
+
+ useEffect(() => {
+ fetchPlatformOrgs({ pageSize: 100 }).then(r => setOrgOptions(r.items)).catch(() => setOrgOptions(null))
+ }, [])
+
+ const load = () => {
+ setLoading(true)
+ setError(null)
+ setDenied(false)
+ fetchInteractions({
+ page, pageSize: PAGE_SIZE,
+ organizationId: orgFilter ? Number(orgFilter) : undefined,
+ userId: userFilter ? Number(userFilter) : undefined,
+ service: serviceFilter || undefined,
+ interactionType: interactionTypeFilter || undefined,
+ status: statusFilter || undefined,
+ startDate: startDate || undefined,
+ endDate: endDate || undefined,
+ })
+ .then(setData)
+ .catch((e: unknown) => {
+ const message = e instanceof Error ? e.message : String(e)
+ if (message.endsWith(' 403')) setDenied(true)
+ else setError(message)
+ })
+ .finally(() => setLoading(false))
+ }
+
+ useEffect(load, [page, orgFilter, userFilter, serviceFilter, interactionTypeFilter, statusFilter, startDate, endDate])
+
+ const hasActiveFilters = !!(orgFilter || userFilter || serviceFilter || interactionTypeFilter || statusFilter || startDate || endDate)
+ const clearFilters = () => {
+ setOrgFilter(''); setUserFilter(''); setServiceFilter(''); setInteractionTypeFilter('')
+ setStatusFilter(''); setStartDate(''); setEndDate(''); setPage(1)
+ }
+
+ return (
+
+
+ Refresh
+
+ }
+ />
+
+ {denied ? (
+
+ ) : (
+ <>
+
+
+
+
+ {loading && }
+ {!loading && error && }
+
+ {!loading && !error && data && (
+ <>
+ i.id}
+ emptyLabel={hasActiveFilters ? 'No interactions match these filters.' : 'No interactions recorded yet.'}
+ rows={data.items}
+ columns={[
+ { key: 'interaction_type', header: 'Interaction Type', render: i => i.interaction_type },
+ { key: 'action', header: 'Action', render: i => i.action },
+ { key: 'status', header: 'Status', render: i => i.status ?? '—' },
+ { key: 'organization', header: 'Organization', render: i => `Org #${i.organization_id}` },
+ { key: 'user', header: 'User', render: i => i.user_id != null ? `User #${i.user_id}` : '—' },
+ {
+ key: 'details', header: 'Details', render: i => (
+ setSelected(i)}
+ style={{ fontSize: 12, fontWeight: 600, color: 'var(--accent)', background: 'none', border: 'none', cursor: 'pointer', padding: 0 }}
+ >
+ View →
+
+ ),
+ },
+ ]}
+ />
+
+ >
+ )}
+ >
+ )}
+
+ {selected && setSelected(null)} />}
+
+ )
+}
diff --git a/frontend/cc-ui/tsconfig.app.tsbuildinfo b/frontend/cc-ui/tsconfig.app.tsbuildinfo
index 4d37395..69ace59 100644
--- a/frontend/cc-ui/tsconfig.app.tsbuildinfo
+++ b/frontend/cc-ui/tsconfig.app.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/dashboard.ts","./src/main.tsx","./src/navigation.ts","./src/organizations.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sso.ts","./src/teams.ts","./src/test-setup.ts","./src/users.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMemberSelector.test.tsx","./src/components/teams/TeamMemberSelector.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx"],"version":"5.9.3"}
\ No newline at end of file
+{"root":["./src/api.ts","./src/audit.ts","./src/auth.test.ts","./src/auth.ts","./src/billing.ts","./src/dashboard.ts","./src/format.ts","./src/interactions.ts","./src/main.tsx","./src/model_registry.ts","./src/navigation.test.ts","./src/navigation.ts","./src/organizations.ts","./src/platform_config.ts","./src/rag.ts","./src/roles.ts","./src/security.ts","./src/serviceAccounts.ts","./src/sessions.ts","./src/sso.ts","./src/teams.ts","./src/tes.ts","./src/test-setup.ts","./src/users.ts","./src/workflows.ts","./src/apps/AdminApp.test.tsx","./src/apps/AdminApp.tsx","./src/apps/AuthGate.tsx","./src/apps/ControlApp.test.tsx","./src/apps/ControlApp.tsx","./src/apps/UnknownModeNotice.test.tsx","./src/apps/UnknownModeNotice.tsx","./src/components/AccessDenied.test.tsx","./src/components/AccessDenied.tsx","./src/components/AdminLogo.tsx","./src/components/Header.tsx","./src/components/LoginScreen.test.tsx","./src/components/LoginScreen.tsx","./src/components/OAuthButtons.tsx","./src/components/StatusBadge.tsx","./src/components/dashboard/AlertCard.tsx","./src/components/dashboard/DashboardCard.tsx","./src/components/dashboard/DashboardGrid.tsx","./src/components/dashboard/HealthCard.tsx","./src/components/dashboard/MetricCard.tsx","./src/components/dashboard/StatusCard.tsx","./src/components/dashboard/TrendCard.tsx","./src/components/dashboard/dashboard-widgets.test.tsx","./src/components/dashboard/index.ts","./src/components/organizations/OrganizationStatusBadge.tsx","./src/components/organizations/OrganizationSummaryCard.tsx","./src/components/organizations/OrganizationTable.tsx","./src/components/organizations/SecuritySummaryCard.tsx","./src/components/roles/PermissionSelector.test.tsx","./src/components/roles/PermissionSelector.tsx","./src/components/roles/RoleAssignmentList.tsx","./src/components/roles/RoleBadge.tsx","./src/components/roles/RoleSelector.test.tsx","./src/components/roles/RoleSelector.tsx","./src/components/shell/AppShell.tsx","./src/components/shell/Breadcrumb.tsx","./src/components/shell/Footer.tsx","./src/components/shell/GlobalSearch.tsx","./src/components/shell/NotificationsMenu.tsx","./src/components/shell/OrgSelector.tsx","./src/components/shell/ProfileMenu.tsx","./src/components/shell/SidebarNav.tsx","./src/components/shell/ThemeToggle.tsx","./src/components/shell/TopAppBar.tsx","./src/components/shell/index.ts","./src/components/teams/TeamMemberSelector.test.tsx","./src/components/teams/TeamMemberSelector.tsx","./src/components/teams/TeamRow.tsx","./src/components/teams/TeamsCard.tsx","./src/components/ui/ActionToolbar.tsx","./src/components/ui/BackLink.tsx","./src/components/ui/Button.tsx","./src/components/ui/Card.tsx","./src/components/ui/ComingSoon.tsx","./src/components/ui/DataTable.tsx","./src/components/ui/EmptyState.tsx","./src/components/ui/ErrorState.tsx","./src/components/ui/LoadingState.tsx","./src/components/ui/PageContainer.tsx","./src/components/ui/Pagination.tsx","./src/components/ui/SectionHeader.tsx","./src/components/ui/SessionExpiredState.tsx","./src/components/ui/StatCard.tsx","./src/components/ui/icon-type.ts","./src/components/ui/index.ts","./src/components/users/UserMFASecurityCard.test.tsx","./src/components/users/UserMFASecurityCard.tsx","./src/components/users/UserOrgMembershipList.tsx","./src/components/users/UserStatusAction.tsx","./src/pages/CloudPage.tsx","./src/pages/ConfigPage.tsx","./src/pages/DashboardPage.test.tsx","./src/pages/DashboardPage.tsx","./src/pages/DockerPage.tsx","./src/pages/EcosystemPage.tsx","./src/pages/HealthPage.tsx","./src/pages/InteractionsPage.test.tsx","./src/pages/InteractionsPage.tsx","./src/pages/LlmPage.tsx","./src/pages/OrganizationDetailPage.test.tsx","./src/pages/OrganizationDetailPage.tsx","./src/pages/OrganizationsPage.test.tsx","./src/pages/OrganizationsPage.tsx","./src/pages/PlatformSettingsPage.test.tsx","./src/pages/PlatformSettingsPage.tsx","./src/pages/UserDetailPage.test.tsx","./src/pages/UserDetailPage.tsx","./src/pages/UsersPage.test.tsx","./src/pages/UsersPage.tsx","./src/pages/audit/AuditLogsPage.test.tsx","./src/pages/audit/AuditLogsPage.tsx","./src/pages/billing/BillingPage.test.tsx","./src/pages/billing/BillingPage.tsx","./src/pages/billing/SubscriptionPage.test.tsx","./src/pages/billing/SubscriptionPage.tsx","./src/pages/identity/RolesPage.test.tsx","./src/pages/identity/RolesPage.tsx","./src/pages/identity/SSOSettingsPage.test.tsx","./src/pages/identity/SSOSettingsPage.tsx","./src/pages/identity/ServiceAccountsPage.test.tsx","./src/pages/identity/ServiceAccountsPage.tsx","./src/pages/identity/TeamsPage.test.tsx","./src/pages/identity/TeamsPage.tsx","./src/pages/operations/AIModelsPage.test.tsx","./src/pages/operations/AIModelsPage.tsx","./src/pages/operations/RAGPage.test.tsx","./src/pages/operations/RAGPage.tsx","./src/pages/operations/ToolExecutionPage.test.tsx","./src/pages/operations/ToolExecutionPage.tsx","./src/pages/operations/WorkflowsPage.test.tsx","./src/pages/operations/WorkflowsPage.tsx","./src/pages/security/OrganizationMFAPolicyPage.test.tsx","./src/pages/security/OrganizationMFAPolicyPage.tsx","./src/pages/security/SecurityDashboardPage.test.tsx","./src/pages/security/SecurityDashboardPage.tsx","./src/pages/security/SessionsPage.test.tsx","./src/pages/security/SessionsPage.tsx"],"version":"5.9.3"}
\ No newline at end of file