diff --git a/frontend/ui/src/app/api/github/token/route.test.ts b/frontend/ui/src/app/api/github/token/route.test.ts index e4397726e..50038c230 100644 --- a/frontend/ui/src/app/api/github/token/route.test.ts +++ b/frontend/ui/src/app/api/github/token/route.test.ts @@ -125,6 +125,17 @@ describe("GET /api/github/token", () => { const res = await GET(makeRequest({ workspaceIdQuery: "ws_1" })); expect(res.status).toBe(403); }); + + it("session path requires ADMIN — VIEWER gets 403 and no token is minted", async () => { + requireAuthMock.mockResolvedValue({ user: { id: "u_1" } }); + requireMembershipMock.mockResolvedValue({ + error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) }, + }); + const res = await GET(makeRequest({ workspaceIdQuery: "ws_1" })); + expect(res.status).toBe(403); + expect(requireMembershipMock).toHaveBeenCalledWith("u_1", "ws_1", "ADMIN"); + expect(getInstallationTokenMock).not.toHaveBeenCalled(); + }); }); describe("installation lookup", () => { diff --git a/frontend/ui/src/app/api/github/token/route.ts b/frontend/ui/src/app/api/github/token/route.ts index aab7b97fa..5713d6a10 100644 --- a/frontend/ui/src/app/api/github/token/route.ts +++ b/frontend/ui/src/app/api/github/token/route.ts @@ -27,7 +27,11 @@ export async function GET(request: NextRequest) { if (!workspaceId) { return NextResponse.json({ error: "workspaceId required" }, { status: 400 }); } - const memberCheck = await requireWorkspaceMembership(authResult.user.id, workspaceId); + const memberCheck = await requireWorkspaceMembership( + authResult.user.id, + workspaceId, + "ADMIN", + ); if (memberCheck.error) return memberCheck.error; } diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.test.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.test.ts new file mode 100644 index 000000000..9f79600dd --- /dev/null +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ NextRequest: class {} })); + +const requireAuthMock = vi.fn(); +const requireProjectAccessMock = vi.fn(); +vi.mock("@/lib/auth-helpers", () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), + requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args), + successResponse: (data: unknown, status = 200) => ({ + status, + json: async () => data, + }), +})); + +vi.mock("@traceroot/core", () => ({ + ModelSource: { BYOK: "byok", SYSTEM: "system" }, + PlanType: { FREE: "FREE" }, + isBillingEnabled: () => false, + prisma: { + modelProvider: { findFirst: vi.fn().mockResolvedValue(null) }, + workspace: { findUnique: vi.fn().mockResolvedValue(null) }, + }, +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { POST } from "./route"; + +function makeParams(sessionId = "sess-1") { + return { params: Promise.resolve({ projectId: "proj-1", sessionId }) }; +} + +function makeRequest(body: unknown = { message: "hello", model: "gpt-5", source: "system" }) { + return { json: async () => body } as unknown as Parameters[0]; +} + +beforeEach(() => { + requireAuthMock.mockReset(); + requireProjectAccessMock.mockReset(); + fetchMock.mockReset(); + requireAuthMock.mockResolvedValue({ user: { id: "user-1" } }); +}); + +describe("POST .../ai/sessions/[sessionId]/messages — role gate", () => { + it("returns 403 for VIEWER (requires MEMBER)", async () => { + requireProjectAccessMock.mockResolvedValue({ + error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) }, + }); + const res = await POST(makeRequest(), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows MEMBER to post a message", async () => { + requireProjectAccessMock.mockResolvedValue({ + project: { id: "proj-1", workspaceId: "ws-1" }, + }); + const mockStream = new ReadableStream(); + fetchMock.mockResolvedValue({ ok: true, body: mockStream }); + const res = await POST(makeRequest(), makeParams()); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.ts index 7528f8756..051aa22aa 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/messages/route.ts @@ -43,7 +43,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId, sessionId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER"); if (accessResult.error) return accessResult.error; const body = await request.json(); diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.test.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.test.ts new file mode 100644 index 000000000..510a47fb1 --- /dev/null +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ NextRequest: class {} })); + +const requireAuthMock = vi.fn(); +const requireProjectAccessMock = vi.fn(); +vi.mock("@/lib/auth-helpers", () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), + requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args), + successResponse: (data: unknown, status = 200) => ({ + status, + json: async () => data, + }), +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { DELETE } from "./route"; + +function makeParams(sessionId = "sess-1") { + return { params: Promise.resolve({ projectId: "proj-1", sessionId }) }; +} + +function makeRequest() { + return {} as unknown as Parameters[0]; +} + +beforeEach(() => { + requireAuthMock.mockReset(); + requireProjectAccessMock.mockReset(); + fetchMock.mockReset(); + requireAuthMock.mockResolvedValue({ user: { id: "user-1" } }); +}); + +describe("DELETE .../ai/sessions/[sessionId] — role gate", () => { + it("returns 403 for VIEWER (requires ADMIN)", async () => { + requireProjectAccessMock.mockResolvedValue({ + error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) }, + }); + const res = await DELETE(makeRequest(), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "ADMIN"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows ADMIN to delete a session", async () => { + requireProjectAccessMock.mockResolvedValue({ + project: { id: "proj-1", workspaceId: "ws-1" }, + }); + fetchMock.mockResolvedValue({ ok: true, json: async () => ({}) }); + const res = await DELETE(makeRequest(), makeParams()); + expect(res.status).toBe(200); + expect(fetchMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.ts index 22149f252..41320357b 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/[sessionId]/route.ts @@ -13,7 +13,7 @@ export async function DELETE(_request: NextRequest, { params }: RouteParams) { const { projectId, sessionId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "ADMIN"); if (accessResult.error) return accessResult.error; const res = await fetch( diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.test.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.test.ts new file mode 100644 index 000000000..7e080c905 --- /dev/null +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ NextRequest: class {} })); + +const requireAuthMock = vi.fn(); +const requireProjectAccessMock = vi.fn(); +vi.mock("@/lib/auth-helpers", () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), + requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args), + successResponse: (data: unknown, status = 200) => ({ + status, + json: async () => data, + }), +})); + +const fetchMock = vi.fn(); +vi.stubGlobal("fetch", fetchMock); + +import { POST } from "./route"; + +function makeParams() { + return { params: Promise.resolve({ projectId: "proj-1" }) }; +} + +function makeRequest(body: unknown = {}) { + return { json: async () => body } as unknown as Parameters[0]; +} + +beforeEach(() => { + requireAuthMock.mockReset(); + requireProjectAccessMock.mockReset(); + fetchMock.mockReset(); + requireAuthMock.mockResolvedValue({ user: { id: "user-1" } }); +}); + +describe("POST .../ai/sessions — role gate", () => { + it("returns 403 for VIEWER (requires MEMBER)", async () => { + requireProjectAccessMock.mockResolvedValue({ + error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) }, + }); + const res = await POST(makeRequest({ title: "new session" }), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER"); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it("allows MEMBER to create a session", async () => { + requireProjectAccessMock.mockResolvedValue({ + project: { id: "proj-1", workspaceId: "ws-1" }, + }); + fetchMock.mockResolvedValue({ + ok: true, + json: async () => ({ id: "sess-1" }), + }); + const res = await POST(makeRequest({ title: "new session" }), makeParams()); + expect(res.status).toBe(201); + expect(fetchMock).toHaveBeenCalled(); + }); +}); diff --git a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.ts b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.ts index 70d9b84ec..9501d142a 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/ai/sessions/route.ts @@ -42,7 +42,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { const { projectId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER"); if (accessResult.error) return accessResult.error; const body = await request.json(); diff --git a/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.test.ts b/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.test.ts new file mode 100644 index 000000000..9b5c021cc --- /dev/null +++ b/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +vi.mock("next/server", () => ({ NextRequest: class {} })); + +const detectorFindFirstMock = vi.fn(); +const detectorUpdateMock = vi.fn(); +const detectorDeleteMock = vi.fn(); +vi.mock("@traceroot/core", () => ({ + prisma: { + detector: { + findFirst: (...args: unknown[]) => detectorFindFirstMock(...args), + update: (...args: unknown[]) => detectorUpdateMock(...args), + delete: (...args: unknown[]) => detectorDeleteMock(...args), + }, + }, +})); + +const requireAuthMock = vi.fn(); +const requireProjectAccessMock = vi.fn(); +vi.mock("@/lib/auth-helpers", () => ({ + requireAuth: (...args: unknown[]) => requireAuthMock(...args), + requireProjectAccess: (...args: unknown[]) => requireProjectAccessMock(...args), + errorResponse: (msg: string, status: number) => ({ + status, + json: async () => ({ error: msg }), + }), + successResponse: (data: unknown, status = 200) => ({ + status, + json: async () => data, + }), +})); + +import { PATCH, DELETE } from "./route"; + +function makeParams(detectorId = "det-1") { + return { params: Promise.resolve({ projectId: "proj-1", detectorId }) }; +} + +function makePatchRequest(body: unknown) { + return { json: async () => body } as unknown as Parameters[0]; +} + +function makeDeleteRequest() { + return {} as unknown as Parameters[0]; +} + +function viewerForbidden() { + return { + error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) }, + }; +} + +function adminForbidden() { + return { + error: { status: 403, json: async () => ({ error: "Requires ADMIN role or higher" }) }, + }; +} + +beforeEach(() => { + detectorFindFirstMock.mockReset(); + detectorUpdateMock.mockReset(); + detectorDeleteMock.mockReset(); + requireAuthMock.mockReset(); + requireProjectAccessMock.mockReset(); + requireAuthMock.mockResolvedValue({ user: { id: "user-1" } }); +}); + +describe("PATCH .../detectors/[detectorId] — role gate", () => { + it("returns 403 for VIEWER (requires MEMBER)", async () => { + requireProjectAccessMock.mockResolvedValue(viewerForbidden()); + const res = await PATCH(makePatchRequest({ name: "updated" }), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER"); + expect(detectorUpdateMock).not.toHaveBeenCalled(); + }); + + it("allows MEMBER to update a detector", async () => { + requireProjectAccessMock.mockResolvedValue({ project: { workspaceId: "ws-1" } }); + detectorFindFirstMock.mockResolvedValue({ id: "det-1", projectId: "proj-1" }); + detectorUpdateMock.mockResolvedValue({ id: "det-1", name: "updated" }); + const res = await PATCH(makePatchRequest({ name: "updated" }), makeParams()); + expect(res.status).toBe(200); + expect(detectorUpdateMock).toHaveBeenCalled(); + }); +}); + +describe("DELETE .../detectors/[detectorId] — role gate", () => { + it("returns 403 for VIEWER (requires ADMIN)", async () => { + requireProjectAccessMock.mockResolvedValue(adminForbidden()); + const res = await DELETE(makeDeleteRequest(), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "ADMIN"); + expect(detectorDeleteMock).not.toHaveBeenCalled(); + }); + + it("allows ADMIN to delete a detector", async () => { + requireProjectAccessMock.mockResolvedValue({ project: { workspaceId: "ws-1" } }); + detectorFindFirstMock.mockResolvedValue({ id: "det-1", projectId: "proj-1" }); + detectorDeleteMock.mockResolvedValue({}); + const res = await DELETE(makeDeleteRequest(), makeParams()); + expect(res.status).toBe(200); + expect(detectorDeleteMock).toHaveBeenCalledWith({ where: { id: "det-1" } }); + }); +}); diff --git a/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.ts b/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.ts index a4c0a7298..b4b216062 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/detectors/[detectorId]/route.ts @@ -38,7 +38,7 @@ export async function PATCH(req: NextRequest, { params }: RouteParams) { const { user } = authResult; const { projectId, detectorId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER"); if (accessResult.error) return accessResult.error; const existing = await prisma.detector.findFirst({ @@ -167,7 +167,7 @@ export async function DELETE(_req: NextRequest, { params }: RouteParams) { const { user } = authResult; const { projectId, detectorId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "ADMIN"); if (accessResult.error) return accessResult.error; const existing = await prisma.detector.findFirst({ diff --git a/frontend/ui/src/app/api/projects/[projectId]/detectors/route.test.ts b/frontend/ui/src/app/api/projects/[projectId]/detectors/route.test.ts index a93c2399b..852fecca0 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/detectors/route.test.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/detectors/route.test.ts @@ -50,6 +50,18 @@ beforeEach(() => { detectorCreateMock.mockResolvedValue({ id: "det-1" }); }); +describe("POST .../detectors — role gate", () => { + it("returns 403 for VIEWER (requires MEMBER)", async () => { + requireProjectAccessMock.mockResolvedValue({ + error: { status: 403, json: async () => ({ error: "Requires MEMBER role or higher" }) }, + }); + const res = await POST(makeRequest(validBody()), makeParams()); + expect(res.status).toBe(403); + expect(requireProjectAccessMock).toHaveBeenCalledWith("user-1", "proj-1", "MEMBER"); + expect(detectorCreateMock).not.toHaveBeenCalled(); + }); +}); + describe("POST .../detectors — sampleRate default", () => { it("defaults sampleRate to 25 when omitted", async () => { const res = await POST(makeRequest(validBody()), makeParams()); diff --git a/frontend/ui/src/app/api/projects/[projectId]/detectors/route.ts b/frontend/ui/src/app/api/projects/[projectId]/detectors/route.ts index 8328fb1fe..ac521341b 100644 --- a/frontend/ui/src/app/api/projects/[projectId]/detectors/route.ts +++ b/frontend/ui/src/app/api/projects/[projectId]/detectors/route.ts @@ -61,7 +61,7 @@ export async function POST(req: NextRequest, { params }: RouteParams) { const { user } = authResult; const { projectId } = await params; - const accessResult = await requireProjectAccess(user.id, projectId); + const accessResult = await requireProjectAccess(user.id, projectId, "MEMBER"); if (accessResult.error) return accessResult.error; let body: unknown; diff --git a/frontend/ui/src/app/projects/[projectId]/detectors/new/page.test.tsx b/frontend/ui/src/app/projects/[projectId]/detectors/new/page.test.tsx index 06a029a75..95ae9a201 100644 --- a/frontend/ui/src/app/projects/[projectId]/detectors/new/page.test.tsx +++ b/frontend/ui/src/app/projects/[projectId]/detectors/new/page.test.tsx @@ -6,6 +6,8 @@ import { getTemplate } from "@/features/detectors/templates"; const mocks = vi.hoisted(() => ({ push: vi.fn(), mutateAsync: vi.fn().mockResolvedValue({ id: "det-1" }), + createIsError: false, + createError: null as Error | null, })); vi.mock("next/navigation", () => ({ @@ -13,11 +15,19 @@ vi.mock("next/navigation", () => ({ useRouter: () => ({ push: mocks.push }), })); vi.mock("@/features/detectors/hooks/use-detectors", () => ({ - useCreateDetector: () => ({ mutateAsync: mocks.mutateAsync, isPending: false }), + useCreateDetector: () => ({ + mutateAsync: mocks.mutateAsync, + isPending: false, + isError: mocks.createIsError, + error: mocks.createError, + }), })); vi.mock("@/features/projects/hooks", () => ({ useProject: () => ({ data: undefined }), })); +vi.mock("@/features/workspaces/hooks", () => ({ + useWorkspace: () => ({ data: { role: "MEMBER" } }), +})); vi.mock("@/features/projects/components", () => ({ ProjectBreadcrumb: () => null, })); @@ -40,6 +50,8 @@ afterEach(() => { cleanup(); mocks.mutateAsync.mockClear(); mocks.push.mockClear(); + mocks.createIsError = false; + mocks.createError = null; }); describe("NewDetectorPage", () => { @@ -65,6 +77,13 @@ describe("NewDetectorPage", () => { expect(mocks.push).toHaveBeenCalledWith("/projects/proj-1/detectors"); }); + it("shows error message when Create fails", () => { + mocks.createIsError = true; + mocks.createError = new Error("Quota exceeded"); + render(); + expect(screen.getByText("Quota exceeded")).toBeDefined(); + }); + it("submits user-edited name and prompt over the template defaults", async () => { render(); fireEvent.change(screen.getByDisplayValue("Failure Detector"), { diff --git a/frontend/ui/src/app/projects/[projectId]/detectors/new/page.tsx b/frontend/ui/src/app/projects/[projectId]/detectors/new/page.tsx index e7daaf081..f37ed90cd 100644 --- a/frontend/ui/src/app/projects/[projectId]/detectors/new/page.tsx +++ b/frontend/ui/src/app/projects/[projectId]/detectors/new/page.tsx @@ -20,6 +20,7 @@ import type { TriggerCondition } from "@/features/detectors/components/trigger-e import { AgentModelLink } from "@/features/detectors/components/agent-model-link"; import { RcaToggle } from "@/features/detectors/components/rca-toggle"; import { useProject } from "@/features/projects/hooks"; +import { useWorkspace } from "@/features/workspaces/hooks"; import { ProjectBreadcrumb } from "@/features/projects/components"; export default function NewDetectorPage() { @@ -28,6 +29,8 @@ export default function NewDetectorPage() { const projectId = params.projectId as string; const { data: project } = useProject(projectId); + const { data: workspace } = useWorkspace(project?.workspace_id ?? ""); + const isMember = workspace?.role === "MEMBER" || workspace?.role === "ADMIN"; const createMutation = useCreateDetector(projectId); const INITIAL_TEMPLATE = DETECTOR_TEMPLATES[0]; @@ -225,6 +228,11 @@ export default function NewDetectorPage() { {/* Footer */} + {createMutation.isError && ( +

+ {(createMutation.error as Error)?.message ?? "Failed to create detector"} +

+ )}
diff --git a/frontend/ui/src/app/projects/[projectId]/detectors/page.test.tsx b/frontend/ui/src/app/projects/[projectId]/detectors/page.test.tsx index edc2511f1..f7af60b36 100644 --- a/frontend/ui/src/app/projects/[projectId]/detectors/page.test.tsx +++ b/frontend/ui/src/app/projects/[projectId]/detectors/page.test.tsx @@ -1,9 +1,79 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from "vitest"; -import { render, cleanup, screen, fireEvent } from "@testing-library/react"; +import { render, cleanup, screen, fireEvent, within } from "@testing-library/react"; import { DETECTOR_SYSTEM_DEFAULT_MODEL_ID } from "@traceroot/core/llm-providers"; -const mocks = vi.hoisted(() => ({ push: vi.fn(), useDetectorList: vi.fn() })); +const mocks = vi.hoisted(() => { + const defaultDetectors = [ + { + id: "det-1", + name: "My Detector", + template: "failure", + detectionModel: null, + detectionProvider: null, + detectionSource: "system", + sampleRate: 25, + createTime: "2026-06-15T00:00:00.000Z", + updateTime: "2026-06-15T00:00:00.000Z", + }, + { + id: "det-2", + name: "Pinned Detector", + template: "failure", + detectionModel: "gpt-5.4", + detectionProvider: "OpenAI", + detectionSource: "system", + sampleRate: 100, + createTime: "2026-06-16T00:00:00.000Z", + updateTime: "2026-06-16T00:00:00.000Z", + }, + { + id: "det-3", + name: "BYOK Detector", + template: "failure", + detectionModel: null, + detectionProvider: "Anthropic BYOK", + detectionSource: "byok", + sampleRate: 100, + createTime: "2026-06-17T00:00:00.000Z", + updateTime: "2026-06-17T00:00:00.000Z", + }, + { + id: "det-4", + name: "Pinned BYOK Detector", + template: "failure", + detectionModel: "gpt-5.4", + detectionProvider: "OpenAI BYOK", + detectionSource: "byok", + sampleRate: 100, + createTime: "2026-06-18T00:00:00.000Z", + updateTime: "2026-06-18T00:00:00.000Z", + }, + { + id: "det-5", + name: "Legacy Detector", + template: "failure", + detectionModel: null, + detectionProvider: null, + detectionSource: null, + sampleRate: 100, + createTime: "2026-06-19T00:00:00.000Z", + updateTime: "2026-06-19T00:00:00.000Z", + }, + ]; + return { + push: vi.fn(), + workspaceData: undefined as { role: string } | undefined, + deleteIsError: false, + deleteError: null as Error | null, + defaultDetectors, + detectorListData: { data: defaultDetectors, meta: { total: 5 } } as { + data: unknown[]; + meta: { total: number }; + }, + deleteDialogProps: undefined as { detectorName: string; isOpen: boolean } | undefined, + }; +}); vi.mock("next/navigation", () => ({ useParams: () => ({ projectId: "proj-1" }), @@ -23,78 +93,27 @@ vi.mock("@/lib/hooks/use-list-page-state", () => ({ }), })); -const defaultDetectorList = { - data: { - data: [ - { - id: "det-1", - name: "My Detector", - template: "failure", - detectionModel: null, - detectionProvider: null, - detectionSource: "system", - sampleRate: 25, - createTime: "2026-06-15T00:00:00.000Z", - updateTime: "2026-06-15T00:00:00.000Z", - }, - { - id: "det-2", - name: "Pinned Detector", - template: "failure", - detectionModel: "gpt-5.4", - detectionProvider: "OpenAI", - detectionSource: "system", - sampleRate: 100, - createTime: "2026-06-16T00:00:00.000Z", - updateTime: "2026-06-16T00:00:00.000Z", - }, - { - id: "det-3", - name: "BYOK Detector", - template: "failure", - detectionModel: null, - detectionProvider: "Anthropic BYOK", - detectionSource: "byok", - sampleRate: 100, - createTime: "2026-06-17T00:00:00.000Z", - updateTime: "2026-06-17T00:00:00.000Z", - }, - { - id: "det-4", - name: "Pinned BYOK Detector", - template: "failure", - detectionModel: "gpt-5.4", - detectionProvider: "OpenAI BYOK", - detectionSource: "byok", - sampleRate: 100, - createTime: "2026-06-18T00:00:00.000Z", - updateTime: "2026-06-18T00:00:00.000Z", - }, - { - id: "det-5", - name: "Legacy Detector", - template: "failure", - detectionModel: null, - detectionProvider: null, - detectionSource: null, - sampleRate: 100, - createTime: "2026-06-19T00:00:00.000Z", - updateTime: "2026-06-19T00:00:00.000Z", - }, - ], - meta: { total: 5 }, - }, - isLoading: false, - error: null, -}; - vi.mock("@/features/detectors/hooks/use-detectors", () => ({ - useDetectorList: (...args: unknown[]) => mocks.useDetectorList(...args), + useDetectorList: () => ({ + data: mocks.detectorListData, + isLoading: false, + error: null, + }), useDetectorCounts: () => ({ data: {}, isLoading: false }), - useDeleteDetector: () => ({ mutate: vi.fn(), isPending: false }), + useDeleteDetector: () => ({ + mutate: vi.fn(), + isPending: false, + isError: mocks.deleteIsError, + error: mocks.deleteError, + }), })); -vi.mock("@/features/projects/hooks", () => ({ useProject: () => ({ data: undefined }) })); +vi.mock("@/features/projects/hooks", () => ({ + useProject: () => ({ data: { workspace_id: "ws-1" } }), +})); +vi.mock("@/features/workspaces/hooks", () => ({ + useWorkspace: () => ({ data: mocks.workspaceData }), +})); vi.mock("@/lib/hooks/use-retention", () => ({ useRetention: () => ({ retentionDays: 15, @@ -110,19 +129,23 @@ vi.mock("@/features/projects/components", () => ({ ProjectBreadcrumb: () => null vi.mock("@/components/search-filter-bar", () => ({ SearchFilterBar: () => null })); vi.mock("@/components/list-pagination", () => ({ ListPagination: () => null })); vi.mock("@/features/detectors/components/delete-detector-dialog", () => ({ - DeleteDetectorDialog: () => null, + DeleteDetectorDialog: (props: { detectorName: string; isOpen: boolean }) => { + mocks.deleteDialogProps = props; + return null; + }, })); vi.mock("@/features/detectors/components/detector-panel", () => ({ DetectorPanel: () => null })); import DetectorsPage from "./page"; -mocks.useDetectorList.mockReturnValue(defaultDetectorList); - afterEach(() => { cleanup(); mocks.push.mockClear(); - mocks.useDetectorList.mockReset(); - mocks.useDetectorList.mockReturnValue(defaultDetectorList); + mocks.workspaceData = undefined; + mocks.deleteIsError = false; + mocks.deleteError = null; + mocks.detectorListData = { data: mocks.defaultDetectors, meta: { total: 5 } }; + mocks.deleteDialogProps = undefined; }); describe("DetectorsPage", () => { @@ -180,6 +203,7 @@ describe("DetectorsPage", () => { }); it("carries the selected time range into the detector detail URL on row click", () => { + mocks.workspaceData = { role: "ADMIN" }; render(); fireEvent.click(screen.getByText("My Detector")); @@ -187,12 +211,83 @@ describe("DetectorsPage", () => { expect(mocks.push).toHaveBeenCalledWith("/projects/proj-1/detectors/det-1?date_filter=7d"); }); - it("shows the empty state with its glyph when the project has no detectors", () => { - mocks.useDetectorList.mockReturnValue({ - data: { data: [], meta: { total: 0 } }, - isLoading: false, - error: null, + it("hides the New Detector button for VIEWER role", () => { + mocks.workspaceData = { role: "VIEWER" }; + render(); + expect(screen.queryByRole("button", { name: "New Detector" })).toBeNull(); + }); + + it("shows error toast when delete mutation fails", () => { + mocks.workspaceData = { role: "ADMIN" }; + mocks.deleteIsError = true; + mocks.deleteError = new Error("Permission denied"); + render(); + expect(screen.getByText("Permission denied")).toBeDefined(); + }); + + it("shows Edit but hides Delete in the row actions menu for MEMBER role", () => { + mocks.workspaceData = { role: "MEMBER" }; + render(); + + const row = screen.getByText("My Detector").closest("tr"); + expect(row).not.toBeNull(); + fireEvent.click(within(row as HTMLElement).getByRole("button")); + + expect(screen.getByText("Edit")).toBeDefined(); + expect(screen.queryByText("Delete")).toBeNull(); + }); + + it("shows Delete in the row actions menu for ADMIN role", () => { + mocks.workspaceData = { role: "ADMIN" }; + render(); + + const row = screen.getByText("My Detector").closest("tr"); + expect(row).not.toBeNull(); + fireEvent.click(within(row as HTMLElement).getByRole("button")); + + expect(screen.getByText("Edit")).toBeDefined(); + expect(screen.getByText("Delete")).toBeDefined(); + }); + + it("opens the delete confirmation dialog for the clicked detector on Delete", () => { + mocks.workspaceData = { role: "ADMIN" }; + render(); + + const row = screen.getByText("My Detector").closest("tr"); + expect(row).not.toBeNull(); + fireEvent.click(within(row as HTMLElement).getByRole("button")); + fireEvent.click(screen.getByText("Delete")); + + expect(mocks.deleteDialogProps).toEqual( + expect.objectContaining({ detectorName: "My Detector", isOpen: true }), + ); + }); + + it("shows a New Detector button in the empty-project state for MEMBER+", () => { + mocks.workspaceData = { role: "MEMBER" }; + mocks.detectorListData = { data: [], meta: { total: 0 } }; + render(); + + const emptyState = screen.getByText("No detectors yet").closest("div"); + expect(emptyState).not.toBeNull(); + const emptyStateButton = within(emptyState as HTMLElement).getByRole("button", { + name: "New Detector", }); + fireEvent.click(emptyStateButton); + expect(mocks.push).toHaveBeenCalledWith("/projects/proj-1/detectors/new"); + }); + + it("hides the New Detector button in the empty-project state for VIEWER", () => { + mocks.workspaceData = { role: "VIEWER" }; + mocks.detectorListData = { data: [], meta: { total: 0 } }; + render(); + + expect(screen.getByText("No detectors yet")).toBeDefined(); + expect(screen.queryByRole("button", { name: "New Detector" })).toBeNull(); + }); + + it("shows the empty state with its glyph when the project has no detectors", () => { + mocks.detectorListData = { data: [], meta: { total: 0 } }; render(); diff --git a/frontend/ui/src/app/projects/[projectId]/detectors/page.tsx b/frontend/ui/src/app/projects/[projectId]/detectors/page.tsx index f9a51b96e..3ece4b6a7 100644 --- a/frontend/ui/src/app/projects/[projectId]/detectors/page.tsx +++ b/frontend/ui/src/app/projects/[projectId]/detectors/page.tsx @@ -20,6 +20,7 @@ import { import { useListPageState } from "@/lib/hooks/use-list-page-state"; import { DETECTORS_DEFAULT_DATE_FILTER_ID } from "@/lib/date-filter"; import { useProject } from "@/features/projects/hooks"; +import { useWorkspace } from "@/features/workspaces/hooks"; import { useRetention } from "@/lib/hooks/use-retention"; import { PricingDialog } from "@/ee/features/billing/PricingDialog"; import { PlanType } from "@traceroot/core"; @@ -83,6 +84,10 @@ export default function DetectorsPage() { customEndDate: state.customEndDate, }); + const { data: workspace } = useWorkspace(project?.workspace_id ?? ""); + const isAdmin = workspace?.role === "ADMIN"; + const isMember = workspace?.role === "MEMBER" || isAdmin; + const { data, isLoading, error } = useDetectorList(projectId, { page: queryOptions.page, limit: queryOptions.limit, @@ -134,13 +139,15 @@ export default function DetectorsPage() { {/* Page header */}

Detectors

- + {isMember && ( + + )}
{/* Search / time-range filter */} @@ -174,13 +181,15 @@ export default function DetectorsPage() {

Create a detector to automatically analyze your traces.

- + {isMember && ( + + )}
) : isEmptySearch ? (
@@ -307,17 +316,19 @@ export default function DetectorsPage() { Edit - + {isAdmin && ( + + )} @@ -355,6 +366,12 @@ export default function DetectorsPage() { /> )} + {deleteMutation.isError && ( +

+ {(deleteMutation.error as Error)?.message ?? "Failed to delete detector"} +

+ )} + {deleteTarget && ( ({ }>; } | undefined, + workspaceData: undefined as { role: string } | undefined, + sessionHistoryCanDelete: undefined as boolean | undefined, + sendError: null as string | null, onClose: vi.fn(), })); @@ -36,12 +39,17 @@ vi.mock("@/lib/api", () => ({ getAvailableLLMModels: vi.fn(), })); +vi.mock("@/features/workspaces/hooks", () => ({ + useWorkspace: () => ({ data: mocks.workspaceData }), +})); + vi.mock("./ai-chat-context", () => ({ useAiChatContext: () => ({ messages: [], isStreaming: false, + sendError: mocks.sendError, sessions: [], - historyOpen: false, + historyOpen: true, currentSessionId: null, setHistoryOpen: vi.fn(), handleSend: vi.fn(), @@ -56,7 +64,12 @@ vi.mock("./ai-chat-context", () => ({ vi.mock("./message-list", () => ({ MessageList: () => null })); vi.mock("./message-input", () => ({ MessageInput: () => null })); -vi.mock("./session-history", () => ({ SessionHistory: () => null })); +vi.mock("./session-history", () => ({ + SessionHistory: (props: { canDelete?: boolean }) => { + mocks.sessionHistoryCanDelete = props.canDelete; + return null; + }, +})); import { AiAssistantPanel } from "./ai-assistant-panel"; @@ -64,6 +77,9 @@ afterEach(() => { cleanup(); mocks.projectData = undefined; mocks.llmModels = undefined; + mocks.workspaceData = undefined; + mocks.sessionHistoryCanDelete = undefined; + mocks.sendError = null; mocks.onClose.mockReset(); }); @@ -78,3 +94,78 @@ describe("AiAssistantPanel", () => { expect(link.getAttribute("href")).toBe("/workspaces/ws-abc/settings/model-providers"); }); }); + +describe("AiAssistantPanel — canDelete passed to SessionHistory", () => { + it("passes canDelete=true when current user is ADMIN", () => { + mocks.projectData = { workspace_id: "ws-1" }; + mocks.llmModels = { + systemModels: [ + { + provider: "anthropic", + adapter: "anthropic", + source: "system", + models: [{ id: "claude-4", label: "Claude 4" }], + }, + ], + byokProviders: [], + }; + mocks.workspaceData = { role: "ADMIN" }; + + render(); + + expect(mocks.sessionHistoryCanDelete).toBe(true); + }); + + it("passes canDelete=false when current user is MEMBER", () => { + mocks.projectData = { workspace_id: "ws-1" }; + mocks.llmModels = { + systemModels: [ + { + provider: "anthropic", + adapter: "anthropic", + source: "system", + models: [{ id: "claude-4", label: "Claude 4" }], + }, + ], + byokProviders: [], + }; + mocks.workspaceData = { role: "MEMBER" }; + + render(); + + expect(mocks.sessionHistoryCanDelete).toBe(false); + }); +}); + +describe("AiAssistantPanel — send/session-creation error", () => { + it("surfaces sendError near the input instead of failing silently", () => { + mocks.projectData = { workspace_id: "ws-1" }; + mocks.llmModels = { + systemModels: [ + { + provider: "anthropic", + adapter: "anthropic", + source: "system", + models: [{ id: "claude-4", label: "Claude 4" }], + }, + ], + byokProviders: [], + }; + mocks.workspaceData = { role: "MEMBER" }; + mocks.sendError = "Requires MEMBER role or higher"; + + render(); + + expect(screen.getByText("Requires MEMBER role or higher")).toBeTruthy(); + }); + + it("renders nothing when there is no sendError", () => { + mocks.projectData = { workspace_id: "ws-1" }; + mocks.workspaceData = { role: "ADMIN" }; + mocks.sendError = null; + + render(); + + expect(screen.queryByText(/requires|failed/i)).toBeNull(); + }); +}); diff --git a/frontend/ui/src/features/ai-assistant/components/ai-assistant-panel.tsx b/frontend/ui/src/features/ai-assistant/components/ai-assistant-panel.tsx index 0978286be..e65aa9f48 100644 --- a/frontend/ui/src/features/ai-assistant/components/ai-assistant-panel.tsx +++ b/frontend/ui/src/features/ai-assistant/components/ai-assistant-panel.tsx @@ -9,6 +9,7 @@ import { MessageInput } from "./message-input"; import { SessionHistory } from "./session-history"; import { useAiChatContext } from "./ai-chat-context"; import { getProject, getAvailableLLMModels } from "@/lib/api"; +import { useWorkspace } from "@/features/workspaces/hooks"; interface AiAssistantPanelProps { projectId?: string; @@ -47,6 +48,10 @@ export function AiAssistantPanel({ projectId, onClose, compact = false }: AiAssi // workspaceId from project (only available on project pages) const workspaceId = project?.workspace_id; + const { data: workspace } = useWorkspace(workspaceId ?? ""); + const isAdmin = workspace?.role === "ADMIN"; + const isMember = workspace?.role === "MEMBER" || isAdmin; + // Check if any models are available (system or BYOK) const { data: llmModels } = useQuery({ queryKey: ["llm-models", workspaceId], @@ -67,6 +72,7 @@ export function AiAssistantPanel({ projectId, onClose, compact = false }: AiAssi const { messages, isStreaming, + sendError, sessions, historyOpen, currentSessionId, @@ -127,6 +133,7 @@ export function AiAssistantPanel({ projectId, onClose, compact = false }: AiAssi projectId={projectId ?? ""} onSelect={handleSelectSession} onDelete={handleDeleteSession} + canDelete={isAdmin} /> @@ -183,10 +190,13 @@ export function AiAssistantPanel({ projectId, onClose, compact = false }: AiAssi )} + {/* Send/session-creation error */} + {sendError &&

{sendError}

} + {/* Input */} ({ cn: (...args: string[]) => args.filter(Boolean).join(" ") })); + +import { SessionHistory } from "./session-history"; +import type { AISession } from "../types"; + +const session: AISession = { + id: "sess-1", + title: "Test session", + createTime: new Date().toISOString(), + projectId: "proj-1", + status: "active", +}; + +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("SessionHistory — canDelete prop", () => { + it("shows the delete icon when canDelete is true", () => { + render( + , + ); + expect(document.querySelector("svg + svg, span + svg")).toBeTruthy(); + }); + + it("hides the delete icon when canDelete is false", () => { + const { container } = render( + , + ); + // Only one svg renders (MessageSquare); Trash2 is absent + const svgs = container.querySelectorAll("button svg"); + expect(svgs).toHaveLength(1); + }); +}); + +describe("SessionHistory — delete error handling", () => { + it("shows error message when DELETE returns 403", async () => { + vi.spyOn(globalThis, "fetch").mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Requires ADMIN role or higher" }), + } as Response); + + const { container } = render( + , + ); + + const trashIcon = container.querySelector("button svg:last-child") as SVGElement; + fireEvent.click(trashIcon, { bubbles: true }); + + await waitFor(() => { + expect(screen.getByText("Requires ADMIN role or higher")).toBeTruthy(); + }); + }); + + it("shows generic error message when DELETE throws", async () => { + vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("Network error")); + + const { container } = render( + , + ); + + const trashIcon = container.querySelector("button svg:last-child") as SVGElement; + fireEvent.click(trashIcon, { bubbles: true }); + + await waitFor(() => { + expect(screen.getByText("Failed to delete session")).toBeTruthy(); + }); + }); +}); diff --git a/frontend/ui/src/features/ai-assistant/components/session-history.tsx b/frontend/ui/src/features/ai-assistant/components/session-history.tsx index cdde1fa41..5761e6b64 100644 --- a/frontend/ui/src/features/ai-assistant/components/session-history.tsx +++ b/frontend/ui/src/features/ai-assistant/components/session-history.tsx @@ -11,6 +11,7 @@ interface SessionHistoryProps { projectId: string; onSelect: (session: AISession) => void; onDelete: (sessionId: string) => void; + canDelete?: boolean; } function getTimeGroup(dateStr: string): string { @@ -35,21 +36,28 @@ export function SessionHistory({ projectId, onSelect, onDelete, + canDelete = false, }: SessionHistoryProps) { const [deletingId, setDeletingId] = useState(null); + const [deleteError, setDeleteError] = useState(null); const handleDelete = async (e: React.MouseEvent, sessionId: string) => { e.stopPropagation(); setDeletingId(sessionId); + setDeleteError(null); try { const res = await fetch(`/api/projects/${projectId}/ai/sessions/${sessionId}`, { method: "DELETE", }); if (res.ok) { onDelete(sessionId); + } else { + const body = await res.json().catch(() => ({})); + setDeleteError(body?.error ?? "Failed to delete session"); } } catch (err) { console.error(err); + setDeleteError("Failed to delete session"); } finally { setDeletingId(null); } @@ -78,6 +86,7 @@ export function SessionHistory({ return (
+ {deleteError &&

{deleteError}

} {groups.map((group) => (
@@ -97,10 +106,12 @@ export function SessionHistory({ {s.title || "Untitled session"} - handleDelete(e, s.id)} - /> + {canDelete && ( + handleDelete(e, s.id)} + /> + )} ))}
diff --git a/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.test.tsx b/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.test.tsx new file mode 100644 index 000000000..7b8f6fd5e --- /dev/null +++ b/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.test.tsx @@ -0,0 +1,135 @@ +// @vitest-environment jsdom +import { afterEach, describe, expect, it, vi } from "vitest"; +import { renderHook, waitFor, act } from "@testing-library/react"; + +const sendMessage = vi.fn(); + +vi.mock("./use-ai-stream", () => ({ + useAIStream: () => ({ + messages: [], + isStreaming: false, + sendMessage, + abort: vi.fn(), + setMessages: vi.fn(), + }), +})); + +import { useAiChat } from "./use-ai-chat"; + +afterEach(() => { + vi.unstubAllGlobals(); + sendMessage.mockReset(); +}); + +describe("useAiChat — session-creation error surfacing", () => { + it("sets sendError from the response body when session creation is forbidden", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Requires MEMBER role or higher" }), + }), + ); + + const { result } = renderHook(() => useAiChat({ projectId: "proj-1" })); + + await act(async () => { + await result.current.handleSend("hello", { + model: "claude-4", + provider: "anthropic", + source: "system", + adapter: "anthropic", + }); + }); + + await waitFor(() => expect(result.current.sendError).toBe("Requires MEMBER role or higher")); + expect(sendMessage).not.toHaveBeenCalled(); + }); + + it("falls back to a generic message when the failed response has no error body", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 500, + json: async () => { + throw new Error("not json"); + }, + }), + ); + + const { result } = renderHook(() => useAiChat({ projectId: "proj-1" })); + + await act(async () => { + await result.current.handleSend("hello", { + model: "claude-4", + provider: "anthropic", + source: "system", + adapter: "anthropic", + }); + }); + + await waitFor(() => expect(result.current.sendError).toBe("Failed to create session: 500")); + }); + + it("clears a prior sendError on the next send attempt once it succeeds", async () => { + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ + ok: false, + status: 403, + json: async () => ({ error: "Requires MEMBER role or higher" }), + }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: "sess-1" }) }); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAiChat({ projectId: "proj-1" })); + const modelSelection = { + model: "claude-4", + provider: "anthropic", + source: "system" as const, + adapter: "anthropic", + }; + + await act(async () => { + await result.current.handleSend("first", modelSelection); + }); + await waitFor(() => expect(result.current.sendError).toBe("Requires MEMBER role or higher")); + + await act(async () => { + await result.current.handleSend("second", modelSelection); + }); + await waitFor(() => expect(sendMessage).toHaveBeenCalled()); + expect(result.current.sendError).toBeNull(); + }); + + it("clears sendError when the user starts a new session", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ + ok: false, + status: 403, + json: async () => ({ error: "Requires MEMBER role or higher" }), + }), + ); + + const { result } = renderHook(() => useAiChat({ projectId: "proj-1" })); + + await act(async () => { + await result.current.handleSend("hello", { + model: "claude-4", + provider: "anthropic", + source: "system", + adapter: "anthropic", + }); + }); + await waitFor(() => expect(result.current.sendError).toBe("Requires MEMBER role or higher")); + + act(() => { + result.current.handleNewSession(); + }); + + expect(result.current.sendError).toBeNull(); + }); +}); diff --git a/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.ts b/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.ts index 943ac0acc..96161b8f1 100644 --- a/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.ts +++ b/frontend/ui/src/features/ai-assistant/hooks/use-ai-chat.ts @@ -27,6 +27,10 @@ export function useAiChat({ // (session creation + first network round-trip). Without this, React 19 can batch // setIsStreaming(true) and setIsStreaming(false) into a single frame, hiding the button. const [isSending, setIsSending] = useState(false); + // Surfaces a failed session creation (e.g. a non-MEMBER's 403) instead of the + // previous silent console.error — the send button would otherwise no-op with + // no visible feedback. Cleared on the next send attempt or a fresh session. + const [sendError, setSendError] = useState(null); // Reset session + messages when the user navigates to a different project so // a session ID from project A can never be replayed against project B's chat @@ -91,11 +95,17 @@ export function useAiChat({ body: JSON.stringify({ traceId, traceSessionId }), signal: ac.signal, }); - if (!res.ok) throw new Error(`Failed to create session: ${res.status}`); + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error(body?.error ?? `Failed to create session: ${res.status}`); + } const data = await res.json(); return data.id; } catch (err) { - if ((err as Error).name !== "AbortError") console.error(err); + if ((err as Error).name !== "AbortError") { + console.error(err); + setSendError((err as Error).message); + } return null; } finally { ensureSessionAbortersRef.current.delete(ac); @@ -106,6 +116,7 @@ export function useAiChat({ async (message: string, modelSelection: ModelSelection) => { if (!projectId) return; setIsSending(true); + setSendError(null); try { const sessionId = await ensureSession(); if (!sessionId) return; @@ -134,6 +145,7 @@ export function useAiChat({ // follow-up (see the linked discussion / issue on #784). sessionIdRef.current = null; setMessages([]); + setSendError(null); }, [setMessages]); // Closing the panel ends the conversation: any in-flight run is aborted, @@ -202,6 +214,7 @@ export function useAiChat({ // State messages, isStreaming: isSending || isStreaming || messages.some((m) => m.isStreaming), + sendError, sessions, historyOpen, currentSessionId: sessionIdRef.current, diff --git a/frontend/ui/src/features/detectors/components/detector-panel.test.tsx b/frontend/ui/src/features/detectors/components/detector-panel.test.tsx index 95d50db7e..15d7f9a2c 100644 --- a/frontend/ui/src/features/detectors/components/detector-panel.test.tsx +++ b/frontend/ui/src/features/detectors/components/detector-panel.test.tsx @@ -6,15 +6,26 @@ import type { Detector } from "../hooks/use-detectors"; const mocks = vi.hoisted(() => ({ detector: undefined as Detector | undefined, mutate: vi.fn(), + workspaceData: undefined as { role: string } | undefined, + mutateIsError: false, + mutateError: null as Error | null, })); vi.mock("../hooks/use-detectors", () => ({ useDetector: () => ({ data: mocks.detector }), - useUpdateDetector: () => ({ mutate: mocks.mutate, isPending: false }), + useUpdateDetector: () => ({ + mutate: mocks.mutate, + isPending: false, + isError: mocks.mutateIsError, + error: mocks.mutateError, + }), })); vi.mock("@/features/projects/hooks", () => ({ useProject: () => ({ data: undefined }), })); +vi.mock("@/features/workspaces/hooks", () => ({ + useWorkspace: () => ({ data: mocks.workspaceData }), +})); vi.mock("./trigger-editor", () => ({ TriggerEditor: () => null, })); @@ -80,6 +91,9 @@ const saveButton = () => screen.getByRole("button", { name: "Save" }); afterEach(() => { cleanup(); mocks.detector = undefined; + mocks.workspaceData = undefined; + mocks.mutateIsError = false; + mocks.mutateError = null; mocks.mutate.mockReset(); }); @@ -106,6 +120,7 @@ describe("DetectorPanel", () => { it("saves only the fields the user changed", () => { mocks.detector = baseDetector; + mocks.workspaceData = { role: "MEMBER" }; const { onClose } = renderPanel(); fireEvent.change(promptBox(), { target: { value: "new prompt" } }); fireEvent.click(saveButton()); @@ -120,6 +135,7 @@ describe("DetectorPanel", () => { it("closes without a network call when nothing changed", () => { mocks.detector = baseDetector; + mocks.workspaceData = { role: "MEMBER" }; const { onClose } = renderPanel(); fireEvent.click(saveButton()); expect(mocks.mutate).not.toHaveBeenCalled(); @@ -132,4 +148,20 @@ describe("DetectorPanel", () => { expect(promptBox().value).toBe(""); expect((saveButton() as HTMLButtonElement).disabled).toBe(true); }); + + it("disables Save for VIEWER role", () => { + mocks.detector = baseDetector; + mocks.workspaceData = { role: "VIEWER" }; + renderPanel(); + expect((saveButton() as HTMLButtonElement).disabled).toBe(true); + }); + + it("shows error message when Save fails", () => { + mocks.detector = baseDetector; + mocks.workspaceData = { role: "MEMBER" }; + mocks.mutateIsError = true; + mocks.mutateError = new Error("Server error"); + renderPanel(); + expect(screen.getByText("Server error")).toBeDefined(); + }); }); diff --git a/frontend/ui/src/features/detectors/components/detector-panel.tsx b/frontend/ui/src/features/detectors/components/detector-panel.tsx index dc361a73b..03b350f27 100644 --- a/frontend/ui/src/features/detectors/components/detector-panel.tsx +++ b/frontend/ui/src/features/detectors/components/detector-panel.tsx @@ -6,6 +6,7 @@ import { DOMAIN_ICONS } from "@/components/icons/domain-icons"; import { useDetector, useUpdateDetector } from "../hooks/use-detectors"; import { DEFAULT_DETECTOR_SAMPLE_RATE } from "../templates"; import { useProject } from "@/features/projects/hooks"; +import { useWorkspace } from "@/features/workspaces/hooks"; import { TriggerEditor } from "./trigger-editor"; import type { TriggerCondition } from "./trigger-editor"; import { AgentModelLink } from "./agent-model-link"; @@ -44,6 +45,8 @@ export function DetectorPanel({ }: DetectorPanelProps) { const { data: detector } = useDetector(projectId, detectorId); const { data: project } = useProject(projectId); + const { data: workspace } = useWorkspace(workspaceId ?? ""); + const isMember = workspace?.role === "MEMBER" || workspace?.role === "ADMIN"; const [editName, setEditName] = useState(""); const [editPrompt, setEditPrompt] = useState(""); @@ -304,6 +307,11 @@ export function DetectorPanel({
{/* Save / Cancel */} + {updateMutation.isError && ( +

+ {(updateMutation.error as Error)?.message ?? "Failed to save"} +

+ )}