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);
|
||||
}
|
||||
|
||||
// Add user display name to result for frontend
|
||||
// Add user info to result for frontend avatar rendering
|
||||
if ($result['success']) {
|
||||
$result['user_name'] = $currentUser['display_name'] ?? $currentUser['username'];
|
||||
$result['user_id'] = $userId;
|
||||
}
|
||||
|
||||
// Discard any unexpected output
|
||||
|
||||
+2
-10
@@ -93,16 +93,8 @@ try {
|
||||
];
|
||||
}
|
||||
|
||||
// Authorization: admins can edit any ticket; others only their own or assigned
|
||||
if (!$this->isAdmin
|
||||
&& (int)$currentTicket['created_by'] !== (int)$this->userId
|
||||
&& (int)$currentTicket['assigned_to'] !== (int)$this->userId
|
||||
) {
|
||||
return [
|
||||
'success' => false,
|
||||
'error' => 'Permission denied'
|
||||
];
|
||||
}
|
||||
// Any authenticated team member can update tickets.
|
||||
// Admin-only operations (delete, bulk actions) are enforced separately.
|
||||
|
||||
// Merge current data with updates, keeping existing values for missing fields
|
||||
$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() {
|
||||
const newComment = document.getElementById('newComment');
|
||||
if (!newComment) return;
|
||||
@@ -226,32 +301,19 @@ function addComment() {
|
||||
.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 commentDiv = document.createElement('div');
|
||||
commentDiv.className = 'comment';
|
||||
|
||||
const headerDiv = document.createElement('div');
|
||||
headerDiv.className = 'comment-header';
|
||||
|
||||
const userSpan = document.createElement('span');
|
||||
userSpan.className = 'comment-user';
|
||||
userSpan.textContent = data.user_name; // Safe - auto-escapes
|
||||
|
||||
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);
|
||||
|
||||
const commentDiv = buildCommentElement({
|
||||
commentId: data.comment_id,
|
||||
userId: data.user_id,
|
||||
displayName: data.user_name,
|
||||
createdAt: data.created_at,
|
||||
commentText: displayText,
|
||||
rawText: commentText,
|
||||
isMarkdown: isMarkdownEnabled,
|
||||
depth: 0,
|
||||
parentId: null,
|
||||
});
|
||||
commentsList.insertBefore(commentDiv, commentsList.firstChild);
|
||||
} else {
|
||||
lt.toast.error(data.error || 'Failed to add comment');
|
||||
@@ -1550,34 +1612,17 @@ function submitReply(parentCommentId) {
|
||||
}
|
||||
|
||||
// Create the new reply element
|
||||
const replyDiv = document.createElement('div');
|
||||
replyDiv.className = `comment thread-depth-${newDepth} comment-reply`;
|
||||
replyDiv.dataset.commentId = data.comment_id;
|
||||
replyDiv.dataset.markdownEnabled = isMarkdownEnabled ? '1' : '0';
|
||||
replyDiv.dataset.threadDepth = newDepth;
|
||||
replyDiv.dataset.parentId = parentCommentId;
|
||||
|
||||
replyDiv.innerHTML = `
|
||||
<div class="thread-line"></div>
|
||||
<div class="comment-content">
|
||||
<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');
|
||||
const replyDiv = buildCommentElement({
|
||||
commentId: data.comment_id,
|
||||
userId: data.user_id,
|
||||
displayName: data.user_name,
|
||||
createdAt: data.created_at,
|
||||
commentText: displayText,
|
||||
rawText: commentText,
|
||||
isMarkdown: isMarkdownEnabled,
|
||||
depth: newDepth,
|
||||
parentId: parentCommentId,
|
||||
});
|
||||
repliesContainer.appendChild(replyDiv);
|
||||
}
|
||||
|
||||
|
||||
@@ -369,7 +369,7 @@ class AuditLogModel {
|
||||
public function logCommentCreate($userId, $commentId, $ticketId) {
|
||||
return $this->log(
|
||||
$userId,
|
||||
'create',
|
||||
'comment',
|
||||
'comment',
|
||||
(string)$commentId,
|
||||
['ticket_id' => $ticketId]
|
||||
|
||||
@@ -37,7 +37,9 @@ function getEventIcon(string $actionType): string {
|
||||
function formatAction(array $event): string {
|
||||
$det = $event['details'] ?? [];
|
||||
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 'view': return 'viewed this ticket';
|
||||
case 'attachment': return 'uploaded a file';
|
||||
|
||||
Reference in New Issue
Block a user