Initial commit: TicketingSystem
Build & Push / Build Client (push) Failing after 9s
Build & Push / Build Server (push) Failing after 28s

Internal ticketing app with CTI routing, severity levels, and n8n integration.

Stack: Express + TypeScript + Prisma + PostgreSQL / React + Vite + Tailwind
- JWT auth for users, API key auth for service accounts (Goddard/n8n)
- CTI hierarchy (Category > Type > Item) for ticket routing
- Severity 1-5, auto-close resolved tickets after 14 days
- Gitea Actions CI/CD building separate server/client images
- Production docker-compose.yml with Traefik integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 19:38:32 -04:00
commit 21894fad7a
50 changed files with 3293 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, ChevronRight } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { Category, CTIType, Item } from '../../types'
type PanelItem = { id: string; name: string }
interface NameModalState {
open: boolean
mode: 'add' | 'edit'
panel: 'category' | 'type' | 'item'
item?: PanelItem
}
export default function AdminCTI() {
const [categories, setCategories] = useState<Category[]>([])
const [types, setTypes] = useState<CTIType[]>([])
const [items, setItems] = useState<Item[]>([])
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null)
const [selectedType, setSelectedType] = useState<CTIType | null>(null)
const [nameModal, setNameModal] = useState<NameModalState>({ open: false, mode: 'add', panel: 'category' })
const [nameValue, setNameValue] = useState('')
const [submitting, setSubmitting] = useState(false)
const fetchCategories = () =>
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data))
const fetchTypes = (categoryId: string) =>
api
.get<CTIType[]>('/cti/types', { params: { categoryId } })
.then((r) => setTypes(r.data))
const fetchItems = (typeId: string) =>
api.get<Item[]>('/cti/items', { params: { typeId } }).then((r) => setItems(r.data))
useEffect(() => { fetchCategories() }, [])
const selectCategory = (cat: Category) => {
setSelectedCategory(cat)
setSelectedType(null)
setItems([])
fetchTypes(cat.id)
}
const selectType = (type: CTIType) => {
setSelectedType(type)
fetchItems(type.id)
}
const openAdd = (panel: 'category' | 'type' | 'item') => {
setNameValue('')
setNameModal({ open: true, mode: 'add', panel })
}
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
setNameValue(item.name)
setNameModal({ open: true, mode: 'edit', panel, item })
}
const closeModal = () => setNameModal((m) => ({ ...m, open: false }))
const handleSave = async (e: React.FormEvent) => {
e.preventDefault()
if (!nameValue.trim()) return
setSubmitting(true)
try {
const { mode, panel, item } = nameModal
if (mode === 'add') {
if (panel === 'category') {
await api.post('/cti/categories', { name: nameValue.trim() })
await fetchCategories()
} else if (panel === 'type' && selectedCategory) {
await api.post('/cti/types', { name: nameValue.trim(), categoryId: selectedCategory.id })
await fetchTypes(selectedCategory.id)
} else if (panel === 'item' && selectedType) {
await api.post('/cti/items', { name: nameValue.trim(), typeId: selectedType.id })
await fetchItems(selectedType.id)
}
} else {
if (!item) return
if (panel === 'category') {
await api.put(`/cti/categories/${item.id}`, { name: nameValue.trim() })
await fetchCategories()
if (selectedCategory?.id === item.id) setSelectedCategory((c) => c ? { ...c, name: nameValue.trim() } : c)
} else if (panel === 'type') {
await api.put(`/cti/types/${item.id}`, { name: nameValue.trim() })
if (selectedCategory) await fetchTypes(selectedCategory.id)
if (selectedType?.id === item.id) setSelectedType((t) => t ? { ...t, name: nameValue.trim() } : t)
} else if (panel === 'item') {
await api.put(`/cti/items/${item.id}`, { name: nameValue.trim() })
if (selectedType) await fetchItems(selectedType.id)
}
}
closeModal()
} finally {
setSubmitting(false)
}
}
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
if (!confirm(`Delete "${item.name}"? This will also delete all child records.`)) return
if (panel === 'category') {
await api.delete(`/cti/categories/${item.id}`)
if (selectedCategory?.id === item.id) {
setSelectedCategory(null)
setSelectedType(null)
setTypes([])
setItems([])
}
await fetchCategories()
} else if (panel === 'type') {
await api.delete(`/cti/types/${item.id}`)
if (selectedType?.id === item.id) {
setSelectedType(null)
setItems([])
}
if (selectedCategory) await fetchTypes(selectedCategory.id)
} else {
await api.delete(`/cti/items/${item.id}`)
if (selectedType) await fetchItems(selectedType.id)
}
}
const panelClass = 'bg-white border border-gray-200 rounded-xl overflow-hidden flex flex-col'
const panelHeaderClass = 'flex items-center justify-between px-4 py-3 border-b border-gray-100 bg-gray-50'
const itemClass = (active: boolean) =>
`flex items-center justify-between px-4 py-2.5 cursor-pointer group transition-colors ${
active ? 'bg-blue-50 border-l-2 border-blue-500' : 'hover:bg-gray-50 border-l-2 border-transparent'
}`
return (
<Layout title="CTI Configuration">
<div className="grid grid-cols-3 gap-4 h-[calc(100vh-10rem)]">
{/* Categories */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">Categories</h3>
<button
onClick={() => openAdd('category')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
</div>
<div className="flex-1 overflow-auto">
{categories.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No categories</p>
) : (
categories.map((cat) => (
<div
key={cat.id}
className={itemClass(selectedCategory?.id === cat.id)}
onClick={() => selectCategory(cat)}
>
<span className="text-sm text-gray-800">{cat.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('category', cat) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
</div>
</div>
))
)}
</div>
</div>
{/* Types */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
Types
{selectedCategory && (
<span className="ml-1 font-normal text-gray-400"> {selectedCategory.name}</span>
)}
</h3>
{selectedCategory && (
<button
onClick={() => openAdd('type')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-auto">
{!selectedCategory ? (
<p className="text-xs text-gray-400 text-center py-8">Select a category</p>
) : types.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No types</p>
) : (
types.map((type) => (
<div
key={type.id}
className={itemClass(selectedType?.id === type.id)}
onClick={() => selectType(type)}
>
<span className="text-sm text-gray-800">{type.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('type', type) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
<ChevronRight size={14} className="text-gray-300" />
</div>
</div>
))
)}
</div>
</div>
{/* Items */}
<div className={panelClass}>
<div className={panelHeaderClass}>
<h3 className="text-sm font-semibold text-gray-700">
Items
{selectedType && (
<span className="ml-1 font-normal text-gray-400"> {selectedType.name}</span>
)}
</h3>
{selectedType && (
<button
onClick={() => openAdd('item')}
className="text-blue-600 hover:text-blue-800 transition-colors"
>
<Plus size={16} />
</button>
)}
</div>
<div className="flex-1 overflow-auto">
{!selectedType ? (
<p className="text-xs text-gray-400 text-center py-8">Select a type</p>
) : items.length === 0 ? (
<p className="text-xs text-gray-400 text-center py-8">No items</p>
) : (
items.map((item) => (
<div key={item.id} className={itemClass(false)} onClick={() => {}}>
<span className="text-sm text-gray-800">{item.name}</span>
<div className="flex items-center gap-1">
<button
onClick={(e) => { e.stopPropagation(); openEdit('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-gray-700 transition-all"
>
<Pencil size={13} />
</button>
<button
onClick={(e) => { e.stopPropagation(); handleDelete('item', item) }}
className="opacity-0 group-hover:opacity-100 text-gray-400 hover:text-red-600 transition-all"
>
<Trash2 size={13} />
</button>
</div>
</div>
))
)}
</div>
</div>
</div>
{/* Name modal */}
{nameModal.open && (
<Modal
title={`${nameModal.mode === 'add' ? 'Add' : 'Rename'} ${nameModal.panel.charAt(0).toUpperCase() + nameModal.panel.slice(1)}`}
onClose={closeModal}
>
<form onSubmit={handleSave} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Name</label>
<input
type="text"
value={nameValue}
onChange={(e) => setNameValue(e.target.value)}
required
autoFocus
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="flex justify-end gap-3">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : 'Save'}
</button>
</div>
</form>
</Modal>
)}
</Layout>
)
}
+346
View File
@@ -0,0 +1,346 @@
import { useState, useEffect } from 'react'
import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react'
import api from '../../api/client'
import Layout from '../../components/Layout'
import Modal from '../../components/Modal'
import { User, Role } from '../../types'
import { useAuth } from '../../contexts/AuthContext'
interface UserForm {
username: string
email: string
displayName: string
password: string
role: Role
}
const BLANK_FORM: UserForm = {
username: '',
email: '',
displayName: '',
password: '',
role: 'AGENT',
}
const ROLE_LABELS: Record<Role, string> = {
ADMIN: 'Admin',
AGENT: 'Agent',
SERVICE: 'Service',
}
export default function AdminUsers() {
const { user: authUser } = useAuth()
const [users, setUsers] = useState<User[]>([])
const [modal, setModal] = useState<'add' | 'edit' | null>(null)
const [selected, setSelected] = useState<User | null>(null)
const [form, setForm] = useState<UserForm>(BLANK_FORM)
const [submitting, setSubmitting] = useState(false)
const [error, setError] = useState('')
const [copiedKey, setCopiedKey] = useState<string | null>(null)
const [newApiKey, setNewApiKey] = useState<string | null>(null)
const fetchUsers = () => {
api.get<User[]>('/users').then((r) => setUsers(r.data))
}
useEffect(() => { fetchUsers() }, [])
const openAdd = () => {
setForm(BLANK_FORM)
setError('')
setNewApiKey(null)
setModal('add')
}
const openEdit = (u: User) => {
setSelected(u)
setForm({ username: u.username, email: u.email, displayName: u.displayName, password: '', role: u.role })
setError('')
setNewApiKey(null)
setModal('edit')
}
const closeModal = () => {
setModal(null)
setSelected(null)
setNewApiKey(null)
fetchUsers()
}
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault()
setSubmitting(true)
setError('')
try {
const payload: Record<string, string> = {
username: form.username,
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
const res = await api.post<User>('/users', payload)
if (res.data.apiKey) setNewApiKey(res.data.apiKey)
else closeModal()
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to create user')
} finally {
setSubmitting(false)
}
}
const handleEdit = async (e: React.FormEvent) => {
e.preventDefault()
if (!selected) return
setSubmitting(true)
setError('')
try {
const payload: Record<string, string> = {
email: form.email,
displayName: form.displayName,
role: form.role,
}
if (form.password) payload.password = form.password
await api.patch(`/users/${selected.id}`, payload)
closeModal()
} catch (err: unknown) {
const e = err as { response?: { data?: { error?: string } } }
setError(e.response?.data?.error ?? 'Failed to update user')
} finally {
setSubmitting(false)
}
}
const handleDelete = async (u: User) => {
if (!confirm(`Delete user "${u.displayName}"?`)) return
await api.delete(`/users/${u.id}`)
fetchUsers()
}
const handleRegenerateKey = async (u: User) => {
if (!confirm(`Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`)) return
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true })
setNewApiKey(res.data.apiKey ?? null)
setSelected(u)
setModal('edit')
}
const copyToClipboard = (key: string) => {
navigator.clipboard.writeText(key)
setCopiedKey(key)
setTimeout(() => setCopiedKey(null), 2000)
}
const inputClass =
'w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500'
const labelClass = 'block text-sm font-medium text-gray-700 mb-1'
return (
<Layout
title="Users"
action={
<button
onClick={openAdd}
className="flex items-center gap-2 bg-blue-600 text-white px-4 py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
<Plus size={14} />
Add User
</button>
}
>
<div className="bg-white border border-gray-200 rounded-xl overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200">
<tr>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
User
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Username
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Role
</th>
<th className="text-left px-5 py-3 text-xs font-semibold text-gray-500 uppercase tracking-wide">
Email
</th>
<th className="px-5 py-3" />
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{users.map((u) => (
<tr key={u.id} className="hover:bg-gray-50">
<td className="px-5 py-3 font-medium text-gray-900">{u.displayName}</td>
<td className="px-5 py-3 text-gray-500 font-mono text-xs">{u.username}</td>
<td className="px-5 py-3">
<span
className={`inline-flex px-2 py-0.5 rounded text-xs font-medium ${
u.role === 'ADMIN'
? 'bg-purple-100 text-purple-700'
: u.role === 'SERVICE'
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-600'
}`}
>
{ROLE_LABELS[u.role]}
</span>
</td>
<td className="px-5 py-3 text-gray-500">{u.email}</td>
<td className="px-5 py-3">
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
title="Regenerate API key"
>
<RefreshCw size={14} />
</button>
)}
<button
onClick={() => openEdit(u)}
className="text-gray-400 hover:text-gray-700 transition-colors"
>
<Pencil size={14} />
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
className="text-gray-400 hover:text-red-600 transition-colors"
>
<Trash2 size={14} />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Add / Edit Modal */}
{modal && (
<Modal
title={modal === 'add' ? 'Add User' : `Edit ${selected?.displayName}`}
onClose={closeModal}
>
{newApiKey ? (
<div className="space-y-4">
<div className="bg-amber-50 border border-amber-200 rounded-lg p-4">
<p className="text-sm font-medium text-amber-800 mb-2">
API Key copy it now, it won't be shown again
</p>
<div className="flex items-center gap-2">
<code className="flex-1 text-xs bg-white border border-amber-200 rounded px-3 py-2 font-mono break-all">
{newApiKey}
</code>
<button
onClick={() => copyToClipboard(newApiKey)}
className="flex-shrink-0 text-amber-700 hover:text-amber-900 transition-colors"
>
{copiedKey === newApiKey ? <Check size={16} /> : <Copy size={16} />}
</button>
</div>
</div>
<button
onClick={closeModal}
className="w-full bg-blue-600 text-white py-2 rounded-lg text-sm hover:bg-blue-700 transition-colors"
>
Done
</button>
</div>
) : (
<form onSubmit={modal === 'add' ? handleAdd : handleEdit} className="space-y-4">
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 text-sm px-3 py-2 rounded-lg">
{error}
</div>
)}
{modal === 'add' && (
<div>
<label className={labelClass}>Username</label>
<input
type="text"
value={form.username}
onChange={(e) => setForm((f) => ({ ...f, username: e.target.value }))}
required
className={inputClass}
/>
</div>
)}
<div>
<label className={labelClass}>Display Name</label>
<input
type="text"
value={form.displayName}
onChange={(e) => setForm((f) => ({ ...f, displayName: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>Email</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm((f) => ({ ...f, email: e.target.value }))}
required
className={inputClass}
/>
</div>
<div>
<label className={labelClass}>
Password {modal === 'edit' && <span className="text-gray-400 font-normal">(leave blank to keep current)</span>}
</label>
<input
type="password"
value={form.password}
onChange={(e) => setForm((f) => ({ ...f, password: e.target.value }))}
required={modal === 'add' && form.role !== 'SERVICE'}
className={inputClass}
placeholder={modal === 'edit' ? '' : ''}
/>
</div>
<div>
<label className={labelClass}>Role</label>
<select
value={form.role}
onChange={(e) => setForm((f) => ({ ...f, role: e.target.value as Role }))}
className={inputClass}
>
<option value="AGENT">Agent</option>
<option value="ADMIN">Admin</option>
<option value="SERVICE">Service (API key auth)</option>
</select>
</div>
<div className="flex justify-end gap-3 pt-1">
<button
type="button"
onClick={closeModal}
className="px-4 py-2 text-sm text-gray-600 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 text-sm bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
{submitting ? 'Saving...' : modal === 'add' ? 'Create User' : 'Save Changes'}
</button>
</div>
</form>
)}
</Modal>
)}
</Layout>
)
}