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:
1 parent
ed636bdbd5
commit
681f89000f
6 files changed
+77
-7
No files matched your search
@@ -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} />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user