Fix comment avatar, activity log labels, and ticket update permissions
- add_comment.php: include user_id in response for avatar rendering - ticket.js: add buildCommentElement() helper that matches server-rendered comment structure (avatar, edit/delete buttons, textarea); use it in addComment() and submitReply() so new comments show the avatar immediately - AuditLogModel: logCommentCreate uses action_type='comment' not 'create' - TicketView: formatAction handles entity_type='comment' with action_type='create' for existing DB records; prevents "created this ticket" showing for comments - update_ticket.php: remove owner/assignee restriction so any authenticated team member can update ticket status and fields Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -157,9 +157,10 @@ try {
|
|||||||
}, $mentionedUsers);
|
}, $mentionedUsers);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add user display name to result for frontend
|
// Add user info to result for frontend avatar rendering
|
||||||
if ($result['success']) {
|
if ($result['success']) {
|
||||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||||
|
$result['user_id'] = $userId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Discard any unexpected output
|
// Discard any unexpected output
|
||||||
|
|||||||
+2
-10
@@ -93,16 +93,8 @@ try {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
// Any authenticated team member can update tickets.
|
||||||
if (!$this->isAdmin
|
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||||
&& (int)$currentTicket['created_by'] !== (int)$this->userId
|
|
||||||
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
|
|
||||||
) {
|
|
||||||
return [
|
|
||||||
'success' => false,
|
|
||||||
'error' => 'Permission denied'
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Merge current data with updates, keeping existing values for missing fields
|
// Merge current data with updates, keeping existing values for missing fields
|
||||||
$updateData = [
|
$updateData = [
|
||||||
|
|||||||
+98
-53
@@ -182,6 +182,81 @@ function toggleEditMode() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute avatar color class from display name (mirrors PHP crc32 % 4 logic)
|
||||||
|
*/
|
||||||
|
function avatarColorClass(displayName) {
|
||||||
|
var colors = ['lt-avatar--orange', 'lt-avatar--green', 'lt-avatar--purple', ''];
|
||||||
|
var h = 0;
|
||||||
|
for (var i = 0; i < displayName.length; i++) {
|
||||||
|
h = ((h << 5) - h + displayName.charCodeAt(i)) | 0;
|
||||||
|
}
|
||||||
|
return colors[Math.abs(h) % 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a comment/reply DOM element matching the server-rendered structure
|
||||||
|
*/
|
||||||
|
function buildCommentElement(opts) {
|
||||||
|
// opts: { commentId, userId, displayName, createdAt, commentText, isMarkdown,
|
||||||
|
// depth, parentId, canModify }
|
||||||
|
var depth = opts.depth || 0;
|
||||||
|
var depthClass = 'thread-depth-' + Math.min(depth, 3);
|
||||||
|
var threadClass = opts.parentId ? 'comment-reply' : 'comment-root';
|
||||||
|
|
||||||
|
var words = (opts.displayName || '').trim().split(/\s+/).filter(Boolean);
|
||||||
|
var initials = words.slice(0, 2).map(function(w) { return w[0].toUpperCase(); }).join('');
|
||||||
|
var color = avatarColorClass(opts.displayName || '');
|
||||||
|
var avatarImg = opts.userId > 0
|
||||||
|
? '<img src="/api/user_avatar.php?user_id=' + opts.userId + '" alt="" class="lt-avatar-img">'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var threadLine = opts.parentId ? '<div class="thread-line" aria-hidden="true"></div>' : '';
|
||||||
|
|
||||||
|
var replyBtn = depth < 3
|
||||||
|
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn reply-btn"' +
|
||||||
|
' data-action="reply-comment" data-comment-id="' + opts.commentId + '"' +
|
||||||
|
' data-user="' + lt.escHtml(opts.displayName) + '" aria-label="Reply to comment">Reply</button>'
|
||||||
|
: '';
|
||||||
|
var modBtns = opts.canModify !== false
|
||||||
|
? '<button type="button" class="lt-btn lt-btn-ghost lt-btn-sm comment-action-btn edit-btn"' +
|
||||||
|
' data-action="edit-comment" data-comment-id="' + opts.commentId + '" aria-label="Edit comment">Edit</button>' +
|
||||||
|
'<button type="button" class="lt-btn lt-btn-danger lt-btn-sm comment-action-btn delete-btn"' +
|
||||||
|
' data-action="delete-comment" data-comment-id="' + opts.commentId + '" aria-label="Delete comment">Del</button>'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
var div = document.createElement('div');
|
||||||
|
div.className = 'comment ' + depthClass + ' ' + threadClass + ' animate-fadein';
|
||||||
|
div.dataset.commentId = opts.commentId;
|
||||||
|
div.dataset.markdownEnabled = opts.isMarkdown ? '1' : '0';
|
||||||
|
div.dataset.threadDepth = depth;
|
||||||
|
div.dataset.parentId = opts.parentId || '';
|
||||||
|
|
||||||
|
div.innerHTML =
|
||||||
|
threadLine +
|
||||||
|
'<div class="comment-content">' +
|
||||||
|
'<div class="comment-header lt-flex lt-flex-gap-sm lt-flex-align-center">' +
|
||||||
|
'<div class="lt-avatar lt-avatar--xs ' + color + '" aria-hidden="true">' +
|
||||||
|
avatarImg +
|
||||||
|
'<span class="lt-avatar-initials">' + lt.escHtml(initials) + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<span class="comment-user lt-text-amber">' + lt.escHtml(opts.displayName) + '</span>' +
|
||||||
|
'<span class="comment-date lt-text-xs lt-text-muted">' + lt.escHtml(opts.createdAt) + '</span>' +
|
||||||
|
'<div class="comment-actions lt-btn-group">' + replyBtn + modBtns + '</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="comment-text" id="comment-text-' + opts.commentId + '"' +
|
||||||
|
(opts.isMarkdown ? ' data-markdown data-rendered="1"' : '') + '>' +
|
||||||
|
opts.commentText +
|
||||||
|
'</div>' +
|
||||||
|
'<textarea class="lt-input lt-textarea comment-edit-raw is-hidden"' +
|
||||||
|
' id="comment-raw-' + opts.commentId + '" aria-hidden="true">' +
|
||||||
|
lt.escHtml(opts.rawText) +
|
||||||
|
'</textarea>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
function addComment() {
|
function addComment() {
|
||||||
const newComment = document.getElementById('newComment');
|
const newComment = document.getElementById('newComment');
|
||||||
if (!newComment) return;
|
if (!newComment) return;
|
||||||
@@ -226,32 +301,19 @@ function addComment() {
|
|||||||
.replace(/\n/g, '<br>');
|
.replace(/\n/g, '<br>');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add new comment to the list (using safe DOM API to prevent XSS)
|
// Add new comment to the list
|
||||||
const commentsList = document.querySelector('.comments-list');
|
const commentsList = document.querySelector('.comments-list');
|
||||||
|
const commentDiv = buildCommentElement({
|
||||||
const commentDiv = document.createElement('div');
|
commentId: data.comment_id,
|
||||||
commentDiv.className = 'comment';
|
userId: data.user_id,
|
||||||
|
displayName: data.user_name,
|
||||||
const headerDiv = document.createElement('div');
|
createdAt: data.created_at,
|
||||||
headerDiv.className = 'comment-header';
|
commentText: displayText,
|
||||||
|
rawText: commentText,
|
||||||
const userSpan = document.createElement('span');
|
isMarkdown: isMarkdownEnabled,
|
||||||
userSpan.className = 'comment-user';
|
depth: 0,
|
||||||
userSpan.textContent = data.user_name; // Safe - auto-escapes
|
parentId: null,
|
||||||
|
});
|
||||||
const dateSpan = document.createElement('span');
|
|
||||||
dateSpan.className = 'comment-date';
|
|
||||||
dateSpan.textContent = data.created_at; // Safe - auto-escapes
|
|
||||||
|
|
||||||
const textDiv = document.createElement('div');
|
|
||||||
textDiv.className = 'comment-text';
|
|
||||||
textDiv.innerHTML = displayText; // displayText already sanitized above
|
|
||||||
|
|
||||||
headerDiv.appendChild(userSpan);
|
|
||||||
headerDiv.appendChild(dateSpan);
|
|
||||||
commentDiv.appendChild(headerDiv);
|
|
||||||
commentDiv.appendChild(textDiv);
|
|
||||||
|
|
||||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||||
} else {
|
} else {
|
||||||
lt.toast.error(data.error || 'Failed to add comment');
|
lt.toast.error(data.error || 'Failed to add comment');
|
||||||
@@ -1550,34 +1612,17 @@ function submitReply(parentCommentId) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create the new reply element
|
// Create the new reply element
|
||||||
const replyDiv = document.createElement('div');
|
const replyDiv = buildCommentElement({
|
||||||
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`;
|
commentId: data.comment_id,
|
||||||
replyDiv.dataset.commentId = data.comment_id;
|
userId: data.user_id,
|
||||||
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0';
|
displayName: data.user_name,
|
||||||
replyDiv.dataset.threadDepth = newDepth;
|
createdAt: data.created_at,
|
||||||
replyDiv.dataset.parentId = parentCommentId;
|
commentText: displayText,
|
||||||
|
rawText: commentText,
|
||||||
replyDiv.innerHTML = `
|
isMarkdown: isMarkdownEnabled,
|
||||||
<div class="thread-line"></div>
|
depth: newDepth,
|
||||||
<div class="comment-content">
|
parentId: parentCommentId,
|
||||||
<div class="comment-header">
|
});
|
||||||
<span class="comment-user">${lt.escHtml(data.user_name)}</span>
|
|
||||||
<span class="comment-date">${lt.escHtml(data.created_at)}</span>
|
|
||||||
<div class="comment-actions">
|
|
||||||
${newDepth < 3 ? `<button type="button" class="comment-action-btn reply-btn" data-action="reply-comment" data-comment-id="${data.comment_id}" data-user="${lt.escHtml(data.user_name)}" title="Reply">↩</button>` : ''}
|
|
||||||
<button type="button" class="comment-action-btn edit-btn" data-action="edit-comment" data-comment-id="${data.comment_id}" title="Edit">[ EDIT ]</button>
|
|
||||||
<button type="button" class="comment-action-btn delete-btn" data-action="delete-comment" data-comment-id="${data.comment_id}" title="Delete">[ DEL ]</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="comment-text" id="comment-text-${data.comment_id}" ${isMarkdownEnabled ? 'data-markdown' : ''}>
|
|
||||||
${displayText}
|
|
||||||
</div>
|
|
||||||
<textarea class="comment-edit-raw is-hidden" id="comment-raw-${data.comment_id}">${lt.escHtml(commentText)}</textarea>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// Add animation
|
|
||||||
replyDiv.classList.add('animate-fadein');
|
|
||||||
repliesContainer.appendChild(replyDiv);
|
repliesContainer.appendChild(replyDiv);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -369,7 +369,7 @@ class AuditLogModel {
|
|||||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||||
return $this->log(
|
return $this->log(
|
||||||
$userId,
|
$userId,
|
||||||
'create',
|
'comment',
|
||||||
'comment',
|
'comment',
|
||||||
(string)$commentId,
|
(string)$commentId,
|
||||||
['ticket_id' => $ticketId]
|
['ticket_id' => $ticketId]
|
||||||
|
|||||||
@@ -37,7 +37,9 @@ function getEventIcon(string $actionType): string {
|
|||||||
function formatAction(array $event): string {
|
function formatAction(array $event): string {
|
||||||
$det = $event['details'] ?? [];
|
$det = $event['details'] ?? [];
|
||||||
switch ($event['action_type']) {
|
switch ($event['action_type']) {
|
||||||
case 'create': return 'created this ticket';
|
case 'create':
|
||||||
|
if (($event['entity_type'] ?? '') === 'comment') return 'posted a comment';
|
||||||
|
return 'created this ticket';
|
||||||
case 'comment': return 'posted a comment';
|
case 'comment': return 'posted a comment';
|
||||||
case 'view': return 'viewed this ticket';
|
case 'view': return 'viewed this ticket';
|
||||||
case 'attachment': return 'uploaded a file';
|
case 'attachment': return 'uploaded a file';
|
||||||
|
|||||||
Reference in New Issue
Block a user