@escalated-dev/escalated 0.3.5 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -1
- package/package.json +9 -2
- package/src/components/BulkActionBar.vue +97 -0
- package/src/components/EscalatedLayout.vue +244 -219
- package/src/components/FollowButton.vue +63 -0
- package/src/components/KeyboardShortcutHelp.vue +123 -0
- package/src/components/MacroDropdown.vue +90 -0
- package/src/components/PinnedNotes.vue +87 -0
- package/src/components/PluginSlot.vue +62 -0
- package/src/components/PresenceIndicator.vue +103 -0
- package/src/components/QuickFilters.vue +49 -0
- package/src/components/ReplyThread.vue +60 -38
- package/src/components/SatisfactionRating.vue +128 -0
- package/src/components/TicketFilters.vue +69 -58
- package/src/components/TicketList.vue +193 -94
- package/src/components/TicketSidebar.vue +99 -77
- package/src/composables/useKeyboardShortcuts.js +46 -0
- package/src/composables/usePluginExtensions.js +67 -0
- package/src/defineEscalatedPlugin.js +50 -0
- package/src/index.js +16 -0
- package/src/pages/Admin/ApiTokens/Index.vue +217 -0
- package/src/pages/Admin/Macros/Index.vue +287 -0
- package/src/pages/Admin/Plugins/Index.vue +197 -0
- package/src/pages/Admin/Plugins.vue +129 -0
- package/src/pages/Admin/Reports.vue +101 -66
- package/src/pages/Admin/Tickets/Index.vue +39 -22
- package/src/pages/Admin/Tickets/Show.vue +156 -109
- package/src/pages/Agent/Dashboard.vue +186 -63
- package/src/pages/Agent/TicketIndex.vue +38 -21
- package/src/pages/Agent/TicketShow.vue +155 -108
- package/src/pages/Customer/Show.vue +63 -55
- package/src/pages/Guest/Show.vue +93 -86
|
@@ -1,77 +1,99 @@
|
|
|
1
|
-
<script setup>
|
|
2
|
-
import { inject, computed } from 'vue';
|
|
3
|
-
import StatusBadge from './StatusBadge.vue';
|
|
4
|
-
import PriorityBadge from './PriorityBadge.vue';
|
|
5
|
-
import SlaTimer from './SlaTimer.vue';
|
|
6
|
-
import AssigneeSelect from './AssigneeSelect.vue';
|
|
7
|
-
import TagSelect from './TagSelect.vue';
|
|
8
|
-
import ActivityTimeline from './ActivityTimeline.vue';
|
|
9
|
-
|
|
10
|
-
defineProps({
|
|
11
|
-
ticket: { type: Object, required: true },
|
|
12
|
-
agents: { type: Array, default: () => [] },
|
|
13
|
-
tags: { type: Array, default: () => [] },
|
|
14
|
-
activities: { type: Array, default: () => [] },
|
|
15
|
-
editable: { type: Boolean, default: false },
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
|
|
19
|
-
const escDark = inject('esc-dark', computed(() => false));
|
|
20
|
-
|
|
21
|
-
const cardClass = computed(() => escDark.value
|
|
22
|
-
? 'rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4'
|
|
23
|
-
: 'rounded-lg border border-gray-200 bg-white p-4'
|
|
24
|
-
);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
<
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
<div class="flex justify-between">
|
|
37
|
-
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">
|
|
38
|
-
<dd><
|
|
39
|
-
</div>
|
|
40
|
-
<div class="flex justify-between">
|
|
41
|
-
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">
|
|
42
|
-
<dd :
|
|
43
|
-
</div>
|
|
44
|
-
<div
|
|
45
|
-
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">
|
|
46
|
-
<dd :class="escDark ? 'text-
|
|
47
|
-
</div>
|
|
48
|
-
<div class="flex justify-between">
|
|
49
|
-
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">
|
|
50
|
-
<dd :class="escDark ? 'text-neutral-300' : ''">{{
|
|
51
|
-
</div>
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
</div>
|
|
76
|
-
|
|
77
|
-
|
|
1
|
+
<script setup>
|
|
2
|
+
import { inject, computed } from 'vue';
|
|
3
|
+
import StatusBadge from './StatusBadge.vue';
|
|
4
|
+
import PriorityBadge from './PriorityBadge.vue';
|
|
5
|
+
import SlaTimer from './SlaTimer.vue';
|
|
6
|
+
import AssigneeSelect from './AssigneeSelect.vue';
|
|
7
|
+
import TagSelect from './TagSelect.vue';
|
|
8
|
+
import ActivityTimeline from './ActivityTimeline.vue';
|
|
9
|
+
|
|
10
|
+
defineProps({
|
|
11
|
+
ticket: { type: Object, required: true },
|
|
12
|
+
agents: { type: Array, default: () => [] },
|
|
13
|
+
tags: { type: Array, default: () => [] },
|
|
14
|
+
activities: { type: Array, default: () => [] },
|
|
15
|
+
editable: { type: Boolean, default: false },
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const emit = defineEmits(['assign', 'tags', 'priority', 'department', 'status']);
|
|
19
|
+
const escDark = inject('esc-dark', computed(() => false));
|
|
20
|
+
|
|
21
|
+
const cardClass = computed(() => escDark.value
|
|
22
|
+
? 'rounded-xl border border-white/[0.06] bg-neutral-900/60 p-4'
|
|
23
|
+
: 'rounded-lg border border-gray-200 bg-white p-4'
|
|
24
|
+
);
|
|
25
|
+
|
|
26
|
+
function renderStars(rating) {
|
|
27
|
+
return Array.from({ length: 5 }, (_, i) => i < rating ? 'filled' : 'empty');
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<template>
|
|
32
|
+
<aside class="space-y-4">
|
|
33
|
+
<div :class="cardClass">
|
|
34
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Details</h3>
|
|
35
|
+
<dl class="space-y-2 text-sm">
|
|
36
|
+
<div class="flex justify-between">
|
|
37
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Status</dt>
|
|
38
|
+
<dd><StatusBadge :status="ticket.status" /></dd>
|
|
39
|
+
</div>
|
|
40
|
+
<div class="flex justify-between">
|
|
41
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Priority</dt>
|
|
42
|
+
<dd><PriorityBadge :priority="ticket.priority" /></dd>
|
|
43
|
+
</div>
|
|
44
|
+
<div class="flex justify-between">
|
|
45
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Reference</dt>
|
|
46
|
+
<dd :class="['font-mono text-xs', escDark ? 'text-white' : '']">{{ ticket.reference }}</dd>
|
|
47
|
+
</div>
|
|
48
|
+
<div v-if="ticket.department" class="flex justify-between">
|
|
49
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Department</dt>
|
|
50
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ ticket.department.name }}</dd>
|
|
51
|
+
</div>
|
|
52
|
+
<div class="flex justify-between">
|
|
53
|
+
<dt :class="escDark ? 'text-neutral-500' : 'text-gray-500'">Created</dt>
|
|
54
|
+
<dd :class="escDark ? 'text-neutral-300' : ''">{{ new Date(ticket.created_at).toLocaleDateString() }}</dd>
|
|
55
|
+
</div>
|
|
56
|
+
</dl>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
<!-- Satisfaction Rating (read-only) -->
|
|
60
|
+
<div v-if="ticket.satisfaction_rating" :class="cardClass">
|
|
61
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Customer Satisfaction</h3>
|
|
62
|
+
<div class="flex items-center gap-1">
|
|
63
|
+
<svg v-for="(star, i) in renderStars(ticket.satisfaction_rating.rating)" :key="i"
|
|
64
|
+
class="h-5 w-5" :class="star === 'filled' ? 'text-amber-400' : (escDark ? 'text-neutral-700' : 'text-gray-300')"
|
|
65
|
+
fill="currentColor" viewBox="0 0 20 20">
|
|
66
|
+
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
|
67
|
+
</svg>
|
|
68
|
+
<span :class="['ml-2 text-sm font-medium', escDark ? 'text-neutral-300' : 'text-gray-700']">
|
|
69
|
+
{{ ticket.satisfaction_rating.rating }}/5
|
|
70
|
+
</span>
|
|
71
|
+
</div>
|
|
72
|
+
<p v-if="ticket.satisfaction_rating.comment" :class="['mt-2 text-xs italic', escDark ? 'text-neutral-500' : 'text-gray-500']">
|
|
73
|
+
"{{ ticket.satisfaction_rating.comment }}"
|
|
74
|
+
</p>
|
|
75
|
+
</div>
|
|
76
|
+
|
|
77
|
+
<div v-if="ticket.first_response_due_at || ticket.resolution_due_at" class="space-y-2">
|
|
78
|
+
<SlaTimer v-if="ticket.first_response_due_at" :due-at="ticket.first_response_due_at"
|
|
79
|
+
:breached="ticket.sla_first_response_breached" label="First Response" />
|
|
80
|
+
<SlaTimer v-if="ticket.resolution_due_at" :due-at="ticket.resolution_due_at"
|
|
81
|
+
:breached="ticket.sla_resolution_breached" label="Resolution" />
|
|
82
|
+
</div>
|
|
83
|
+
|
|
84
|
+
<div v-if="editable && agents.length" :class="cardClass">
|
|
85
|
+
<AssigneeSelect :agents="agents" :model-value="ticket.assigned_to"
|
|
86
|
+
@update:model-value="emit('assign', $event)" />
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<div v-if="editable && tags.length" :class="cardClass">
|
|
90
|
+
<TagSelect :tags="tags" :model-value="(ticket.tags || []).map(t => t.id)"
|
|
91
|
+
@update:model-value="emit('tags', $event)" />
|
|
92
|
+
</div>
|
|
93
|
+
|
|
94
|
+
<div v-if="activities.length" :class="cardClass">
|
|
95
|
+
<h3 :class="['mb-3 text-sm font-semibold', escDark ? 'text-white' : 'text-gray-900']">Activity</h3>
|
|
96
|
+
<ActivityTimeline :activities="activities" />
|
|
97
|
+
</div>
|
|
98
|
+
</aside>
|
|
99
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { onMounted, onUnmounted, unref } from 'vue';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Composable for registering keyboard shortcuts.
|
|
5
|
+
*
|
|
6
|
+
* @param {Object} shortcuts - Map of key to handler function, e.g. { 'r': () => {}, 'n': () => {} }
|
|
7
|
+
* @param {Object} options - Optional configuration
|
|
8
|
+
* @param {import('vue').Ref<boolean>} options.enabled - Ref controlling whether shortcuts are active (default: true)
|
|
9
|
+
*/
|
|
10
|
+
export function useKeyboardShortcuts(shortcuts, options = {}) {
|
|
11
|
+
const ignoredTags = new Set(['INPUT', 'TEXTAREA', 'SELECT']);
|
|
12
|
+
|
|
13
|
+
function handleKeydown(event) {
|
|
14
|
+
// Check if shortcuts are enabled
|
|
15
|
+
if (options.enabled !== undefined && !unref(options.enabled)) {
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Skip when focus is inside form elements or contenteditable
|
|
20
|
+
const target = event.target;
|
|
21
|
+
if (target && (ignoredTags.has(target.tagName) || target.isContentEditable)) {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Skip if modifier keys are held (allow shift for ? and similar)
|
|
26
|
+
if (event.ctrlKey || event.metaKey || event.altKey) {
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const key = event.key;
|
|
31
|
+
const handler = shortcuts[key];
|
|
32
|
+
|
|
33
|
+
if (handler && typeof handler === 'function') {
|
|
34
|
+
event.preventDefault();
|
|
35
|
+
handler(event);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
onMounted(() => {
|
|
40
|
+
document.addEventListener('keydown', handleKeydown);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
onUnmounted(() => {
|
|
44
|
+
document.removeEventListener('keydown', handleKeydown);
|
|
45
|
+
});
|
|
46
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { computed } from 'vue';
|
|
2
|
+
import { usePage } from '@inertiajs/vue3';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Composable for reading plugin extensions from Inertia shared props.
|
|
6
|
+
*
|
|
7
|
+
* The Laravel backend (escalated-laravel) shares plugin UI extensions via
|
|
8
|
+
* `$page.props.escalated.plugins`, which contains:
|
|
9
|
+
* - menuItems: Array of navigation items injected by plugins
|
|
10
|
+
* - dashboardWidgets: Array of widget definitions for the agent dashboard
|
|
11
|
+
* - pageComponents: Object mapping slot names to arrays of component definitions
|
|
12
|
+
*
|
|
13
|
+
* @returns {Object} Plugin extension data and helper functions
|
|
14
|
+
*/
|
|
15
|
+
export function usePluginExtensions() {
|
|
16
|
+
const page = usePage();
|
|
17
|
+
|
|
18
|
+
const pluginData = computed(() => page.props?.escalated?.plugins || {});
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Menu items injected by plugins, sorted by position.
|
|
22
|
+
* Each item: { label, route, icon, position, permission, badge, submenu }
|
|
23
|
+
*/
|
|
24
|
+
const menuItems = computed(() => {
|
|
25
|
+
const items = pluginData.value.menuItems || [];
|
|
26
|
+
return [...items].sort((a, b) => (a.position || 100) - (b.position || 100));
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Dashboard widgets injected by plugins, sorted by position.
|
|
31
|
+
* Each widget: { id, title, component, plugin, data, position, width }
|
|
32
|
+
*/
|
|
33
|
+
const dashboardWidgets = computed(() => {
|
|
34
|
+
const widgets = pluginData.value.dashboardWidgets || [];
|
|
35
|
+
return [...widgets].sort((a, b) => (a.position || 100) - (b.position || 100));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Get plugin components for a specific page slot, sorted by position.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} pageName - The page name (e.g. 'dashboard', 'ticket.show')
|
|
42
|
+
* @param {string} slotName - The slot name (e.g. 'header', 'sidebar', 'footer')
|
|
43
|
+
* @returns {Array} Sorted array of component definitions for the given slot
|
|
44
|
+
*/
|
|
45
|
+
function getPageComponents(pageName, slotName) {
|
|
46
|
+
const key = `${pageName}.${slotName}`;
|
|
47
|
+
const components = pluginData.value.pageComponents || {};
|
|
48
|
+
return [...(components[key] || [])].sort((a, b) => (a.position || 100) - (b.position || 100));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Check whether any plugins have registered extensions.
|
|
53
|
+
*/
|
|
54
|
+
const hasPlugins = computed(() => {
|
|
55
|
+
return menuItems.value.length > 0
|
|
56
|
+
|| dashboardWidgets.value.length > 0
|
|
57
|
+
|| Object.keys(pluginData.value.pageComponents || {}).length > 0;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return {
|
|
61
|
+
pluginData,
|
|
62
|
+
menuItems,
|
|
63
|
+
dashboardWidgets,
|
|
64
|
+
getPageComponents,
|
|
65
|
+
hasPlugins,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helper for plugin authors to define an Escalated plugin.
|
|
3
|
+
*
|
|
4
|
+
* Returns a Vue plugin object that registers components globally when installed.
|
|
5
|
+
*
|
|
6
|
+
* Usage in a plugin's main entry file:
|
|
7
|
+
*
|
|
8
|
+
* import { defineEscalatedPlugin } from '@escalated-dev/escalated'
|
|
9
|
+
* import SalesWidget from './Components/SalesWidget.vue'
|
|
10
|
+
* import CustomSidebar from './Components/CustomSidebar.vue'
|
|
11
|
+
*
|
|
12
|
+
* export default defineEscalatedPlugin({
|
|
13
|
+
* name: 'my-plugin',
|
|
14
|
+
* components: {
|
|
15
|
+
* SalesWidget,
|
|
16
|
+
* CustomSidebar,
|
|
17
|
+
* },
|
|
18
|
+
* setup(app) {
|
|
19
|
+
* // Optional: run custom setup logic when the plugin is installed
|
|
20
|
+
* },
|
|
21
|
+
* })
|
|
22
|
+
*
|
|
23
|
+
* @param {Object} options
|
|
24
|
+
* @param {string} options.name - Plugin identifier
|
|
25
|
+
* @param {Object} [options.components] - Map of component name to component definition
|
|
26
|
+
* @param {Function} [options.setup] - Optional setup callback receiving the Vue app instance
|
|
27
|
+
* @returns {Object} A Vue plugin object with an install() method
|
|
28
|
+
*/
|
|
29
|
+
export function defineEscalatedPlugin(options = {}) {
|
|
30
|
+
return {
|
|
31
|
+
install(app) {
|
|
32
|
+
// Register all provided components globally
|
|
33
|
+
if (options.components) {
|
|
34
|
+
Object.entries(options.components).forEach(([name, component]) => {
|
|
35
|
+
app.component(name, component);
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Run optional setup callback
|
|
40
|
+
if (typeof options.setup === 'function') {
|
|
41
|
+
options.setup(app);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Provide the plugin name for debugging
|
|
45
|
+
if (options.name) {
|
|
46
|
+
app.provide(`escalated-plugin:${options.name}`, true);
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
}
|
package/src/index.js
CHANGED
|
@@ -5,11 +5,20 @@ export { EscalatedPlugin } from './plugin'
|
|
|
5
5
|
export { default as ActivityTimeline } from './components/ActivityTimeline.vue'
|
|
6
6
|
export { default as AssigneeSelect } from './components/AssigneeSelect.vue'
|
|
7
7
|
export { default as AttachmentList } from './components/AttachmentList.vue'
|
|
8
|
+
export { default as BulkActionBar } from './components/BulkActionBar.vue'
|
|
8
9
|
export { default as EscalatedLayout } from './components/EscalatedLayout.vue'
|
|
9
10
|
export { default as FileDropzone } from './components/FileDropzone.vue'
|
|
11
|
+
export { default as FollowButton } from './components/FollowButton.vue'
|
|
12
|
+
export { default as KeyboardShortcutHelp } from './components/KeyboardShortcutHelp.vue'
|
|
13
|
+
export { default as MacroDropdown } from './components/MacroDropdown.vue'
|
|
14
|
+
export { default as PinnedNotes } from './components/PinnedNotes.vue'
|
|
15
|
+
export { default as PluginSlot } from './components/PluginSlot.vue'
|
|
16
|
+
export { default as PresenceIndicator } from './components/PresenceIndicator.vue'
|
|
10
17
|
export { default as PriorityBadge } from './components/PriorityBadge.vue'
|
|
18
|
+
export { default as QuickFilters } from './components/QuickFilters.vue'
|
|
11
19
|
export { default as ReplyComposer } from './components/ReplyComposer.vue'
|
|
12
20
|
export { default as ReplyThread } from './components/ReplyThread.vue'
|
|
21
|
+
export { default as SatisfactionRating } from './components/SatisfactionRating.vue'
|
|
13
22
|
export { default as SlaTimer } from './components/SlaTimer.vue'
|
|
14
23
|
export { default as StatsCard } from './components/StatsCard.vue'
|
|
15
24
|
export { default as StatusBadge } from './components/StatusBadge.vue'
|
|
@@ -17,3 +26,10 @@ export { default as TagSelect } from './components/TagSelect.vue'
|
|
|
17
26
|
export { default as TicketFilters } from './components/TicketFilters.vue'
|
|
18
27
|
export { default as TicketList } from './components/TicketList.vue'
|
|
19
28
|
export { default as TicketSidebar } from './components/TicketSidebar.vue'
|
|
29
|
+
|
|
30
|
+
// Composables
|
|
31
|
+
export { useKeyboardShortcuts } from './composables/useKeyboardShortcuts'
|
|
32
|
+
export { usePluginExtensions } from './composables/usePluginExtensions'
|
|
33
|
+
|
|
34
|
+
// Plugin authoring helper
|
|
35
|
+
export { defineEscalatedPlugin } from './defineEscalatedPlugin'
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
import { ref, computed, inject } from 'vue';
|
|
3
|
+
import { useForm, usePage } from '@inertiajs/vue3';
|
|
4
|
+
import EscalatedLayout from '../../../components/EscalatedLayout.vue';
|
|
5
|
+
|
|
6
|
+
const props = defineProps({
|
|
7
|
+
tokens: Array,
|
|
8
|
+
users: Array,
|
|
9
|
+
api_enabled: Boolean,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const dark = inject('esc-dark', computed(() => true));
|
|
13
|
+
const page = usePage();
|
|
14
|
+
const prefix = computed(() => {
|
|
15
|
+
const p = page.props.escalated?.prefix || 'support';
|
|
16
|
+
return p.startsWith('/') ? p : `/${p}`;
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
const showCreate = ref(false);
|
|
20
|
+
const copiedToken = ref(null);
|
|
21
|
+
|
|
22
|
+
const form = useForm({
|
|
23
|
+
name: '',
|
|
24
|
+
user_id: '',
|
|
25
|
+
abilities: ['agent'],
|
|
26
|
+
expires_in_days: null,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
function createToken() {
|
|
30
|
+
form.post(`${prefix.value}/admin/api-tokens`, {
|
|
31
|
+
preserveScroll: true,
|
|
32
|
+
onSuccess: (page) => {
|
|
33
|
+
const flash = page.props.flash || {};
|
|
34
|
+
if (flash.plain_text_token) {
|
|
35
|
+
copiedToken.value = flash.plain_text_token;
|
|
36
|
+
}
|
|
37
|
+
form.reset();
|
|
38
|
+
showCreate.value = false;
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function revokeToken(id) {
|
|
44
|
+
if (!confirm('Are you sure you want to revoke this token? This cannot be undone.')) return;
|
|
45
|
+
useForm({}).delete(`${prefix.value}/admin/api-tokens/${id}`, { preserveScroll: true });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function copyToken() {
|
|
49
|
+
if (copiedToken.value) {
|
|
50
|
+
navigator.clipboard.writeText(copiedToken.value);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function dismissToken() {
|
|
55
|
+
copiedToken.value = null;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function toggleAbility(ability) {
|
|
59
|
+
const idx = form.abilities.indexOf(ability);
|
|
60
|
+
if (idx >= 0) {
|
|
61
|
+
form.abilities.splice(idx, 1);
|
|
62
|
+
} else {
|
|
63
|
+
form.abilities.push(ability);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function formatDate(iso) {
|
|
68
|
+
if (!iso) return '—';
|
|
69
|
+
return new Date(iso).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
70
|
+
}
|
|
71
|
+
</script>
|
|
72
|
+
|
|
73
|
+
<template>
|
|
74
|
+
<EscalatedLayout title="API Tokens">
|
|
75
|
+
<div class="space-y-6">
|
|
76
|
+
<!-- Header -->
|
|
77
|
+
<div class="flex items-center justify-between">
|
|
78
|
+
<div>
|
|
79
|
+
<h2 :class="dark ? 'text-white' : 'text-gray-900'" class="text-lg font-semibold">API Tokens</h2>
|
|
80
|
+
<p :class="dark ? 'text-neutral-500' : 'text-gray-500'" class="mt-1 text-sm">Manage API tokens for desktop apps and external integrations.</p>
|
|
81
|
+
</div>
|
|
82
|
+
<button @click="showCreate = true"
|
|
83
|
+
:class="dark ? 'bg-white text-black hover:bg-neutral-200' : 'bg-gray-900 text-white hover:bg-gray-800'"
|
|
84
|
+
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors">
|
|
85
|
+
Create Token
|
|
86
|
+
</button>
|
|
87
|
+
</div>
|
|
88
|
+
|
|
89
|
+
<!-- Not enabled warning -->
|
|
90
|
+
<div v-if="!api_enabled"
|
|
91
|
+
:class="dark ? 'border-amber-500/30 bg-amber-500/10 text-amber-300' : 'border-amber-300 bg-amber-50 text-amber-800'"
|
|
92
|
+
class="rounded-lg border p-4 text-sm">
|
|
93
|
+
The REST API is currently disabled. Set <code class="font-mono">ESCALATED_API_ENABLED=true</code> in your .env to enable it.
|
|
94
|
+
</div>
|
|
95
|
+
|
|
96
|
+
<!-- Copied token modal -->
|
|
97
|
+
<div v-if="copiedToken"
|
|
98
|
+
:class="dark ? 'border-emerald-500/30 bg-emerald-500/10' : 'border-emerald-300 bg-emerald-50'"
|
|
99
|
+
class="rounded-lg border p-4">
|
|
100
|
+
<p :class="dark ? 'text-emerald-300' : 'text-emerald-800'" class="text-sm font-medium">Token created! Copy it now — it won't be shown again.</p>
|
|
101
|
+
<div class="mt-2 flex items-center gap-2">
|
|
102
|
+
<code :class="dark ? 'bg-black text-emerald-400' : 'bg-white text-emerald-700'"
|
|
103
|
+
class="flex-1 rounded border px-3 py-2 text-xs font-mono break-all"
|
|
104
|
+
:style="dark ? 'border-color: rgba(255,255,255,0.1)' : ''">{{ copiedToken }}</code>
|
|
105
|
+
<button @click="copyToken"
|
|
106
|
+
:class="dark ? 'bg-white/10 text-white hover:bg-white/20' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
|
|
107
|
+
class="rounded-lg px-3 py-2 text-sm font-medium transition-colors">Copy</button>
|
|
108
|
+
<button @click="dismissToken"
|
|
109
|
+
:class="dark ? 'text-neutral-500 hover:text-white' : 'text-gray-400 hover:text-gray-600'"
|
|
110
|
+
class="px-2 py-2 text-sm transition-colors">Dismiss</button>
|
|
111
|
+
</div>
|
|
112
|
+
</div>
|
|
113
|
+
|
|
114
|
+
<!-- Create form -->
|
|
115
|
+
<div v-if="showCreate"
|
|
116
|
+
:class="dark ? 'border-white/[0.06] bg-neutral-950' : 'border-gray-200 bg-white'"
|
|
117
|
+
class="rounded-lg border p-5 space-y-4">
|
|
118
|
+
<h3 :class="dark ? 'text-white' : 'text-gray-900'" class="text-sm font-semibold">New API Token</h3>
|
|
119
|
+
|
|
120
|
+
<div class="grid grid-cols-2 gap-4">
|
|
121
|
+
<div>
|
|
122
|
+
<label :class="dark ? 'text-neutral-400' : 'text-gray-600'" class="mb-1 block text-xs font-medium">Token Name</label>
|
|
123
|
+
<input v-model="form.name" type="text" placeholder="e.g. Desktop App - Work Laptop"
|
|
124
|
+
:class="dark ? 'border-white/[0.08] bg-white/[0.04] text-white' : 'border-gray-300 bg-white text-gray-900'"
|
|
125
|
+
class="w-full rounded-lg border px-3 py-2 text-sm" />
|
|
126
|
+
</div>
|
|
127
|
+
<div>
|
|
128
|
+
<label :class="dark ? 'text-neutral-400' : 'text-gray-600'" class="mb-1 block text-xs font-medium">User</label>
|
|
129
|
+
<select v-model="form.user_id"
|
|
130
|
+
:class="dark ? 'border-white/[0.08] bg-white/[0.04] text-white' : 'border-gray-300 bg-white text-gray-900'"
|
|
131
|
+
class="w-full rounded-lg border px-3 py-2 text-sm">
|
|
132
|
+
<option value="">Select user...</option>
|
|
133
|
+
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.name }} ({{ u.email }})</option>
|
|
134
|
+
</select>
|
|
135
|
+
</div>
|
|
136
|
+
</div>
|
|
137
|
+
|
|
138
|
+
<div>
|
|
139
|
+
<label :class="dark ? 'text-neutral-400' : 'text-gray-600'" class="mb-1 block text-xs font-medium">Abilities</label>
|
|
140
|
+
<div class="flex items-center gap-3">
|
|
141
|
+
<label v-for="ability in ['agent', 'admin']" :key="ability"
|
|
142
|
+
:class="dark ? 'text-neutral-300' : 'text-gray-700'"
|
|
143
|
+
class="flex items-center gap-1.5 text-sm cursor-pointer">
|
|
144
|
+
<input type="checkbox" :checked="form.abilities.includes(ability)" @change="toggleAbility(ability)"
|
|
145
|
+
class="rounded border-neutral-600" />
|
|
146
|
+
{{ ability }}
|
|
147
|
+
</label>
|
|
148
|
+
</div>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div>
|
|
152
|
+
<label :class="dark ? 'text-neutral-400' : 'text-gray-600'" class="mb-1 block text-xs font-medium">Expires In (days)</label>
|
|
153
|
+
<input v-model.number="form.expires_in_days" type="number" min="1" max="365" placeholder="Leave empty for no expiration"
|
|
154
|
+
:class="dark ? 'border-white/[0.08] bg-white/[0.04] text-white' : 'border-gray-300 bg-white text-gray-900'"
|
|
155
|
+
class="w-48 rounded-lg border px-3 py-2 text-sm" />
|
|
156
|
+
</div>
|
|
157
|
+
|
|
158
|
+
<div class="flex items-center gap-3 pt-2">
|
|
159
|
+
<button @click="createToken" :disabled="form.processing || !form.name || !form.user_id"
|
|
160
|
+
:class="dark ? 'bg-white text-black hover:bg-neutral-200 disabled:bg-neutral-700 disabled:text-neutral-500' : 'bg-gray-900 text-white hover:bg-gray-800 disabled:bg-gray-300 disabled:text-gray-500'"
|
|
161
|
+
class="rounded-lg px-4 py-2 text-sm font-medium transition-colors">
|
|
162
|
+
{{ form.processing ? 'Creating...' : 'Create Token' }}
|
|
163
|
+
</button>
|
|
164
|
+
<button @click="showCreate = false"
|
|
165
|
+
:class="dark ? 'text-neutral-500 hover:text-white' : 'text-gray-500 hover:text-gray-900'"
|
|
166
|
+
class="text-sm transition-colors">Cancel</button>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
|
|
170
|
+
<!-- Tokens table -->
|
|
171
|
+
<div :class="dark ? 'border-white/[0.06]' : 'border-gray-200'" class="overflow-hidden rounded-lg border">
|
|
172
|
+
<table class="w-full text-sm">
|
|
173
|
+
<thead :class="dark ? 'bg-white/[0.03] text-neutral-500' : 'bg-gray-50 text-gray-500'">
|
|
174
|
+
<tr>
|
|
175
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase">Name</th>
|
|
176
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase">User</th>
|
|
177
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase">Abilities</th>
|
|
178
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase">Last Used</th>
|
|
179
|
+
<th class="px-4 py-3 text-left text-xs font-medium uppercase">Expires</th>
|
|
180
|
+
<th class="px-4 py-3 text-right text-xs font-medium uppercase">Actions</th>
|
|
181
|
+
</tr>
|
|
182
|
+
</thead>
|
|
183
|
+
<tbody :class="dark ? 'divide-white/[0.04]' : 'divide-gray-100'" class="divide-y">
|
|
184
|
+
<tr v-for="token in tokens" :key="token.id"
|
|
185
|
+
:class="[dark ? 'hover:bg-white/[0.02]' : 'hover:bg-gray-50', token.is_expired ? 'opacity-50' : '']">
|
|
186
|
+
<td :class="dark ? 'text-white' : 'text-gray-900'" class="px-4 py-3 font-medium">{{ token.name }}</td>
|
|
187
|
+
<td :class="dark ? 'text-neutral-400' : 'text-gray-600'" class="px-4 py-3">{{ token.user_name }}</td>
|
|
188
|
+
<td class="px-4 py-3">
|
|
189
|
+
<span v-for="a in (token.abilities || [])" :key="a"
|
|
190
|
+
:class="dark ? 'bg-white/[0.06] text-neutral-300' : 'bg-gray-100 text-gray-700'"
|
|
191
|
+
class="mr-1 rounded px-1.5 py-0.5 text-xs">{{ a }}</span>
|
|
192
|
+
</td>
|
|
193
|
+
<td :class="dark ? 'text-neutral-500' : 'text-gray-500'" class="px-4 py-3 text-xs">
|
|
194
|
+
<template v-if="token.last_used_at">{{ formatDate(token.last_used_at) }}</template>
|
|
195
|
+
<template v-else>Never</template>
|
|
196
|
+
</td>
|
|
197
|
+
<td :class="dark ? 'text-neutral-500' : 'text-gray-500'" class="px-4 py-3 text-xs">
|
|
198
|
+
<span v-if="token.is_expired" class="text-rose-400">Expired</span>
|
|
199
|
+
<template v-else-if="token.expires_at">{{ formatDate(token.expires_at) }}</template>
|
|
200
|
+
<template v-else>Never</template>
|
|
201
|
+
</td>
|
|
202
|
+
<td class="px-4 py-3 text-right">
|
|
203
|
+
<button @click="revokeToken(token.id)"
|
|
204
|
+
class="text-xs text-rose-400 hover:text-rose-300 transition-colors">Revoke</button>
|
|
205
|
+
</td>
|
|
206
|
+
</tr>
|
|
207
|
+
<tr v-if="!tokens.length">
|
|
208
|
+
<td colspan="6" :class="dark ? 'text-neutral-500' : 'text-gray-400'" class="px-4 py-8 text-center text-sm">
|
|
209
|
+
No API tokens created yet.
|
|
210
|
+
</td>
|
|
211
|
+
</tr>
|
|
212
|
+
</tbody>
|
|
213
|
+
</table>
|
|
214
|
+
</div>
|
|
215
|
+
</div>
|
|
216
|
+
</EscalatedLayout>
|
|
217
|
+
</template>
|