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
+97 -27

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 { getTicketWithMessages, listAgents } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
import { TicketThread } from "./ticket-thread"; import { TicketThread } from "./ticket-thread";
export default async function TicketPage({ params }: { params: Promise<{ id: string }> }) { 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 { id } = await params;
const result = await getTicketWithMessages(id); const result = await getTicketWithMessages(id);
if (!result) notFound(); if (!result) notFound();
if (!isTicketVisibleTo(result.ticket, currentUser)) {
redirect("/dashboard");
}
const agents = await listAgents(); const agents = await listAgents();
const visibleMessages = currentUser.role === "admin"
? result.messages
: result.messages.filter((m) => m.visibility !== "internal");
return ( return (
<TicketThread <TicketThread
ticket={result.ticket} ticket={result.ticket}
initialMessages={result.messages} initialMessages={visibleMessages}
agents={agents.map((a) => ({ id: a.id, name: a.name }))} agents={agents.map((a) => ({ id: a.id, name: a.name }))}
currentUser={currentUser}
/> />
); );
} }
+30 -20
View File
@@ -22,11 +22,14 @@ export function TicketThread({
ticket: initialTicket, ticket: initialTicket,
initialMessages, initialMessages,
agents, agents,
currentUser,
}: { }: {
ticket: TicketDTO; ticket: TicketDTO;
initialMessages: MessageDTO[]; initialMessages: MessageDTO[];
agents: { id: string; name: string }[]; agents: { id: string; name: string }[];
currentUser: { id: string; role: "admin" | "agent" };
}) { }) {
const isAdmin = currentUser.role === "admin";
const [ticket, setTicket] = useState(initialTicket); const [ticket, setTicket] = useState(initialTicket);
const [messages, setMessages] = useState(initialMessages); const [messages, setMessages] = useState(initialMessages);
const [draft, setDraft] = useState(""); const [draft, setDraft] = useState("");
@@ -63,6 +66,10 @@ export function TicketThread({
setTicket(event.ticket); setTicket(event.ticket);
} }
if (event.type === "message.created" && event.ticketId === ticket.id) { 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])); setMessages((prev) => (prev.some((m) => m.id === event.message.id) ? prev : [...prev, event.message]));
} }
}); });
@@ -179,26 +186,29 @@ export function TicketThread({
</div> </div>
<div className="mt-3"> <div className="mt-3">
<div className="mb-1.5 flex gap-1"> {/* Internal notes are admin-only — agents only ever reply to the client, so there's nothing to toggle. */}
<button {isAdmin && (
type="button" <div className="mb-1.5 flex gap-1">
onClick={() => setVisibility("public")} <button
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${ type="button"
visibility === "public" ? "bg-accent-soft text-accent-soft-text" : "text-text-faint hover:text-text-muted" onClick={() => setVisibility("public")}
}`} className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
> visibility === "public" ? "bg-accent-soft text-accent-soft-text" : "text-text-faint hover:text-text-muted"
Ответ клиенту }`}
</button> >
<button Ответ клиенту
type="button" </button>
onClick={() => setVisibility("internal")} <button
className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${ type="button"
visibility === "internal" ? "bg-warning-soft text-warning-soft-text" : "text-text-faint hover:text-text-muted" onClick={() => setVisibility("internal")}
}`} className={`rounded-full px-2.5 py-1 text-xs font-medium transition-colors ${
> visibility === "internal" ? "bg-warning-soft text-warning-soft-text" : "text-text-faint hover:text-text-muted"
Заметка для команды }`}
</button> >
</div> Заметка для команды
</button>
</div>
)}
{pendingFile && ( {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"> <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} /> <Paperclip size={12} />
+11 -1
View File
@@ -3,13 +3,22 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { requireSession } from "@/lib/auth/require"; import { requireSession } from "@/lib/auth/require";
import { deliverAgentMessage } from "@/lib/tickets/delivery"; 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"; import { saveAttachment, AttachmentTooLargeError, MAX_ATTACHMENT_BYTES } from "@/lib/attachments/storage";
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) { export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (!session) return response; if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params; 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 formData = await request.formData().catch(() => null);
const file = formData?.get("file"); const file = formData?.get("file");
if (!(file instanceof 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 captionRaw = formData?.get("caption");
const caption = typeof captionRaw === "string" ? captionRaw.trim() : ""; 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()); const buffer = Buffer.from(await file.arrayBuffer());
let saved; let saved;
+15 -1
View File
@@ -4,6 +4,8 @@ import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireSession } from "@/lib/auth/require"; import { requireSession } from "@/lib/auth/require";
import { deliverAgentMessage } from "@/lib/tickets/delivery"; import { deliverAgentMessage } from "@/lib/tickets/delivery";
import { getTicketWithMessages } from "@/lib/tickets/service";
import { isTicketVisibleTo } from "@/lib/tickets/visibility";
const messageSchema = z.object({ const messageSchema = z.object({
body: z.string().min(1).max(10_000), 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(); const { session, response } = await requireSession();
if (!session) return response; if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params; 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 body = await request.json().catch(() => null);
const parsed = messageSchema.safeParse(body); const parsed = messageSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
return NextResponse.json({ error: "Invalid input" }, { status: 400 }); 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 { try {
const result = await deliverAgentMessage({ const result = await deliverAgentMessage({
ticketId: id, ticketId: id,
agentId: session.user.id, agentId: session.user.id,
agentName: session.user.name, agentName: session.user.name,
body: parsed.data.body, body: parsed.data.body,
visibility: parsed.data.visibility, visibility,
}); });
return NextResponse.json(result, { status: 201 }); return NextResponse.json(result, { status: 201 });
} catch { } catch {
+16 -2
View File
@@ -4,17 +4,24 @@ import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireSession } from "@/lib/auth/require"; import { requireSession } from "@/lib/auth/require";
import { getTicketWithMessages, setTicketStatus, assignTicket } from "@/lib/tickets/service"; 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 }> }) { export async function GET(_request: Request, { params }: { params: Promise<{ id: string }> }) {
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (!session) return response; if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params; const { id } = await params;
const result = await getTicketWithMessages(id); 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({ 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({ const patchSchema = z.object({
@@ -26,7 +33,14 @@ export async function PATCH(request: Request, { params }: { params: Promise<{ id
const { session, response } = await requireSession(); const { session, response } = await requireSession();
if (!session) return response; if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params; 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 body = await request.json().catch(() => null);
const parsed = patchSchema.safeParse(body); const parsed = patchSchema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {
+9 -1
View File
@@ -3,7 +3,8 @@ export const runtime = "nodejs";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { requireSession } from "@/lib/auth/require"; 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()) }); 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(); const { session, response } = await requireSession();
if (!session) return response; if (!session) return response;
const currentUser = { id: session.user.id, role: session.user.role };
const { id } = await params; 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 body = await request.json().catch(() => null);
const parsed = schema.safeParse(body); const parsed = schema.safeParse(body);
if (!parsed.success) { if (!parsed.success) {