diff --git a/client/src/App.tsx b/client/src/App.tsx index 40ad1ef..8dd7f5b 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -4,10 +4,14 @@ import PrivateRoute from './components/PrivateRoute'; import AdminRoute from './components/AdminRoute'; import Login from './pages/Login'; import Dashboard from './pages/Dashboard'; +import Tickets from './pages/Tickets'; import MyTickets from './pages/MyTickets'; import TicketDetail from './pages/TicketDetail'; +import Notifications from './pages/Notifications'; +import Settings from './pages/Settings'; import AdminUsers from './pages/admin/Users'; import AdminCTI from './pages/admin/CTI'; +import AdminWebhooks from './pages/admin/Webhooks'; export default function App() { return ( @@ -16,12 +20,17 @@ export default function App() { } /> }> - } /> + } /> + } /> + } /> } /> + } /> + } /> } /> }> } /> } /> + } /> } /> diff --git a/client/src/api/queries.ts b/client/src/api/queries.ts index e237324..cd4a4e6 100644 --- a/client/src/api/queries.ts +++ b/client/src/api/queries.ts @@ -1,18 +1,30 @@ import { useQuery, useMutation, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import api from './client'; import { Ticket, Category, CTIType, Item, User, AuditLog, Comment } from '../types'; +import type { + Notification, + SavedView, + Webhook, + PaginatedResponse, +} from '../../../shared/types'; // ── Keys ───────────────────────────────────────────────────────────────────── export const qk = { tickets: (filters?: Record) => ['tickets', filters ?? {}] as const, + ticketsPaged: (filters?: Record) => + ['tickets-paged', filters ?? {}] as const, ticket: (id: string) => ['ticket', id] as const, ticketAudit: (id: string) => ['ticket', id, 'audit'] as const, categories: () => ['cti', 'categories'] as const, types: (categoryId?: string) => ['cti', 'types', categoryId ?? null] as const, items: (typeId?: string) => ['cti', 'items', typeId ?? null] as const, users: () => ['users'] as const, + notifications: (unread?: boolean) => ['notifications', { unread: !!unread }] as const, + webhooks: () => ['webhooks'] as const, + savedViews: () => ['saved-views'] as const, + analytics: (window: number) => ['analytics', window] as const, }; // ── Tickets ────────────────────────────────────────────────────────────────── @@ -34,6 +46,34 @@ export function useTickets(params: Record = }); } +export function useTicketsPaged(params: Record = {}) { + const clean: Record = {}; + Object.entries(params).forEach(([k, v]) => { + if (v !== undefined && v !== '' && v !== null) clean[k] = v; + }); + + return useQuery({ + queryKey: qk.ticketsPaged(clean), + queryFn: async () => { + const res = await api.get>('/tickets', { params: clean }); + return res.data; + }, + placeholderData: keepPreviousData, + }); +} + +export function useBulkTickets() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: { ids: string[]; action: string; value?: unknown }) => + (await api.post('/tickets/bulk', data)).data, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['tickets'] }); + qc.invalidateQueries({ queryKey: ['tickets-paged'] }); + }, + }); +} + export function useTicket(id: string | undefined) { return useQuery({ queryKey: qk.ticket(id ?? ''), @@ -246,3 +286,158 @@ export function useDeleteUser() { onSuccess: () => qc.invalidateQueries({ queryKey: qk.users() }), }); } + +// ── Notifications ──────────────────────────────────────────────────────────── + +export function useNotifications(unread = false) { + return useQuery({ + queryKey: qk.notifications(unread), + queryFn: async () => + ( + await api.get('/notifications', { + params: unread ? { unread: 'true' } : {}, + }) + ).data, + refetchInterval: 60_000, + }); +} + +export function useMarkNotificationsRead() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: { ids?: string[]; all?: boolean }) => + (await api.post<{ updated: number }>('/notifications/read', data)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications'] }), + }); +} + +export function useUnreadCount() { + return useQuery({ + queryKey: ['notifications', 'unread-count'], + queryFn: async () => + (await api.get<{ count: number }>('/notifications/unread-count')).data.count, + refetchInterval: 60_000, + }); +} + +// ── Webhooks ───────────────────────────────────────────────────────────────── + +export function useWebhooks() { + return useQuery({ + queryKey: qk.webhooks(), + queryFn: async () => (await api.get('/webhooks')).data, + }); +} + +export function useCreateWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: { name: string; url: string; events: string[] }) => + (await api.post('/webhooks', data)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }), + }); +} + +export function useUpdateWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async ({ id, data }: { id: string; data: Record }) => + (await api.patch(`/webhooks/${id}`, data)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }), + }); +} + +export function useDeleteWebhook() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/webhooks/${id}`); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }), + }); +} + +export function useRotateWebhookSecret() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => + (await api.post(`/webhooks/${id}/rotate-secret`)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.webhooks() }), + }); +} + +// ── Saved Views ────────────────────────────────────────────────────────────── + +export function useSavedViews() { + return useQuery({ + queryKey: qk.savedViews(), + queryFn: async () => (await api.get('/saved-views')).data, + staleTime: 60_000, + }); +} + +export function useCreateSavedView() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (data: { name: string; filters: Record }) => + (await api.post('/saved-views', data)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }), + }); +} + +export function useDeleteSavedView() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (id: string) => { + await api.delete(`/saved-views/${id}`); + }, + onSuccess: () => qc.invalidateQueries({ queryKey: qk.savedViews() }), + }); +} + +// ── Analytics ──────────────────────────────────────────────────────────────── + +export interface AnalyticsSummary { + windowDays: number; + openBySeverity: { severity: number; count: number }[]; + statusCounts: { status: string; count: number }[]; + queueByAssignee: { assigneeId: string | null; count: number }[]; + ageBuckets: Record<'d1' | 'd7' | 'd14' | 'older', number>; + medianResolutionHours: number | null; +} + +export function useAnalytics(windowDays = 30) { + return useQuery({ + queryKey: qk.analytics(windowDays), + queryFn: async () => + ( + await api.get('/analytics/summary', { + params: { window: windowDays }, + }) + ).data, + staleTime: 60_000, + }); +} + +// ── Notification prefs ─────────────────────────────────────────────────────── + +export interface NotificationPrefs { + email: { assignment: boolean; mention: boolean; resolved: boolean }; + inApp: { assignment: boolean; mention: boolean; resolved: boolean }; +} + +export function useNotificationPrefs() { + return useQuery({ + queryKey: ['notifications', 'prefs'], + queryFn: async () => (await api.get('/notifications/prefs')).data, + }); +} + +export function useUpdateNotificationPrefs() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: async (prefs: NotificationPrefs) => + (await api.put('/notifications/prefs', prefs)).data, + onSuccess: () => qc.invalidateQueries({ queryKey: ['notifications', 'prefs'] }), + }); +} diff --git a/client/src/components/GlobalSearch.tsx b/client/src/components/GlobalSearch.tsx new file mode 100644 index 0000000..e2c0436 --- /dev/null +++ b/client/src/components/GlobalSearch.tsx @@ -0,0 +1,104 @@ +import { useEffect, useRef, useState } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { Search } from 'lucide-react'; +import api from '../api/client'; +import type { Ticket } from '../types'; + +export default function GlobalSearch() { + const navigate = useNavigate(); + const [q, setQ] = useState(''); + const [open, setOpen] = useState(false); + const [results, setResults] = useState([]); + const [loading, setLoading] = useState(false); + const inputRef = useRef(null); + const wrapperRef = useRef(null); + + useEffect(() => { + const handler = (e: MouseEvent) => { + if (wrapperRef.current && !wrapperRef.current.contains(e.target as Node)) { + setOpen(false); + } + }; + document.addEventListener('mousedown', handler); + return () => document.removeEventListener('mousedown', handler); + }, []); + + useEffect(() => { + if (!q.trim()) { + setResults([]); + return; + } + setLoading(true); + const t = setTimeout(async () => { + try { + const res = await api.get<{ tickets: Ticket[] }>('/search', { + params: { q, limit: 8 }, + }); + setResults(res.data.tickets ?? []); + } catch { + setResults([]); + } finally { + setLoading(false); + } + }, 200); + return () => clearTimeout(t); + }, [q]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (!q.trim()) return; + setOpen(false); + navigate(`/tickets?search=${encodeURIComponent(q)}`); + }; + + return ( +
+
+ + setQ(e.target.value)} + onFocus={() => setOpen(true)} + placeholder="Search tickets" + className="w-full pl-9 pr-3 py-1.5 rounded-md text-sm bg-background border border-input text-foreground placeholder-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring" + /> + + + {open && q.trim() && ( +
+ {loading ? ( +

Searching…

+ ) : results.length === 0 ? ( +

No matches

+ ) : ( +
    + {results.map((t) => ( +
  • + +
  • + ))} +
+ )} +
+ )} +
+ ); +} diff --git a/client/src/components/Layout.tsx b/client/src/components/Layout.tsx index 1729d6b..48a616e 100644 --- a/client/src/components/Layout.tsx +++ b/client/src/components/Layout.tsx @@ -1,98 +1,219 @@ import { ReactNode, useState } from 'react'; -import { Link, useLocation, useNavigate } from 'react-router-dom'; -import { LayoutDashboard, Users, Settings, LogOut, Plus, Ticket } from 'lucide-react'; +import { Link, NavLink, useNavigate } from 'react-router-dom'; +import { + Plus, + LogOut, + Settings as SettingsIcon, + Users as UsersIcon, + Webhook, + Menu, + X, + SlidersHorizontal, +} from 'lucide-react'; import { useAuth } from '../contexts/AuthContext'; import NewTicketModal from '../pages/NewTicket'; +import NotificationsBell from './NotificationsBell'; +import GlobalSearch from './GlobalSearch'; +import Avatar from './Avatar'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; interface LayoutProps { children: ReactNode; title?: string; action?: ReactNode; + subheader?: ReactNode; + wide?: boolean; } -export default function Layout({ children, title, action }: LayoutProps) { +const navBase = + 'px-3 py-1.5 rounded-md text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground'; + +export default function Layout({ children, title, action, subheader, wide }: LayoutProps) { const { user, logout } = useAuth(); - const location = useLocation(); const navigate = useNavigate(); const [showNewTicket, setShowNewTicket] = useState(false); + const [mobileOpen, setMobileOpen] = useState(false); const canCreateTicket = user?.role !== 'USER'; - - const navItems = [ - { to: '/', icon: LayoutDashboard, label: 'All Tickets' }, - { to: '/my-tickets', icon: Ticket, label: 'My Tickets' }, - ...(user?.role === 'ADMIN' - ? [ - { to: '/admin/users', icon: Users, label: 'Users' }, - { to: '/admin/cti', icon: Settings, label: 'CTI Config' }, - ] - : []), - ]; + const isAdmin = user?.role === 'ADMIN'; const handleLogout = () => { logout(); navigate('/login'); }; - const isActive = (to: string) => - to === '/' ? location.pathname === '/' : location.pathname.startsWith(to); + const primaryNav = ( + <> + + `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}` + } + > + Dashboard + + + `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}` + } + > + Tickets + + + `${navBase} ${isActive ? 'bg-accent text-accent-foreground' : 'text-muted-foreground'}` + } + > + My tickets + + + ); return ( -
- {/* Sidebar */} - + + T + + Ticketing + - {/* Main */} -
- {(title || action) && ( -
- {title &&

{title}

} - {action &&
{action}
} -
+ + +
+ +
+ +
+ {canCreateTicket && ( + + )} + + + + + + + +
+

{user?.displayName}

+

{user?.email}

+
+ + + + + Settings + + + {isAdmin && ( + <> + + + + + Users + + + + + + CTI config + + + + + + Webhooks + + + + )} + + + + Log out + +
+
+ + +
+
+ + {mobileOpen && ( +
+
{primaryNav}
+ + {canCreateTicket && ( + + )} +
)} -
{children}
-
+ + + {/* Optional sub-header */} + {(title || action || subheader) && ( +
+
+
+ {title && ( +

{title}

+ )} + {subheader} +
+ {action &&
{action}
} +
+
+ )} + +
+ {children} +
{showNewTicket && setShowNewTicket(false)} />} diff --git a/client/src/components/NotificationsBell.tsx b/client/src/components/NotificationsBell.tsx new file mode 100644 index 0000000..0a2668f --- /dev/null +++ b/client/src/components/NotificationsBell.tsx @@ -0,0 +1,113 @@ +import { useMemo } from 'react'; +import { Link } from 'react-router-dom'; +import { Bell } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + useNotifications, + useUnreadCount, + useMarkNotificationsRead, +} from '../api/queries'; +import type { Notification } from '../../../shared/types'; + +const KIND_LABELS: Record = { + ASSIGNED: 'assigned to you', + MENTION: 'mentioned you', + RESOLVED: 'was resolved', + TICKET_CREATED: 'was created', + STATUS_CHANGED: 'status changed', + COMMENT: 'new comment', +}; + +interface NotifData { + displayId?: string; + title?: string; + byName?: string; +} + +function renderSummary(n: Notification): { label: string; href: string } { + const data = (n.data ?? {}) as NotifData; + const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase(); + const label = data.title + ? `${data.title} — ${action}` + : `Ticket ${action}`; + const href = data.displayId ? `/${data.displayId}` : '/notifications'; + return { label, href }; +} + +export default function NotificationsBell() { + const { data: unreadCount = 0 } = useUnreadCount(); + const { data: notifications = [] } = useNotifications(); + const markRead = useMarkNotificationsRead(); + + const latest = useMemo(() => notifications.slice(0, 8), [notifications]); + + return ( + + + + + +
+ Notifications + {unreadCount > 0 && ( + + )} +
+
+ {latest.length === 0 ? ( +

+ You're all caught up +

+ ) : ( + latest.map((n) => { + const { label, href } = renderSummary(n); + const unread = !n.readAt; + return ( + unread && markRead.mutate({ ids: [n.id] })} + className={`flex flex-col gap-1 px-3 py-2 text-sm border-b last:border-b-0 hover:bg-accent transition-colors ${ + unread ? 'bg-primary/5' : '' + }`} + > + {label} + + {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })} + + + ); + }) + )} +
+ + View all notifications + +
+
+ ); +} diff --git a/client/src/contexts/ThemeContext.tsx b/client/src/contexts/ThemeContext.tsx new file mode 100644 index 0000000..ac64728 --- /dev/null +++ b/client/src/contexts/ThemeContext.tsx @@ -0,0 +1,36 @@ +import { createContext, useContext, useEffect, useState, ReactNode } from 'react'; + +type Theme = 'light' | 'dark'; + +interface ThemeContextType { + theme: Theme; + toggle: () => void; +} + +const ThemeContext = createContext(null!); + +function initial(): Theme { + // v1.0 ships dark-only; light-mode pages migrate in a future pass. + // Theme context is kept so the toggle can be re-enabled later without wiring. + return 'dark'; +} + +export function ThemeProvider({ children }: { children: ReactNode }) { + const [theme, setTheme] = useState(initial); + + useEffect(() => { + const root = document.documentElement; + root.classList.toggle('dark', theme === 'dark'); + localStorage.setItem('theme', theme); + }, [theme]); + + return ( + setTheme((t) => (t === 'dark' ? 'light' : 'dark')) }} + > + {children} + + ); +} + +export const useTheme = () => useContext(ThemeContext); diff --git a/client/src/main.tsx b/client/src/main.tsx index 11a730b..26d5e29 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -2,13 +2,18 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; import { QueryClientProvider } from '@tanstack/react-query'; import { queryClient } from './api/queryClient'; +import { ThemeProvider } from './contexts/ThemeContext'; +import { Toaster } from '@/components/ui/sonner'; import './index.css'; import App from './App'; createRoot(document.getElementById('root')!).render( - - - + + + + + + , ); diff --git a/client/src/pages/Dashboard.tsx b/client/src/pages/Dashboard.tsx index 106c117..e033f07 100644 --- a/client/src/pages/Dashboard.tsx +++ b/client/src/pages/Dashboard.tsx @@ -1,306 +1,191 @@ -import { useState, useEffect } from 'react'; +import { useMemo } from 'react'; import { Link } from 'react-router-dom'; -import { Search, ChevronRight, X } from 'lucide-react'; -import { formatDistanceToNow } from 'date-fns'; +import { AlertTriangle, Clock, TrendingUp, Users } from 'lucide-react'; import Layout from '../components/Layout'; -import SeverityBadge from '../components/SeverityBadge'; -import StatusBadge from '../components/StatusBadge'; -import Avatar from '../components/Avatar'; -import { TicketStatus, Category, CTIType, Item } from '../types'; -import { useTickets, useCategories, useTypes, useItems } from '../api/queries'; +import { useAnalytics, useUsers } from '../api/queries'; -const STATUSES: { value: TicketStatus | ''; label: string }[] = [ - { value: '', label: 'All Statuses' }, - { value: 'OPEN', label: 'Open' }, - { value: 'IN_PROGRESS', label: 'In Progress' }, - { value: 'RESOLVED', label: 'Resolved' }, - { value: 'CLOSED', label: 'Closed' }, -]; +const SEV_NAMES: Record = { + 1: 'SEV 1 — Critical', + 2: 'SEV 2 — High', + 3: 'SEV 3 — Medium', + 4: 'SEV 4 — Low', + 5: 'SEV 5 — Minimal', +}; -const selectClass = - 'bg-gray-800 border border-gray-700 text-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent'; +const SEV_COLORS: Record = { + 1: 'bg-red-500', + 2: 'bg-orange-400', + 3: 'bg-yellow-400', + 4: 'bg-blue-400', + 5: 'bg-gray-500', +}; -function queueLabel(category: Category | null, type: CTIType | null, item: Item | null): string { - if (item && type && category) return `${category.name} › ${type.name} › ${item.name}`; - if (type && category) return `${category.name} › ${type.name}`; - if (category) return category.name; - return ''; +function fmtHours(hours: number | null) { + if (hours == null) return '—'; + if (hours < 1) return `${Math.round(hours * 60)} min`; + if (hours < 48) return `${hours.toFixed(1)} h`; + return `${(hours / 24).toFixed(1)} d`; } export default function Dashboard() { - const [search, setSearch] = useState(''); - const [debouncedSearch, setDebouncedSearch] = useState(''); - const [status, setStatus] = useState(''); - const [severity, setSeverity] = useState(''); + const { data: a } = useAnalytics(30); + const { data: users = [] } = useUsers(); - const [selectedCategory, setSelectedCategory] = useState(null); - const [selectedType, setSelectedType] = useState(null); - const [selectedItem, setSelectedItem] = useState(null); - const [showQueueFilter, setShowQueueFilter] = useState(false); + const userById = useMemo( + () => new Map(users.map((u) => [u.id, u.displayName])), + [users], + ); - const { data: categories = [] } = useCategories(); - const { data: types = [] } = useTypes(selectedCategory?.id); - const { data: items = [] } = useItems(selectedType?.id); - - useEffect(() => { - const t = setTimeout(() => setDebouncedSearch(search), 300); - return () => clearTimeout(t); - }, [search]); - - const ticketParams: Record = {}; - if (selectedItem) ticketParams.itemId = selectedItem.id; - else if (selectedType) ticketParams.typeId = selectedType.id; - else if (selectedCategory) ticketParams.categoryId = selectedCategory.id; - if (status) ticketParams.status = status; - if (severity) ticketParams.severity = severity; - if (debouncedSearch) ticketParams.search = debouncedSearch; - - const { data: tickets = [], isLoading } = useTickets(ticketParams); - - const handleCategorySelect = (cat: Category) => { - setSelectedCategory(cat); - setSelectedType(null); - setSelectedItem(null); - }; - - const handleTypeSelect = (type: CTIType) => { - setSelectedType(type); - setSelectedItem(null); - }; - - const handleItemSelect = (item: Item) => { - setSelectedItem(item); - setShowQueueFilter(false); - }; - - const clearQueue = () => { - setSelectedCategory(null); - setSelectedType(null); - setSelectedItem(null); - }; - - const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem); + const totalOpen = + a?.openBySeverity.reduce((sum, row) => sum + row.count, 0) ?? 0; + const maxBucket = Math.max( + ...Object.values(a?.ageBuckets ?? { d1: 0, d7: 0, d14: 0, older: 0 }), + 1, + ); + const maxSeverity = Math.max(...(a?.openBySeverity.map((r) => r.count) ?? [1])); return ( - - {/* Filters */} -
-
- - setSearch(e.target.value)} - className="pl-9 pr-4 py-2 bg-gray-800 border border-gray-700 text-gray-100 placeholder-gray-500 rounded-lg w-full text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent" - /> -
- - - - - - {/* Queue picker */} -
- - - {showQueueFilter && ( -
- {/* Categories */} -
-

- Category -

-
- {categories.map((cat) => ( - - ))} -
-
- - {/* Types */} -
-

- Type -

-
- {!selectedCategory ? ( -

Select category

- ) : types.length === 0 ? ( -

No types

- ) : ( - types.map((type) => ( - - )) - )} -
-
- - {/* Items */} -
-

- Item -

-
- {!selectedType ? ( -

Select type

- ) : items.length === 0 ? ( -

No items

- ) : ( - items.map((item) => ( - - )) - )} -
-
-
- )} -
-
- - {/* Ticket list */} - {isLoading ? ( -
Loading...
- ) : tickets.length === 0 ? ( -
No tickets found
+ Last 30 days

}> + {!a ? ( +

Loading analytics…

) : ( -
- {tickets.map((ticket) => ( - -
+
+ } + label="Open tickets" + value={totalOpen.toString()} + /> + } + label="Aging >7d" + value={((a.ageBuckets.d14 ?? 0) + (a.ageBuckets.older ?? 0)).toString()} + /> + } + label="Median resolution" + value={fmtHours(a.medianResolutionHours)} + /> + } + label="Assignees loaded" + value={a.queueByAssignee.filter((q) => q.assigneeId).length.toString()} + /> -
-
- - {ticket.displayId} - - - - - {ticket.category.name} › {ticket.type.name} › {ticket.item.name} - -
-

- {ticket.title} -

-
- -
- {ticket.assignee ? ( -
- - {ticket.assignee.displayName} + {/* Open by severity */} +
+

Open by severity

+
+ {[1, 2, 3, 4, 5].map((sev) => { + const row = a.openBySeverity.find((r) => r.severity === sev); + const count = row?.count ?? 0; + const pct = maxSeverity > 0 ? (count / maxSeverity) * 100 : 0; + return ( +
+ + {SEV_NAMES[sev]} + +
+
+
+ + {count} +
- ) : ( - Unassigned - )} - - {ticket._count?.comments ?? 0} comments - - - {formatDistanceToNow(new Date(ticket.createdAt), { addSuffix: true })} - + ); + })} +
+
+ + Go to open tickets → + +
+
+ + {/* Age buckets */} +
+

Age of open tickets

+
+ {[ + { key: 'd1', label: '≤ 1 day' }, + { key: 'd7', label: '≤ 7 days' }, + { key: 'd14', label: '≤ 14 days' }, + { key: 'older', label: '> 14 days' }, + ].map(({ key, label }) => { + const count = a.ageBuckets[key as keyof typeof a.ageBuckets] ?? 0; + const pct = (count / maxBucket) * 100; + return ( +
+ {label} +
+
+
+ + {count} + +
+ ); + })} +
+
+ + {/* Queue by assignee */} +
+

Queue load by assignee

+ {a.queueByAssignee.length === 0 ? ( +

No open tickets right now.

+ ) : ( +
+ {a.queueByAssignee + .slice() + .sort((x, y) => y.count - x.count) + .map((row) => { + const name = row.assigneeId + ? userById.get(row.assigneeId) ?? 'Unknown' + : 'Unassigned'; + return ( +
+ {name} + {row.count} +
+ ); + })}
- - ))} + )} +
)} ); } + +function Card({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { + return ( +
+
+ {icon} + {label} +
+

{value}

+
+ ); +} diff --git a/client/src/pages/Notifications.tsx b/client/src/pages/Notifications.tsx new file mode 100644 index 0000000..0ccdf75 --- /dev/null +++ b/client/src/pages/Notifications.tsx @@ -0,0 +1,90 @@ +import { Link } from 'react-router-dom'; +import { formatDistanceToNow } from 'date-fns'; +import Layout from '../components/Layout'; +import { useNotifications, useMarkNotificationsRead } from '../api/queries'; +import type { Notification } from '../../../shared/types'; + +const KIND_LABELS: Record = { + ASSIGNED: 'assigned to you', + MENTION: 'mentioned you', + RESOLVED: 'was resolved', + TICKET_CREATED: 'was created', + STATUS_CHANGED: 'status changed', + COMMENT: 'new comment', +}; + +interface NotifData { + displayId?: string; + title?: string; + byName?: string; +} + +function summary(n: Notification): { label: string; href: string } { + const data = (n.data ?? {}) as NotifData; + const action = KIND_LABELS[n.kind] ?? n.kind.toLowerCase(); + const label = data.title ? `${data.title} — ${action}` : `Ticket ${action}`; + const href = data.displayId ? `/${data.displayId}` : '/notifications'; + return { label, href }; +} + +export default function Notifications() { + const { data: notifications = [], isLoading } = useNotifications(); + const markRead = useMarkNotificationsRead(); + + const unread = notifications.filter((n) => !n.readAt); + + return ( + 0 ? ( + + ) : null + } + > + {isLoading ? ( +

Loading…

+ ) : notifications.length === 0 ? ( +

+ You're all caught up +

+ ) : ( +
    + {notifications.map((n) => { + const { label, href } = summary(n); + const isUnread = !n.readAt; + return ( +
  • + isUnread && markRead.mutate({ ids: [n.id] })} + className="flex items-center gap-3 px-4 py-3 hover:bg-accent/30 transition-colors" + > + +
    +

    {label}

    +

    + {formatDistanceToNow(new Date(n.createdAt), { addSuffix: true })} +

    +
    + +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx new file mode 100644 index 0000000..ec52df0 --- /dev/null +++ b/client/src/pages/Settings.tsx @@ -0,0 +1,151 @@ +import { useEffect, useState } from 'react'; +import { Copy } from 'lucide-react'; +import { toast } from 'sonner'; +import Layout from '../components/Layout'; +import { useAuth } from '../contexts/AuthContext'; +import { + useNotificationPrefs, + useUpdateNotificationPrefs, + type NotificationPrefs, +} from '../api/queries'; + +const CHANNELS = [ + { key: 'assignment', label: 'Ticket assigned to me' }, + { key: 'mention', label: 'I am mentioned in a comment' }, + { key: 'resolved', label: 'A ticket I created is resolved' }, +] as const; + +export default function Settings() { + const { user } = useAuth(); + const prefsQ = useNotificationPrefs(); + const updatePrefs = useUpdateNotificationPrefs(); + + const [local, setLocal] = useState(null); + + useEffect(() => { + if (prefsQ.data && !local) setLocal(prefsQ.data); + }, [prefsQ.data, local]); + + const handleToggle = (channel: 'email' | 'inApp', key: keyof NotificationPrefs['email']) => { + setLocal((l) => + l + ? { + ...l, + [channel]: { ...l[channel], [key]: !l[channel][key] }, + } + : l, + ); + }; + + const save = async () => { + if (!local) return; + try { + await updatePrefs.mutateAsync(local); + toast.success('Preferences saved'); + } catch (e) { + toast.error((e as Error).message || 'Failed to save'); + } + }; + + const copyKey = async () => { + if (!user?.apiKey) return; + await navigator.clipboard.writeText(user.apiKey); + toast.success('API key copied'); + }; + + return ( + +
+ {/* Profile */} +
+

Profile

+
+ + + + +
+
+ + {/* Notifications */} +
+

Notification preferences

+ {local ? ( +
+
+ + Email + In app +
+ {CHANNELS.map(({ key, label }) => ( +
+ {label} +
+ handleToggle('email', key)} + /> +
+
+ handleToggle('inApp', key)} + /> +
+
+ ))} +
+ +
+
+ ) : ( +

Loading…

+ )} +

+ Email notifications require the server's SMTP config. If SMTP is unset, only + in-app delivery happens regardless of these settings. +

+
+ + {/* API key (service accounts only) */} + {user?.role === 'SERVICE' && user?.apiKey && ( +
+

API key

+
+ {user.apiKey} + +
+

+ Pass as x-api-key header on any server-to-server request. +

+
+ )} +
+
+ ); +} + +function Field({ label, value, mono }: { label: string; value: string; mono?: boolean }) { + return ( +
+

{label}

+

{value || '—'}

+
+ ); +} diff --git a/client/src/pages/TicketDetail.tsx b/client/src/pages/TicketDetail.tsx index faa3ae2..83cbe51 100644 --- a/client/src/pages/TicketDetail.tsx +++ b/client/src/pages/TicketDetail.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { format, formatDistanceToNow } from 'date-fns'; import ReactMarkdown from 'react-markdown'; @@ -16,6 +16,7 @@ import { ChevronDown, ChevronRight, } from 'lucide-react'; +import { toast } from 'sonner'; import Layout from '../components/Layout'; import Modal from '../components/Modal'; import SeverityBadge from '../components/SeverityBadge'; @@ -33,6 +34,21 @@ import { useAddComment, useDeleteComment, } from '../api/queries'; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from '@/components/ui/popover'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; type Tab = 'overview' | 'comments' | 'audit'; @@ -81,15 +97,29 @@ export default function TicketDetail() { const [commentBody, setCommentBody] = useState(''); const [preview, setPreview] = useState(false); const [expandedLogs, setExpandedLogs] = useState>(new Set()); + const [deleteOpen, setDeleteOpen] = useState(false); const [editForm, setEditForm] = useState({ title: '', overview: '' }); const [pendingCTI, setPendingCTI] = useState({ categoryId: '', typeId: '', itemId: '' }); - const [editingStatus, setEditingStatus] = useState(false); - const [editingSeverity, setEditingSeverity] = useState(false); - const [editingAssignee, setEditingAssignee] = useState(false); + const [statusOpen, setStatusOpen] = useState(false); + const [severityOpen, setSeverityOpen] = useState(false); + const [assigneeOpen, setAssigneeOpen] = useState(false); const [expandedDates, setExpandedDates] = useState>(new Set()); const [expandedCommentDates, setExpandedCommentDates] = useState>(new Set()); + // Draft autosave + const draftKey = id ? `comment-draft:${id}` : null; + useEffect(() => { + if (!draftKey) return; + const saved = localStorage.getItem(draftKey); + if (saved) setCommentBody(saved); + }, [draftKey]); + useEffect(() => { + if (!draftKey) return; + if (commentBody) localStorage.setItem(draftKey, commentBody); + else localStorage.removeItem(draftKey); + }, [commentBody, draftKey]); + const { data: ticket, isLoading } = useTicket(id); const { data: users = [] } = useUsers(); const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit'); @@ -145,10 +175,11 @@ export default function TicketDetail() { setReroutingCTI(false); }; - const deleteTicket = async () => { - if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return; + const confirmDeleteTicket = async () => { + if (!ticket) return; await deleteTicketMutation.mutateAsync(ticket.displayId); - navigate('/'); + toast.success('Ticket deleted'); + navigate('/tickets'); }; const submitComment = async (e: React.FormEvent) => { @@ -157,6 +188,7 @@ export default function TicketDetail() { await addComment.mutateAsync(commentBody.trim()); setCommentBody(''); setPreview(false); + if (draftKey) localStorage.removeItem(draftKey); }; const handleDeleteComment = async (commentId: string) => { @@ -206,11 +238,11 @@ export default function TicketDetail() { {/* Back link */}
@@ -511,22 +543,66 @@ export default function TicketDetail() {
{/* Status */} - + + + + + + {statusOptions.map((s) => ( + + ))} + {!isAdmin && ( +

+ Closing requires admin +

+ )} +
+
{/* Severity */} - + + + + + + {SEVERITY_OPTIONS.map((s) => ( + + ))} + + {/* CTI — one clickable unit */} + + + +
+ {agentUsers.map((u) => ( + + ))}
- ) : ( -

Unassigned

- )} - +
+ {/* Requester */}
@@ -599,7 +706,7 @@ export default function TicketDetail() { {isAdmin && (
- {editingStatus && ( - setEditingStatus(false)}> -
- {statusOptions.map((s) => ( - - ))} - {!isAdmin && ( -

Closing a ticket requires admin access

- )} -
-
- )} - {editingSeverity && ( - setEditingSeverity(false)}> -
- {SEVERITY_OPTIONS.map((s) => ( - - ))} -
-
- )} - - {editingAssignee && ( - setEditingAssignee(false)}> -
- - {agentUsers.map((u) => ( - - ))} -
-
- )} + Delete + + + + {reroutingCTI && ( setReroutingCTI(false)} size="lg"> diff --git a/client/src/pages/Tickets.tsx b/client/src/pages/Tickets.tsx new file mode 100644 index 0000000..cdb4b1f --- /dev/null +++ b/client/src/pages/Tickets.tsx @@ -0,0 +1,605 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Link, useSearchParams } from 'react-router-dom'; +import { ChevronLeft, ChevronRight, Trash2, Save } from 'lucide-react'; +import { formatDistanceToNow } from 'date-fns'; +import { toast } from 'sonner'; +import Layout from '../components/Layout'; +import SeverityBadge from '../components/SeverityBadge'; +import StatusBadge from '../components/StatusBadge'; +import Avatar from '../components/Avatar'; +import CTISelect from '../components/CTISelect'; +import { TicketStatus } from '../types'; +import { useAuth } from '../contexts/AuthContext'; +import { + useTicketsPaged, + useBulkTickets, + useSavedViews, + useCreateSavedView, + useDeleteSavedView, + useUsers, +} from '../api/queries'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; + +const STATUS_TABS: { value: TicketStatus | ''; label: string }[] = [ + { value: '', label: 'All' }, + { value: 'OPEN', label: 'Open' }, + { value: 'IN_PROGRESS', label: 'In progress' }, + { value: 'RESOLVED', label: 'Resolved' }, + { value: 'CLOSED', label: 'Closed' }, +]; + +const PAGE_SIZE = 25; +const BULK_LIMIT = 500; + +function sevColor(severity: number) { + return severity === 1 + ? 'bg-red-500' + : severity === 2 + ? 'bg-orange-400' + : severity === 3 + ? 'bg-yellow-400' + : severity === 4 + ? 'bg-blue-400' + : 'bg-gray-600'; +} + +export default function Tickets() { + const [params, setParams] = useSearchParams(); + const { user: authUser } = useAuth(); + const { data: users = [] } = useUsers(); + + const status = (params.get('status') ?? '') as TicketStatus | ''; + const severity = params.get('severity') ?? ''; + const assigneeId = params.get('assigneeId') ?? ''; + const categoryId = params.get('categoryId') ?? ''; + const typeId = params.get('typeId') ?? ''; + const itemId = params.get('itemId') ?? ''; + const search = params.get('search') ?? ''; + const page = Math.max(1, Number(params.get('page') ?? '1')); + + const [searchInput, setSearchInput] = useState(search); + const [selected, setSelected] = useState>(new Set()); + const [saveOpen, setSaveOpen] = useState(false); + const [newViewName, setNewViewName] = useState(''); + const [confirmBulk, setConfirmBulk] = useState< + | { kind: 'close' | 'setSeverity' | 'reassign'; value?: unknown; label: string } + | null + >(null); + + useEffect(() => { + const t = setTimeout(() => { + if (search !== searchInput) { + updateParam('search', searchInput); + updateParam('page', '1'); + } + }, 250); + return () => clearTimeout(t); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchInput]); + + const updateParam = (key: string, value: string | null) => { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value === null || value === '') next.delete(key); + else next.set(key, value); + if (key !== 'page') next.delete('page'); // reset page on filter change + return next; + }, + { replace: true }, + ); + }; + + const queryParams = { + status: status || undefined, + severity: severity ? Number(severity) : undefined, + assigneeId: assigneeId || undefined, + categoryId: categoryId || undefined, + typeId: typeId || undefined, + itemId: itemId || undefined, + search: search || undefined, + page, + pageSize: PAGE_SIZE, + }; + + const { data, isLoading, isFetching } = useTicketsPaged(queryParams); + const tickets = data?.data ?? []; + const total = data?.total ?? 0; + const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE)); + + const savedViewsQ = useSavedViews(); + const createView = useCreateSavedView(); + const deleteView = useDeleteSavedView(); + const bulk = useBulkTickets(); + + const allVisible = tickets.length > 0 && tickets.every((t) => selected.has(t.id)); + const someVisible = tickets.some((t) => selected.has(t.id)); + + const toggleAll = () => { + setSelected((prev) => { + const next = new Set(prev); + if (allVisible) tickets.forEach((t) => next.delete(t.id)); + else tickets.forEach((t) => next.add(t.id)); + return next; + }); + }; + + const toggleOne = (id: string) => { + setSelected((prev) => { + const next = new Set(prev); + if (next.has(id)) next.delete(id); + else next.add(id); + return next; + }); + }; + + const clearSelected = () => setSelected(new Set()); + + const currentFilters = useMemo( + () => ({ + status: status || undefined, + severity: severity || undefined, + assigneeId: assigneeId || undefined, + categoryId: categoryId || undefined, + typeId: typeId || undefined, + itemId: itemId || undefined, + search: search || undefined, + }), + [status, severity, assigneeId, categoryId, typeId, itemId, search], + ); + + const runBulk = async (payload: { action: string; value?: unknown }) => { + const ids = Array.from(selected).slice(0, BULK_LIMIT); + try { + const res = (await bulk.mutateAsync({ ids, ...payload })) as { updated?: number }; + toast.success(`Updated ${res.updated ?? ids.length} ticket${ids.length === 1 ? '' : 's'}`); + clearSelected(); + } catch (e) { + toast.error((e as Error).message || 'Bulk action failed'); + } finally { + setConfirmBulk(null); + } + }; + + const activeCount = Object.values(currentFilters).filter(Boolean).length; + + const handleSaveView = async () => { + if (!newViewName.trim()) return; + try { + await createView.mutateAsync({ + name: newViewName.trim(), + filters: currentFilters, + }); + toast.success('Saved view'); + setNewViewName(''); + setSaveOpen(false); + } catch (e) { + toast.error((e as Error).message || 'Failed to save view'); + } + }; + + const applyView = (filters: Record) => { + const next = new URLSearchParams(); + Object.entries(filters).forEach(([k, v]) => { + if (v !== undefined && v !== null && v !== '') next.set(k, String(v)); + }); + setParams(next, { replace: true }); + setSearchInput(String(filters.search ?? '')); + }; + + const agentUsers = users.filter((u) => u.role !== 'SERVICE'); + + return ( + + {/* Status tabs */} +
+ {STATUS_TABS.map((tab) => ( + + ))} +
+ + {/* Filter bar */} +
+ setSearchInput(e.target.value)} + placeholder="Search title, overview, ID…" + className="flex-1 min-w-48 max-w-xs px-3 py-1.5 rounded-md border border-input bg-background text-foreground text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> + + + + + +
+ { + setParams( + (prev) => { + const next = new URLSearchParams(prev); + next.delete('page'); + if (cti.categoryId) next.set('categoryId', cti.categoryId); + else next.delete('categoryId'); + if (cti.typeId) next.set('typeId', cti.typeId); + else next.delete('typeId'); + if (cti.itemId) next.set('itemId', cti.itemId); + else next.delete('itemId'); + return next; + }, + { replace: true }, + ); + }} + /> +
+ + {/* Saved views */} + + + + + + Saved views + + {savedViewsQ.data && savedViewsQ.data.length > 0 ? ( + savedViewsQ.data.map((v) => ( +
+ + +
+ )) + ) : ( +

No saved views yet

+ )} + + setSaveOpen(true)} + className="gap-2" + > + + Save current filters + +
+
+ +
+ {isFetching ? 'Loading…' : `${total} result${total === 1 ? '' : 's'}`} +
+
+ + {/* Bulk bar */} + {selected.size > 0 && ( +
+ + {selected.size} selected + + +
+ + + + + + + setConfirmBulk({ + kind: 'reassign', + value: null, + label: 'Unassign', + }) + } + > + Unassigned + + {agentUsers.map((u) => ( + + setConfirmBulk({ + kind: 'reassign', + value: u.id, + label: `Assign to ${u.displayName}`, + }) + } + > + {u.displayName} + + ))} + + + + + + + + + {[1, 2, 3, 4, 5].map((s) => ( + + setConfirmBulk({ + kind: 'setSeverity', + value: s, + label: `Set severity SEV ${s}`, + }) + } + > + SEV {s} + + ))} + + + + +
+ )} + + {/* Ticket list */} + {isLoading ? ( +
Loading…
+ ) : tickets.length === 0 ? ( +
No tickets found
+ ) : ( +
+
+ { + if (el) el.indeterminate = !allVisible && someVisible; + }} + onChange={toggleAll} + className="cursor-pointer" + /> + + {tickets.length} of {total} + +
+ +
    + {tickets.map((ticket) => ( +
  • + toggleOne(ticket.id)} + aria-label={`Select ${ticket.displayId}`} + className="cursor-pointer flex-shrink-0" + /> +
    + +
    +
    + + {ticket.title} + + + {ticket.displayId} + +
    +
    + + + + opened {formatDistanceToNow(new Date(ticket.createdAt), { + addSuffix: true, + })}{' '} + by {ticket.createdBy.displayName} + + + · {ticket.category.name} › {ticket.type.name} › {ticket.item.name} + + {ticket.assignee && ( + · assigned {ticket.assignee.displayName} + )} + · {ticket._count?.comments ?? 0} comments +
    +
    +
    + {ticket.assignee ? ( + + ) : null} +
    + +
  • + ))} +
+
+ )} + + {/* Pagination */} + {totalPages > 1 && ( +
+
+ Page {page} of {totalPages} +
+
+ + +
+
+ )} + + {/* Save view dialog */} + + + + Save current filters + +
+ setNewViewName(e.target.value)} + placeholder="View name" + className="w-full px-3 py-2 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + onKeyDown={(e) => { + if (e.key === 'Enter') handleSaveView(); + }} + /> +
+ + + + +
+
+ + {/* Confirm bulk action */} + !o && setConfirmBulk(null)}> + + + {confirmBulk?.label} + + This will affect {selected.size} ticket{selected.size === 1 ? '' : 's'}. + + + + Cancel + { + if (!confirmBulk) return; + if (confirmBulk.kind === 'close') runBulk({ action: 'close' }); + else + runBulk({ + action: confirmBulk.kind, + value: confirmBulk.value, + }); + }} + > + Confirm + + + + + + ); +} diff --git a/client/src/pages/admin/Users.tsx b/client/src/pages/admin/Users.tsx index 71bfd1b..968a64d 100644 --- a/client/src/pages/admin/Users.tsx +++ b/client/src/pages/admin/Users.tsx @@ -1,5 +1,6 @@ import { useState } from 'react'; import { Plus, Pencil, Trash2, RefreshCw, Copy, Check } from 'lucide-react'; +import { toast } from 'sonner'; import Layout from '../../components/Layout'; import Modal from '../../components/Modal'; import { User, Role } from '../../types'; @@ -10,6 +11,16 @@ import { useUpdateUser, useDeleteUser, } from '../../api/queries'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; interface UserForm { username: string; @@ -61,6 +72,8 @@ export default function AdminUsers() { const [error, setError] = useState(''); const [copiedKey, setCopiedKey] = useState(null); const [newApiKey, setNewApiKey] = useState(null); + const [deleting, setDeleting] = useState(null); + const [rotating, setRotating] = useState(null); const submitting = createUser.isPending || updateUser.isPending; @@ -130,25 +143,31 @@ export default function AdminUsers() { } }; - const handleDelete = async (u: User) => { - if (!confirm(`Delete user "${u.displayName}"?`)) return; - await deleteUser.mutateAsync(u.id); + const confirmDelete = async () => { + if (!deleting) return; + try { + await deleteUser.mutateAsync(deleting.id); + toast.success(`Deleted ${deleting.displayName}`); + } catch (e) { + toast.error((e as Error).message || 'Failed to delete user'); + } + setDeleting(null); }; - const handleRegenerateKey = async (u: User) => { - if ( - !confirm( - `Regenerate API key for "${u.displayName}"? The old key will stop working immediately.`, - ) - ) - return; - const updated = await updateUser.mutateAsync({ - id: u.id, - data: { regenerateApiKey: true }, - }); - setNewApiKey(updated.apiKey ?? null); - setSelected(u); - setModal('edit'); + const confirmRegenerate = async () => { + if (!rotating) return; + try { + const updated = await updateUser.mutateAsync({ + id: rotating.id, + data: { regenerateApiKey: true }, + }); + setNewApiKey(updated.apiKey ?? null); + setSelected(rotating); + setModal('edit'); + } catch (e) { + toast.error((e as Error).message || 'Failed to rotate key'); + } + setRotating(null); }; const copyToClipboard = (key: string) => { @@ -210,7 +229,7 @@ export default function AdminUsers() {
{u.role === 'SERVICE' && ( {u.id !== authUser?.id && ( + } + > + {hooks.length === 0 ? ( +
+ No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver. +
+ ) : ( +
+ + + + + + + + + + + {hooks.map((h) => ( + + + + + + + + ))} + +
NameURLEventsActive +
{h.name} + {h.url} + + {h.events.length} event{h.events.length === 1 ? '' : 's'} + + + +
+ + +
+
+
+ )} + + + + + Add webhook + +
+
+ + setForm((f) => ({ ...f, name: e.target.value }))} + className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ + setForm((f) => ({ ...f, url: e.target.value }))} + placeholder="https://n8n.example.com/webhook/abc" + className="w-full px-3 py-1.5 rounded-md border border-input bg-background text-sm focus:outline-none focus:ring-2 focus:ring-ring" + /> +
+
+ +
+ {WEBHOOK_EVENTS.map((ev) => ( + + ))} +
+
+
+ + + + +
+
+ + !o && setRevealedSecret(null)}> + + + Secret for {revealedSecret?.name} + +

+ Copy it now — it won't be shown again. Use it to verify the{' '} + X-Ticketing-Signature HMAC-SHA256 header. +

+
+ {revealedSecret?.secret} + +
+ + + +
+
+ + !o && setDeleting(null)}> + + + Delete webhook {deleting?.name}? + + No events will be dispatched to this URL anymore. + + + + Cancel + + Delete + + + + + + !o && setRotating(null)}> + + + Rotate secret for {rotating?.name}? + + The existing secret stops signing new events immediately. You'll see the new + secret once. + + + + Cancel + Rotate + + + + + ); +}