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 { 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}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -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,6 +186,8 @@ export function TicketThread({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3">
|
<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">
|
<div className="mb-1.5 flex gap-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -199,6 +208,7 @@ export function TicketThread({
|
|||||||
Заметка для команды
|
Заметка для команды
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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} />
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in new issue
Block a user