Make internal notes admin-only, close a direct-URL ticket access gap

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012o9j9RezxbZVKQMrB7oRLY
This commit is contained in:
ogrechkoandClaude Sonnet 5 committed 2026-08-05 18:22:46 +00:00
1 parent ed636bdbd5
commit 681f89000f
6 files changed
+77 -7

No files matched your search

+16 -2
View File
@@ -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 (
<TicketThread
ticket={result.ticket}
initialMessages={result.messages}
initialMessages={visibleMessages}
agents={agents.map((a) => ({ id: a.id, name: a.name }))}
currentUser={currentUser}
/>
);
}
@@ -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,6 +186,8 @@ export function TicketThread({
</div>
<div className="mt-3">
{/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */}
{isAdmin && (
<div className="mb-1.5 flex gap-1">
<button
type="button"
@@ -199,6 +208,7 @@ export function TicketThread({
Заметка для команды
</button>
</div>
)}
{pendingFile && (
<div className="mb-1.5 flex items-center gap-2 rounded-md border border-border bg-surface-hover px-2.5 py-1.5 text-xs">
<Paperclip size={12} />
+11 -1
View File
@@ -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;
+15 -1
View File
@@ -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 {
+16 -2
View File
@@ -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) {
+9 -1
View File
@@ -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) {