Phase 1b: React Query + Vitest on client

- @tanstack/react-query v5 with QueryClientProvider at app root
- client/src/api/queries.ts: query-key factory, hooks for tickets, ticket, audit,
  comments, users, CTI tree + cascade, plus full mutation set
  (create/update/delete ticket, add/delete comment, CTI CRUD, user CRUD)
- All page-level useEffect + useState fetching replaced:
  Dashboard, MyTickets, TicketDetail, NewTicket, admin/CTI, admin/Users
- Dashboard preserves 300ms debounced search via separate debouncedSearch state
- CTISelect cascades via useCategories / useTypes(categoryId) / useItems(typeId);
  dependent hooks disabled until parent selected
- vitest + @testing-library/react + jsdom; 6 client tests cover SeverityBadge + StatusBadge

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 15:35:09 -04:00
parent aff52e5672
commit 4eae11b5b0
16 changed files with 1722 additions and 3401 deletions
+22 -46
View File
@@ -1,13 +1,13 @@
import { useState, useEffect, useCallback } from 'react';
import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom';
import { Search, ChevronRight, X } from 'lucide-react';
import { formatDistanceToNow } from 'date-fns';
import api from '../api/client';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import Avatar from '../components/Avatar';
import { Ticket, TicketStatus, Category, CTIType, Item } from '../types';
import { TicketStatus, Category, CTIType, Item } from '../types';
import { useTickets, useCategories, useTypes, useItems } from '../api/queries';
const STATUSES: { value: TicketStatus | ''; label: string }[] = [
{ value: '', label: 'All Statuses' },
@@ -20,7 +20,6 @@ const STATUSES: { value: TicketStatus | ''; label: string }[] = [
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';
// Queue label built from whatever CTI level is selected
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}`;
@@ -29,41 +28,44 @@ function queueLabel(category: Category | null, type: CTIType | null, item: Item
}
export default function Dashboard() {
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [status, setStatus] = useState<TicketStatus | ''>('');
const [severity, setSeverity] = useState('');
// CTI queue filter state
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 [selectedItem, setSelectedItem] = useState<Item | null>(null);
const [showQueueFilter, setShowQueueFilter] = useState(false);
const { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(selectedCategory?.id);
const { data: items = [] } = useItems(selectedType?.id);
useEffect(() => {
api.get<Category[]>('/cti/categories').then((r) => setCategories(r.data));
}, []);
const t = setTimeout(() => setDebouncedSearch(search), 300);
return () => clearTimeout(t);
}, [search]);
const ticketParams: Record<string, string> = {};
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);
setTypes([]);
setItems([]);
api
.get<CTIType[]>('/cti/types', { params: { categoryId: cat.id } })
.then((r) => setTypes(r.data));
};
const handleTypeSelect = (type: CTIType) => {
setSelectedType(type);
setSelectedItem(null);
setItems([]);
api.get<Item[]>('/cti/items', { params: { typeId: type.id } }).then((r) => setItems(r.data));
};
const handleItemSelect = (item: Item) => {
@@ -75,34 +77,8 @@ export default function Dashboard() {
setSelectedCategory(null);
setSelectedType(null);
setSelectedItem(null);
setTypes([]);
setItems([]);
};
// Derive the most specific filter param
const queueParams: Record<string, string> = {};
if (selectedItem) queueParams.itemId = selectedItem.id;
else if (selectedType) queueParams.typeId = selectedType.id;
else if (selectedCategory) queueParams.categoryId = selectedCategory.id;
const fetchTickets = useCallback(() => {
setLoading(true);
const params: Record<string, string> = { ...queueParams };
if (status) params.status = status;
if (severity) params.severity = severity;
if (search) params.search = search;
api
.get<Ticket[]>('/tickets', { params })
.then((r) => setTickets(r.data))
.finally(() => setLoading(false));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [status, severity, search, selectedCategory, selectedType, selectedItem]);
useEffect(() => {
const t = setTimeout(fetchTickets, 300);
return () => clearTimeout(t);
}, [fetchTickets]);
const activeQueue = queueLabel(selectedCategory, selectedType, selectedItem);
return (
@@ -263,7 +239,7 @@ export default function Dashboard() {
</div>
{/* Ticket list */}
{loading ? (
{isLoading ? (
<div className="text-center py-16 text-gray-600 text-sm">Loading...</div>
) : tickets.length === 0 ? (
<div className="text-center py-16 text-gray-600 text-sm">No tickets found</div>
+16 -23
View File
@@ -1,36 +1,29 @@
import { useState, useEffect } from 'react';
import { useMemo } from 'react';
import { Link } from 'react-router-dom';
import { formatDistanceToNow } from 'date-fns';
import api from '../api/client';
import Layout from '../components/Layout';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import { Ticket } from '../types';
import { useTickets } from '../api/queries';
import { useAuth } from '../contexts/AuthContext';
export default function MyTickets() {
const { user } = useAuth();
const [tickets, setTickets] = useState<Ticket[]>([]);
const [loading, setLoading] = useState(true);
const openQ = useTickets(user ? { assigneeId: user.id, status: 'OPEN' } : {});
const inProgressQ = useTickets(user ? { assigneeId: user.id, status: 'IN_PROGRESS' } : {});
useEffect(() => {
if (!user) return;
// Only show active tickets — OPEN and IN_PROGRESS
Promise.all([
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'OPEN' } }),
api.get<Ticket[]>('/tickets', { params: { assigneeId: user.id, status: 'IN_PROGRESS' } }),
])
.then(([openRes, inProgressRes]) => {
const combined = [...openRes.data, ...inProgressRes.data];
combined.sort(
(a, b) =>
a.severity - b.severity ||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
setTickets(combined);
})
.finally(() => setLoading(false));
}, [user]);
const loading = openQ.isLoading || inProgressQ.isLoading;
const tickets = useMemo(() => {
if (!user) return [];
const combined = [...(openQ.data ?? []), ...(inProgressQ.data ?? [])];
combined.sort(
(a, b) =>
a.severity - b.severity ||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return combined;
}, [user, openQ.data, inProgressQ.data]);
return (
<Layout title="My Tickets">
+33 -49
View File
@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { format, formatDistanceToNow } from 'date-fns';
import ReactMarkdown from 'react-markdown';
@@ -16,15 +16,23 @@ import {
ChevronDown,
ChevronRight,
} from 'lucide-react';
import api from '../api/client';
import Layout from '../components/Layout';
import Modal from '../components/Modal';
import SeverityBadge from '../components/SeverityBadge';
import StatusBadge from '../components/StatusBadge';
import CTISelect from '../components/CTISelect';
import Avatar from '../components/Avatar';
import { Ticket, TicketStatus, User, Comment, AuditLog } from '../types';
import { TicketStatus } from '../types';
import { useAuth } from '../contexts/AuthContext';
import {
useTicket,
useTicketAudit,
useUpdateTicket,
useDeleteTicket,
useUsers,
useAddComment,
useDeleteComment,
} from '../api/queries';
type Tab = 'overview' | 'comments' | 'audit';
@@ -67,15 +75,10 @@ export default function TicketDetail() {
const navigate = useNavigate();
const { user: authUser } = useAuth();
const [ticket, setTicket] = useState<Ticket | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [auditLogs, setAuditLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true);
const [tab, setTab] = useState<Tab>('overview');
const [editing, setEditing] = useState(false);
const [reroutingCTI, setReroutingCTI] = useState(false);
const [commentBody, setCommentBody] = useState('');
const [submittingComment, setSubmittingComment] = useState(false);
const [preview, setPreview] = useState(false);
const [expandedLogs, setExpandedLogs] = useState<Set<string>>(new Set());
@@ -87,6 +90,15 @@ export default function TicketDetail() {
const [expandedDates, setExpandedDates] = useState<Set<string>>(new Set());
const [expandedCommentDates, setExpandedCommentDates] = useState<Set<string>>(new Set());
const { data: ticket, isLoading } = useTicket(id);
const { data: users = [] } = useUsers();
const { data: auditLogs = [] } = useTicketAudit(id, tab === 'audit');
const updateTicket = useUpdateTicket();
const deleteTicketMutation = useDeleteTicket();
const addComment = useAddComment(id);
const deleteCommentMutation = useDeleteComment(id);
const toggleDate = (key: string) =>
setExpandedDates((prev) => {
const next = new Set(prev);
@@ -95,36 +107,19 @@ export default function TicketDetail() {
return next;
});
const toggleCommentDate = (id: string) =>
const toggleCommentDate = (commentId: string) =>
setExpandedCommentDates((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
if (next.has(commentId)) next.delete(commentId);
else next.add(commentId);
return next;
});
const isAdmin = authUser?.role === 'ADMIN';
useEffect(() => {
Promise.all([api.get<Ticket>(`/tickets/${id}`), api.get<User[]>('/users')])
.then(([tRes, uRes]) => {
setTicket(tRes.data);
setUsers(uRes.data);
})
.finally(() => setLoading(false));
}, [id]);
useEffect(() => {
if (tab === 'audit' && ticket) {
api.get<AuditLog[]>(`/tickets/${id}/audit`).then((r) => setAuditLogs(r.data));
}
}, [tab, ticket, id]);
const patch = async (payload: Record<string, unknown>) => {
if (!ticket) return;
const res = await api.patch<Ticket>(`/tickets/${ticket.displayId}`, payload);
setTicket(res.data);
return res.data;
await updateTicket.mutateAsync({ id: ticket.displayId, data: payload });
};
const startEdit = () => {
@@ -152,30 +147,20 @@ export default function TicketDetail() {
const deleteTicket = async () => {
if (!ticket || !confirm('Delete this ticket? This cannot be undone.')) return;
await api.delete(`/tickets/${ticket.displayId}`);
await deleteTicketMutation.mutateAsync(ticket.displayId);
navigate('/');
};
const submitComment = async (e: React.FormEvent) => {
e.preventDefault();
if (!ticket || !commentBody.trim()) return;
setSubmittingComment(true);
try {
const res = await api.post<Comment>(`/tickets/${ticket.displayId}/comments`, {
body: commentBody.trim(),
});
setTicket((t) => (t ? { ...t, comments: [...(t.comments ?? []), res.data] } : t));
setCommentBody('');
setPreview(false);
} finally {
setSubmittingComment(false);
}
await addComment.mutateAsync(commentBody.trim());
setCommentBody('');
setPreview(false);
};
const deleteComment = async (commentId: string) => {
if (!ticket) return;
await api.delete(`/tickets/${ticket.displayId}/comments/${commentId}`);
setTicket((t) => (t ? { ...t, comments: t.comments?.filter((c) => c.id !== commentId) } : t));
const handleDeleteComment = async (commentId: string) => {
await deleteCommentMutation.mutateAsync(commentId);
};
const toggleLog = (logId: string) => {
@@ -187,7 +172,7 @@ export default function TicketDetail() {
});
};
if (loading) {
if (isLoading) {
return (
<Layout>
<div className="flex items-center justify-center h-full text-gray-600 text-sm">
@@ -210,7 +195,6 @@ export default function TicketDetail() {
const commentCount = ticket.comments?.length ?? 0;
const agentUsers = users.filter((u) => u.role !== 'SERVICE');
// Status options: CLOSED only for admins
const statusOptions: { value: TicketStatus; label: string }[] = [
{ value: 'OPEN', label: 'Open' },
{ value: 'IN_PROGRESS', label: 'In Progress' },
@@ -362,7 +346,7 @@ export default function TicketDetail() {
</div>
{(comment.authorId === authUser?.id || isAdmin) && (
<button
onClick={() => deleteComment(comment.id)}
onClick={() => handleDeleteComment(comment.id)}
className="opacity-0 group-hover:opacity-100 text-gray-600 hover:text-red-400 transition-all"
>
<Trash2 size={13} />
@@ -426,7 +410,7 @@ export default function TicketDetail() {
<span className="text-xs text-gray-600">Markdown · Ctrl+Enter</span>
<button
type="submit"
disabled={submittingComment || !commentBody.trim()}
disabled={addComment.isPending || !commentBody.trim()}
className="flex items-center gap-2 px-4 py-1.5 bg-blue-600 text-white text-sm rounded-lg hover:bg-blue-700 disabled:opacity-50 transition-colors"
>
<Send size={13} />
+68 -73
View File
@@ -1,66 +1,81 @@
import { useState, useEffect } from 'react';
import { useState } 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';
import { Category, CTIType } from '../../types';
import {
useCategories,
useTypes,
useItems,
useCreateCategory,
useUpdateCategory,
useDeleteCategory,
useCreateType,
useUpdateType,
useDeleteType,
useCreateItem,
useUpdateItem,
useDeleteItem,
} from '../../api/queries';
type PanelItem = { id: string; name: string };
type Panel = 'category' | 'type' | 'item';
interface NameModalState {
open: boolean;
mode: 'add' | 'edit';
panel: 'category' | 'type' | 'item';
panel: Panel;
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 { data: categories = [] } = useCategories();
const { data: types = [] } = useTypes(selectedCategory?.id);
const { data: items = [] } = useItems(selectedType?.id);
const createCategory = useCreateCategory();
const updateCategory = useUpdateCategory();
const deleteCategory = useDeleteCategory();
const createType = useCreateType();
const updateType = useUpdateType();
const deleteType = useDeleteType();
const createItem = useCreateItem();
const updateItem = useUpdateItem();
const deleteItem = useDeleteItem();
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 submitting =
createCategory.isPending ||
updateCategory.isPending ||
createType.isPending ||
updateType.isPending ||
createItem.isPending ||
updateItem.isPending;
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') => {
const openAdd = (panel: Panel) => {
setNameValue('');
setNameModal({ open: true, mode: 'add', panel });
};
const openEdit = (panel: 'category' | 'type' | 'item', item: PanelItem) => {
const openEdit = (panel: Panel, item: PanelItem) => {
setNameValue(item.name);
setNameModal({ open: true, mode: 'edit', panel, item });
};
@@ -69,65 +84,45 @@ export default function AdminCTI() {
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);
}
const name = nameValue.trim();
if (!name) return;
const { mode, panel, item } = nameModal;
if (mode === 'add') {
if (panel === 'category') {
await createCategory.mutateAsync({ name });
} else if (panel === 'type' && selectedCategory) {
await createType.mutateAsync({ name, categoryId: selectedCategory.id });
} else if (panel === 'item' && selectedType) {
await createItem.mutateAsync({ name, typeId: selectedType.id });
}
} else if (item) {
if (panel === 'category') {
await updateCategory.mutateAsync({ id: item.id, name });
if (selectedCategory?.id === item.id)
setSelectedCategory((c) => (c ? { ...c, name } : c));
} else if (panel === 'type') {
await updateType.mutateAsync({ id: item.id, name });
if (selectedType?.id === item.id) setSelectedType((t) => (t ? { ...t, name } : t));
} else if (panel === 'item') {
await updateItem.mutateAsync({ id: item.id, name });
}
closeModal();
} finally {
setSubmitting(false);
}
closeModal();
};
const handleDelete = async (panel: 'category' | 'type' | 'item', item: PanelItem) => {
const handleDelete = async (panel: Panel, 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}`);
await deleteCategory.mutateAsync(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);
await deleteType.mutateAsync(item.id);
if (selectedType?.id === item.id) setSelectedType(null);
} else {
await api.delete(`/cti/items/${item.id}`);
if (selectedType) await fetchItems(selectedType.id);
await deleteItem.mutateAsync(item.id);
}
};
+22 -25
View File
@@ -1,10 +1,15 @@
import { useState, useEffect } from 'react';
import { useState } 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';
import {
useUsers,
useCreateUser,
useUpdateUser,
useDeleteUser,
} from '../../api/queries';
interface UserForm {
username: string;
@@ -45,22 +50,19 @@ const ROLE_DESCRIPTIONS: Record<Role, string> = {
export default function AdminUsers() {
const { user: authUser } = useAuth();
const [users, setUsers] = useState<User[]>([]);
const { data: users = [] } = useUsers();
const createUser = useCreateUser();
const updateUser = useUpdateUser();
const deleteUser = useDeleteUser();
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 submitting = createUser.isPending || updateUser.isPending;
const openAdd = () => {
setForm(BLANK_FORM);
@@ -87,12 +89,10 @@ export default function AdminUsers() {
setModal(null);
setSelected(null);
setNewApiKey(null);
fetchUsers();
};
const handleAdd = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError('');
try {
const payload: Record<string, string> = {
@@ -102,21 +102,18 @@ export default function AdminUsers() {
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);
const created = await createUser.mutateAsync(payload);
if (created.apiKey) setNewApiKey(created.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> = {
@@ -125,20 +122,17 @@ export default function AdminUsers() {
role: form.role,
};
if (form.password) payload.password = form.password;
await api.patch(`/users/${selected.id}`, payload);
await updateUser.mutateAsync({ id: selected.id, data: 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();
await deleteUser.mutateAsync(u.id);
};
const handleRegenerateKey = async (u: User) => {
@@ -148,8 +142,11 @@ export default function AdminUsers() {
)
)
return;
const res = await api.patch<User>(`/users/${u.id}`, { regenerateApiKey: true });
setNewApiKey(res.data.apiKey ?? null);
const updated = await updateUser.mutateAsync({
id: u.id,
data: { regenerateApiKey: true },
});
setNewApiKey(updated.apiKey ?? null);
setSelected(u);
setModal('edit');
};