feat: Add 9 new features for enhanced UX and security

Quick Wins:
- Feature 1: Ticket linking in comments (#123456789 auto-links)
- Feature 6: Checkbox click area fix (click anywhere in cell)
- Feature 7: User groups display in settings modal

UI Enhancements:
- Feature 4: Collapsible sidebar with localStorage persistence
- Feature 5: Inline ticket preview popup on hover (300ms delay)
- Feature 2: Mobile responsive improvements (44px touch targets, iOS zoom fix)

Major Features:
- Feature 3: Kanban card view with status columns (toggle with localStorage)
- Feature 9: API key generation admin panel (/admin/api-keys)
- Feature 8: Ticket visibility levels (public/internal/confidential)

New files:
- views/admin/ApiKeysView.php
- api/generate_api_key.php
- api/revoke_api_key.php
- migrations/008_ticket_visibility.sql

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-01-23 10:01:50 -05:00
parent c32e9c871b
commit e86a5de3fd
19 changed files with 1933 additions and 39 deletions

View File

@@ -261,8 +261,8 @@ class TicketModel {
// Generate ticket ID (9-digit format with leading zeros)
$ticket_id = sprintf('%09d', mt_rand(1, 999999999));
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)";
$sql = "INSERT INTO tickets (ticket_id, title, description, status, priority, category, type, created_by, visibility, visibility_groups)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)";
$stmt = $this->conn->prepare($sql);
@@ -271,9 +271,22 @@ class TicketModel {
$priority = $ticketData['priority'] ?? '4';
$category = $ticketData['category'] ?? 'General';
$type = $ticketData['type'] ?? 'Issue';
$visibility = $ticketData['visibility'] ?? 'public';
$visibilityGroups = $ticketData['visibility_groups'] ?? null;
// Validate visibility
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Clear visibility_groups if not internal
if ($visibility !== 'internal') {
$visibilityGroups = null;
}
$stmt->bind_param(
"sssssssi",
"sssssssiss",
$ticket_id,
$ticketData['title'],
$ticketData['description'],
@@ -281,7 +294,9 @@ class TicketModel {
$priority,
$category,
$type,
$createdBy
$createdBy,
$visibility,
$visibilityGroups
);
if ($stmt->execute()) {
@@ -407,4 +422,123 @@ class TicketModel {
$stmt->close();
return $tickets;
}
/**
* Check if a user can access a ticket based on visibility settings
*
* @param array $ticket The ticket data
* @param array $user The user data (must include user_id, is_admin, groups)
* @return bool True if user can access the ticket
*/
public function canUserAccessTicket($ticket, $user) {
// Admins can access all tickets
if (!empty($user['is_admin'])) {
return true;
}
$visibility = $ticket['visibility'] ?? 'public';
// Public tickets are accessible to all authenticated users
if ($visibility === 'public') {
return true;
}
// Confidential tickets: only creator, assignee, and admins
if ($visibility === 'confidential') {
$userId = $user['user_id'] ?? null;
return ($ticket['created_by'] == $userId || $ticket['assigned_to'] == $userId);
}
// Internal tickets: check if user is in any of the allowed groups
if ($visibility === 'internal') {
$allowedGroups = array_filter(array_map('trim', explode(',', $ticket['visibility_groups'] ?? '')));
if (empty($allowedGroups)) {
return false; // No groups specified means no access
}
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Check if any user group matches any allowed group
return !empty(array_intersect($userGroups, $allowedGroups));
}
return false;
}
/**
* Build visibility filter SQL for queries
*
* @param array $user The current user
* @return array ['sql' => string, 'params' => array, 'types' => string]
*/
public function getVisibilityFilter($user) {
// Admins see all tickets
if (!empty($user['is_admin'])) {
return ['sql' => '1=1', 'params' => [], 'types' => ''];
}
$userId = $user['user_id'] ?? 0;
$userGroups = array_filter(array_map('trim', explode(',', $user['groups'] ?? '')));
// Build the visibility filter
// 1. Public tickets
// 2. Confidential tickets where user is creator or assignee
// 3. Internal tickets where user's groups overlap with visibility_groups
$conditions = [];
$params = [];
$types = '';
// Public visibility
$conditions[] = "(t.visibility = 'public' OR t.visibility IS NULL)";
// Confidential - user is creator or assignee
$conditions[] = "(t.visibility = 'confidential' AND (t.created_by = ? OR t.assigned_to = ?))";
$params[] = $userId;
$params[] = $userId;
$types .= 'ii';
// Internal - check group membership
if (!empty($userGroups)) {
$groupConditions = [];
foreach ($userGroups as $group) {
$groupConditions[] = "FIND_IN_SET(?, REPLACE(t.visibility_groups, ' ', ''))";
$params[] = $group;
$types .= 's';
}
$conditions[] = "(t.visibility = 'internal' AND (" . implode(' OR ', $groupConditions) . "))";
}
return [
'sql' => '(' . implode(' OR ', $conditions) . ')',
'params' => $params,
'types' => $types
];
}
/**
* Update ticket visibility settings
*
* @param int $ticketId
* @param string $visibility ('public', 'internal', 'confidential')
* @param string|null $visibilityGroups Comma-separated group names for 'internal' visibility
* @param int $updatedBy User ID
* @return bool
*/
public function updateVisibility($ticketId, $visibility, $visibilityGroups, $updatedBy) {
$allowedVisibilities = ['public', 'internal', 'confidential'];
if (!in_array($visibility, $allowedVisibilities)) {
$visibility = 'public';
}
// Clear visibility_groups if not internal
if ($visibility !== 'internal') {
$visibilityGroups = null;
}
$sql = "UPDATE tickets SET visibility = ?, visibility_groups = ?, updated_by = ?, updated_at = NOW() WHERE ticket_id = ?";
$stmt = $this->conn->prepare($sql);
$stmt->bind_param("ssii", $visibility, $visibilityGroups, $updatedBy, $ticketId);
$result = $stmt->execute();
$stmt->close();
return $result;
}
}

View File

@@ -266,4 +266,29 @@ class UserModel {
$stmt->close();
return $users;
}
/**
* Get all distinct groups from all users
* Used for visibility group selection UI
*
* @return array Array of unique group names
*/
public function getAllGroups() {
$stmt = $this->conn->prepare("SELECT DISTINCT groups FROM users WHERE groups IS NOT NULL AND groups != ''");
$stmt->execute();
$result = $stmt->get_result();
$allGroups = [];
while ($row = $result->fetch_assoc()) {
$userGroups = array_filter(array_map('trim', explode(',', $row['groups'])));
$allGroups = array_merge($allGroups, $userGroups);
}
$stmt->close();
// Return unique groups sorted alphabetically
$uniqueGroups = array_unique($allGroups);
sort($uniqueGroups);
return $uniqueGroups;
}
}