From 681f89000f47c70a7346ac86ac5a966695b43309 Mon Sep 17 00:00:00 2001 From: Oleg Date: Wed, 5 Aug 2026 18:22:46 +0000 Subject: [PATCH] Make internal notes admin-only, close a direct-URL ticket access gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Internal notes ("Внутренняя заметка") were visible to any agent who opened the ticket — the compose toggle and the SSE-delivered live updates had no role check. Agents now never see the internal/public toggle (they only ever reply publicly) and internal messages are filtered out of both the initial server-rendered load and live SSE message.created events. Fixing that surfaced a bigger gap: the ticket detail page and every per-ticket API route (GET/PATCH /api/tickets/[id], POST .../messages, POST .../attachments, PUT .../tags) had no ownership check at all — an agent could open, reply to, tag, reassign, or read the full message history of *any* ticket by URL/API, not just their own, regardless of the dashboard-level filtering added earlier. All five now reuse isTicketVisibleTo() to 404/redirect for tickets an agent doesn't own. Verified live: an agent opening a ticket assigned to them sees public messages but not an admin's internal note or the note-vs-reply toggle; opening a ticket assigned to someone else redirects to /dashboard on the page and returns 404 from the API. Test accounts/data removed after. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY --- src/app/(admin)/tickets/[id]/page.tsx | 18 ++++++- .../(admin)/tickets/[id]/ticket-thread.tsx | 50 +++++++++++-------- src/app/api/tickets/[id]/attachments/route.ts | 12 ++++- src/app/api/tickets/[id]/messages/route.ts | 16 +++++- src/app/api/tickets/[id]/route.ts | 18 ++++++- src/app/api/tickets/[id]/tags/route.ts | 10 +++- 6 files changed, 97 insertions(+), 27 deletions(-) diff --git a/src/app/(admin)/tickets/[id]/page.tsx b/src/app/(admin)/tickets/[id]/page.tsx index aa27814..bb88194 100644 --- a/src/app/(admin)/tickets/[id]/page.tsx +++ b/src/app/(admin)/tickets/[id]/page.tsx @@ -1,19 +1,33 @@ -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; +import { getCurrentSession } from "@/lib/auth/session"; import { getTicketWithMessages, listAgents } from "@/lib/tickets/service"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; import { TicketThread } from "./ticket-thread"; export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) { + const session = await getCurrentSession(); + if (!session) redirect("/login"); + + const currentUser = { id: session.user.id, role: session.user.role }; + const { id } = await params; const result = await getTicketWithMessages(id); if (!result) notFound(); + if (!isTicketVisibleTo(result.ticket, currentUser)) { + redirect("/dashboard"); + } const agents = await listAgents(); + const visibleMessages = currentUser.role === "admin" + ? result.messages + : result.messages.filter((m) => m.visibility !== "internal"); return ( ({ id: a.id, name: a.name }))} + currentUser={currentUser} /> ); } diff --git a/src/app/(admin)/tickets/[id]/ticket-thread.tsx b/src/app/(admin)/tickets/[id]/ticket-thread.tsx index 4724ece..6cea06d 100644 --- a/src/app/(admin)/tickets/[id]/ticket-thread.tsx +++ b/src/app/(admin)/tickets/[id]/ticket-thread.tsx @@ -22,11 +22,14 @@ export function TicketThread({ ticket: initialTicket, initialMessages, agents, + currentUser, }: { ticket: TicketDTO; initialMessages: MessageDTO[]; agents: { id: string; name: string }[]; + currentUser: { id: string; role: "admin" | "agent" }; }) { + const isAdmin = currentUser.role === "admin"; const [ticket, setTicket] = useState(initialTicket); const [messages, setMessages] = useState(initialMessages); const [draft, setDraft] = useState(""); @@ -63,6 +66,10 @@ export function TicketThread({ setTicket(event.ticket); } if (event.type === "message.created" && event.ticketId === ticket.id) { + // Internal notes are admin-only — an agent's SSE connection still + // receives every event on the bus, so this has to be filtered here + // too, not just at the initial server-rendered load. + if (event.message.visibility === "internal" && !isAdmin) return; setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message])); } }); @@ -179,26 +186,29 @@ export function TicketThread({
-
- - -
+ {/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */} + {isAdmin && ( +
+ + +
+ )} {pendingFile && (
diff --git a/src/app/api/tickets/[id]/attachments/route.ts b/src/app/api/tickets/[id]/attachments/route.ts index 798c88f..1636114 100644 --- a/src/app/api/tickets/[id]/attachments/route.ts +++ b/src/app/api/tickets/[id]/attachments/route.ts @@ -3,13 +3,22 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { requireSession } from "@/lib/auth/require"; import { deliverAgentMessage } from "@/lib/tickets/delivery"; +import { getTicketWithMessages } from "@/lib/tickets/service"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage"; export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { const { session, response } = await requireSession(); if (!session) return response; + const currentUser = { id: session.user.id, role: session.user.role }; const { id } = await params; + + const existing = await getTicketWithMessages(id); + if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) { + return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } + const formData = await request.formData().catch(() => null); const file = formData?.get("file"); if (!(file instanceof File)) { @@ -21,7 +30,8 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const captionRaw = formData?.get("caption"); const caption = typeof captionRaw === "string" ? captionRaw.trim() : ""; - const visibility = formData?.get("visibility") === "internal" ? "internal" : "public"; + // Internal notes are admin-only — see messages/route.ts for the same rule. + const visibility = currentUser.role === "admin" && formData?.get("visibility") === "internal" ? "internal" : "public"; const buffer = Buffer.from(await file.arrayBuffer()); let saved; diff --git a/src/app/api/tickets/[id]/messages/route.ts b/src/app/api/tickets/[id]/messages/route.ts index 466c686..cd612ca 100644 --- a/src/app/api/tickets/[id]/messages/route.ts +++ b/src/app/api/tickets/[id]/messages/route.ts @@ -4,6 +4,8 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/auth/require"; import { deliverAgentMessage } from "@/lib/tickets/delivery"; +import { getTicketWithMessages } from "@/lib/tickets/service"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; const messageSchema = z.object({ body: z.string().min(1).max(10_000), @@ -14,20 +16,32 @@ export async function POST(request: Request, { params }: { params: Promise<{ id: const { session, response } = await requireSession(); if (!session) return response; + const currentUser = { id: session.user.id, role: session.user.role }; const { id } = await params; + + const existing = await getTicketWithMessages(id); + if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) { + return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } + const body = await request.json().catch(() => null); const parsed = messageSchema.safeParse(body); if (!parsed.success) { return NextResponse.json({ error: "Invalid input" }, { status: 400 }); } + // Internal notes are admin-only — the client already hides the toggle for + // agents, but a raw API call could still set it, so it's forced back to + // public here too. + const visibility = currentUser.role === "admin" ? parsed.data.visibility : "public"; + try { const result = await deliverAgentMessage({ ticketId: id, agentId: session.user.id, agentName: session.user.name, body: parsed.data.body, - visibility: parsed.data.visibility, + visibility, }); return NextResponse.json(result, { status: 201 }); } catch { diff --git a/src/app/api/tickets/[id]/route.ts b/src/app/api/tickets/[id]/route.ts index 7cc10ba..b31c3d3 100644 --- a/src/app/api/tickets/[id]/route.ts +++ b/src/app/api/tickets/[id]/route.ts @@ -4,17 +4,24 @@ import { NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/auth/require"; import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) { const { session, response } = await requireSession(); if (!session) return response; + const currentUser = { id: session.user.id, role: session.user.role }; const { id } = await params; const result = await getTicketWithMessages(id); - if (!result) { + if (!result || !isTicketVisibleTo(result.ticket, currentUser)) { return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); } - return NextResponse.json(result); + + const messages = currentUser.role === "admin" + ? result.messages + : result.messages.filter((m) => m.visibility !== "internal"); + + return NextResponse.json({ ticket: result.ticket, messages }); } const patchSchema = z.object({ @@ -26,7 +33,14 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id const { session, response } = await requireSession(); if (!session) return response; + const currentUser = { id: session.user.id, role: session.user.role }; const { id } = await params; + + const existing = await getTicketWithMessages(id); + if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) { + return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } + const body = await request.json().catch(() => null); const parsed = patchSchema.safeParse(body); if (!parsed.success) { diff --git a/src/app/api/tickets/[id]/tags/route.ts b/src/app/api/tickets/[id]/tags/route.ts index 16157e5..6fc8607 100644 --- a/src/app/api/tickets/[id]/tags/route.ts +++ b/src/app/api/tickets/[id]/tags/route.ts @@ -3,7 +3,8 @@ export const runtime = "nodejs"; import { NextResponse } from "next/server"; import { z } from "zod"; import { requireSession } from "@/lib/auth/require"; -import { setTicketTags } from "@/lib/tickets/service"; +import { getTicketWithMessages, setTicketTags } from "@/lib/tickets/service"; +import { isTicketVisibleTo } from "@/lib/tickets/visibility"; const schema = z.object({ tagIds: z.array(z.string()) }); @@ -11,7 +12,14 @@ export async function PUT(request: Request, { params }: { params: Promise<{ id: const { session, response } = await requireSession(); if (!session) return response; + const currentUser = { id: session.user.id, role: session.user.role }; const { id } = await params; + + const existing = await getTicketWithMessages(id); + if (!existing || !isTicketVisibleTo(existing.ticket, currentUser)) { + return NextResponse.json({ error: "Ticket not found" }, { status: 404 }); + } + const body = await request.json().catch(() => null); const parsed = schema.safeParse(body); if (!parsed.success) {