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
+97
-27
No files matched your search
@@ -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({
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<div className="mb-1.5 flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
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"
|
||||
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>
|
||||
{/* 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"
|
||||
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"
|
||||
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>
|
||||
)}
|
||||
{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} />
|
||||
|
||||
Reference in new issue
Block a user