fix(ticketing): restrict settings link to admins and protect global priorities
- Show 'Go to Settings' bootstrap link only for admin/super_admin users - Pass isSiteAdmin prop to Create.vue to control settings CTA visibility - Require site admin for updatePriority/destroyPriority when priority is global (group_id = null) - Closes: non-admin users seeing forbidden settings link; agents mutating global priorities
This commit is contained in:
@@ -1,218 +1,218 @@
|
||||
<template>
|
||||
<div class="max-w-4xl mx-auto py-8 px-4">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||
</div>
|
||||
<AppLayout>
|
||||
<div class="max-w-4xl mx-auto">
|
||||
<!-- Header -->
|
||||
<div class="mb-6">
|
||||
<Link :href="route('ticketing.index')" class="text-sm text-indigo-600 hover:underline">← Back to tickets</Link>
|
||||
</div>
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-sm border border-gray-200 dark:border-gray-700">
|
||||
<!-- Ticket Header -->
|
||||
<div class="p-5 border-b border-gray-200 dark:border-gray-700">
|
||||
<div class="flex items-start gap-3 flex-wrap">
|
||||
<span
|
||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||
>
|
||||
{{ ticket.number }}
|
||||
</span>
|
||||
<div class="bg-white rounded-xl shadow-sm border border-gray-200">
|
||||
<!-- Ticket Header -->
|
||||
<div class="p-5 border-b border-gray-200">
|
||||
<div class="flex items-start gap-3 flex-wrap">
|
||||
<span
|
||||
class="inline-block text-xs font-mono font-semibold px-2 py-1 rounded text-white flex-shrink-0"
|
||||
:style="{ backgroundColor: ticket.group?.color || '#6366f1' }"
|
||||
>
|
||||
{{ ticket.number }}
|
||||
</span>
|
||||
|
||||
<!-- Title (editable inline for agents) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
|
||||
<!-- Title (editable inline for agents) -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div v-if="editingTitle && isAgent" class="flex items-center gap-2">
|
||||
<input
|
||||
v-model="titleEdit"
|
||||
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent focus:outline-none"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.esc="editingTitle = false"
|
||||
/>
|
||||
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
|
||||
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
|
||||
</div>
|
||||
<h1
|
||||
v-else
|
||||
class="text-xl font-semibold text-gray-900"
|
||||
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
|
||||
@click="isAgent && startEditTitle()"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="flex flex-wrap items-center gap-3 mt-3">
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.status"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
|
||||
:class="statusClass(metaForm.status)"
|
||||
>
|
||||
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ statusLabel(ticket.status) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.priority_id"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
||||
>
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="p in priorities" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
|
||||
{{ ticket.priority.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div v-if="isAgent">
|
||||
<select
|
||||
v-model="metaForm.assigned_to"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 rounded-full px-3 py-1"
|
||||
>
|
||||
<option :value="null">Unassigned</option>
|
||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500">
|
||||
<span>📅</span>
|
||||
<input
|
||||
v-model="titleEdit"
|
||||
class="flex-1 text-xl font-semibold border-b-2 border-indigo-400 bg-transparent dark:text-white focus:outline-none"
|
||||
@keyup.enter="saveTitle"
|
||||
@keyup.esc="editingTitle = false"
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.due_date"
|
||||
type="date"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 bg-transparent cursor-pointer p-0"
|
||||
/>
|
||||
<button @click="saveTitle" class="text-xs text-green-600 hover:underline">Save</button>
|
||||
<button @click="editingTitle = false" class="text-xs text-gray-400 hover:underline">Cancel</button>
|
||||
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Delete actions -->
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Link
|
||||
v-if="isAgent"
|
||||
:href="route('ticketing.edit', { ticket: ticket.id })"
|
||||
class="text-xs text-indigo-600 hover:underline"
|
||||
>Edit</Link>
|
||||
<button
|
||||
v-if="isManager"
|
||||
@click="confirmDelete"
|
||||
class="text-xs text-red-500 hover:underline"
|
||||
>Delete</button>
|
||||
</div>
|
||||
<h1
|
||||
v-else
|
||||
class="text-xl font-semibold text-gray-900 dark:text-white"
|
||||
:class="{ 'cursor-pointer hover:text-indigo-600': isAgent }"
|
||||
@click="isAgent && startEditTitle()"
|
||||
>
|
||||
{{ ticket.title }}
|
||||
<span v-if="isAgent" class="ml-1 text-xs text-gray-400">✏️</span>
|
||||
</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Meta row -->
|
||||
<div class="flex flex-wrap items-center gap-3 mt-3">
|
||||
<!-- Status -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.status"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 rounded-full px-3 py-1 font-medium cursor-pointer"
|
||||
:class="statusClass(metaForm.status)"
|
||||
>
|
||||
<option v-for="s in statuses" :key="s.value" :value="s.value">{{ s.label }}</option>
|
||||
</select>
|
||||
<span v-else :class="statusClass(ticket.status)" class="inline-block text-xs px-2 py-1 rounded-full font-medium">
|
||||
{{ statusLabel(ticket.status) }}
|
||||
</span>
|
||||
<!-- Message Thread -->
|
||||
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
|
||||
No messages yet.
|
||||
</div>
|
||||
|
||||
<!-- Priority -->
|
||||
<div>
|
||||
<select
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.priority_id"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option :value="null">No priority</option>
|
||||
<option v-for="p in priorities" :key="p.id" :value="p.id">
|
||||
{{ p.name }}
|
||||
</option>
|
||||
</select>
|
||||
<span v-else-if="ticket.priority" class="inline-flex items-center gap-1 text-xs text-gray-600 dark:text-gray-300">
|
||||
<span class="w-2 h-2 rounded-full" :style="{ backgroundColor: ticket.priority.color }"></span>
|
||||
{{ ticket.priority.name }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Assignee -->
|
||||
<div v-if="isAgent">
|
||||
<select
|
||||
v-model="metaForm.assigned_to"
|
||||
@change="saveMeta"
|
||||
class="text-xs border border-gray-200 dark:border-gray-600 rounded-full px-3 py-1 dark:bg-gray-700 dark:text-white"
|
||||
>
|
||||
<option :value="null">Unassigned</option>
|
||||
<option v-for="a in agents" :key="a.id" :value="a.id">{{ a.name }}</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Due date -->
|
||||
<div class="flex items-center gap-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>📅</span>
|
||||
<input
|
||||
v-if="isAgent"
|
||||
v-model="metaForm.due_date"
|
||||
type="date"
|
||||
@change="saveMeta"
|
||||
class="text-xs border-0 bg-transparent dark:text-gray-400 cursor-pointer p-0"
|
||||
/>
|
||||
<span v-else>{{ ticket.due_date || 'No due date' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Edit / Delete actions -->
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Link
|
||||
v-if="isAgent"
|
||||
:href="route('ticketing.edit', { ticket: ticket.id })"
|
||||
class="text-xs text-indigo-600 hover:underline"
|
||||
>Edit</Link>
|
||||
<button
|
||||
v-if="isManager"
|
||||
@click="confirmDelete"
|
||||
class="text-xs text-red-500 hover:underline"
|
||||
>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Message Thread -->
|
||||
<div class="p-5 space-y-4 max-h-[60vh] overflow-y-auto">
|
||||
<div v-if="ticket.messages.length === 0" class="text-sm text-gray-400 text-center py-8">
|
||||
No messages yet.
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="msg in ticket.messages"
|
||||
:key="msg.id"
|
||||
:class="[
|
||||
'flex',
|
||||
isOwnMessage(msg) ? 'justify-end' : 'justify-start'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
|
||||
msg.is_internal
|
||||
? 'bg-amber-50 dark:bg-amber-900/30 border border-amber-200 dark:border-amber-700 text-amber-900 dark:text-amber-100'
|
||||
: isOwnMessage(msg)
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-100'
|
||||
]"
|
||||
v-for="msg in ticket.messages"
|
||||
:key="msg.id"
|
||||
:class="['flex', isOwnMessage(msg) ? 'justify-end' : 'justify-start']"
|
||||
>
|
||||
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
|
||||
<span v-if="msg.is_internal">🔒 Internal Note · </span>
|
||||
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
|
||||
<span>· {{ timeAgo(msg.created_at) }}</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Reply Area -->
|
||||
<div class="border-t border-gray-200 dark:border-gray-700 p-5">
|
||||
<!-- Tab switcher (agents only) -->
|
||||
<div v-if="isAgent" class="flex gap-2 mb-3">
|
||||
<button
|
||||
@click="replyTab = 'reply'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
|
||||
]"
|
||||
>Reply to submitter</button>
|
||||
<button
|
||||
@click="replyTab = 'internal'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-300 hover:bg-gray-200'
|
||||
]"
|
||||
>🔒 Internal Note</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
v-model="messageForm.body"
|
||||
required
|
||||
rows="3"
|
||||
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
|
||||
:class="[
|
||||
'w-full rounded-lg text-sm border',
|
||||
replyTab === 'internal'
|
||||
? 'border-amber-300 bg-amber-50 dark:bg-amber-900/20 dark:border-amber-700'
|
||||
: 'border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white'
|
||||
]"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="file" class="hidden" @change="attachFile" />
|
||||
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="messageForm.processing"
|
||||
<div
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
|
||||
replyTab === 'internal'
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-700 disabled:opacity-60'
|
||||
'max-w-[75%] rounded-xl px-4 py-2.5 text-sm',
|
||||
msg.is_internal
|
||||
? 'bg-amber-50 border border-amber-200 text-amber-900'
|
||||
: isOwnMessage(msg)
|
||||
? 'bg-indigo-600 text-white'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
]"
|
||||
>
|
||||
{{ messageForm.processing ? 'Sending…' : 'Send' }}
|
||||
</button>
|
||||
<div class="flex items-center gap-2 mb-1 text-xs opacity-70">
|
||||
<span v-if="msg.is_internal">🔒 Internal Note · </span>
|
||||
<span>{{ msg.author?.name || msg.author_email || 'Unknown' }}</span>
|
||||
<span>· {{ timeAgo(msg.created_at) }}</span>
|
||||
</div>
|
||||
<p class="whitespace-pre-wrap">{{ msg.body }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Reply Area -->
|
||||
<div class="border-t border-gray-200 p-5">
|
||||
<!-- Tab switcher (agents only) -->
|
||||
<div v-if="isAgent" class="flex gap-2 mb-3">
|
||||
<button
|
||||
@click="replyTab = 'reply'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'reply' ? 'bg-indigo-600 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>Reply to submitter</button>
|
||||
<button
|
||||
@click="replyTab = 'internal'"
|
||||
:class="[
|
||||
'text-xs px-3 py-1.5 rounded-md font-medium transition',
|
||||
replyTab === 'internal' ? 'bg-amber-500 text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||
]"
|
||||
>🔒 Internal Note</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="sendMessage">
|
||||
<textarea
|
||||
v-model="messageForm.body"
|
||||
required
|
||||
rows="3"
|
||||
:placeholder="replyTab === 'internal' ? 'Internal note — only visible to agents…' : 'Type your reply…'"
|
||||
:class="[
|
||||
'w-full rounded-lg text-sm border',
|
||||
replyTab === 'internal'
|
||||
? 'border-amber-300 bg-amber-50'
|
||||
: 'border-gray-300'
|
||||
]"
|
||||
></textarea>
|
||||
|
||||
<div class="flex items-center justify-between mt-3">
|
||||
<label class="flex items-center gap-2 text-xs text-gray-500 cursor-pointer">
|
||||
<input type="file" class="hidden" @change="attachFile" />
|
||||
<span class="text-gray-400 hover:text-indigo-600 transition">📎 Attach file</span>
|
||||
</label>
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="messageForm.processing"
|
||||
:class="[
|
||||
'inline-flex items-center gap-1 text-sm font-medium px-4 py-2 rounded-lg transition',
|
||||
replyTab === 'internal'
|
||||
? 'bg-amber-500 text-white hover:bg-amber-600 disabled:opacity-60'
|
||||
: 'bg-gray-900 text-white hover:bg-gray-700 disabled:opacity-60'
|
||||
]"
|
||||
>
|
||||
{{ messageForm.processing ? 'Sending…' : 'Send' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppLayout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { Link, useForm, router } from '@inertiajs/vue3'
|
||||
import AppLayout from '@/Layouts/AppLayout.vue'
|
||||
|
||||
const props = defineProps({
|
||||
ticket: Object,
|
||||
@@ -247,8 +247,6 @@ const messageForm = useForm({
|
||||
})
|
||||
|
||||
function isOwnMessage(msg) {
|
||||
// If the message user_id matches the current user (we don't have auth here easily,
|
||||
// but for display purposes: agents on right when it's not submitter's message)
|
||||
return !props.isAgent ? msg.user_id === props.ticket.submitter_id : msg.user_id !== props.ticket.submitter_id
|
||||
}
|
||||
|
||||
@@ -295,11 +293,11 @@ function statusLabel(status) {
|
||||
|
||||
function statusClass(status) {
|
||||
const map = {
|
||||
open: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-200',
|
||||
in_progress: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-200',
|
||||
pending: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200',
|
||||
resolved: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-200',
|
||||
closed: 'bg-gray-100 text-gray-500 dark:bg-gray-700 dark:text-gray-400',
|
||||
open: 'bg-blue-100 text-blue-700',
|
||||
in_progress: 'bg-purple-100 text-purple-700',
|
||||
pending: 'bg-yellow-100 text-yellow-700',
|
||||
resolved: 'bg-green-100 text-green-700',
|
||||
closed: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
return map[status] || 'bg-gray-100 text-gray-600'
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user