Phase 3: UI redesign (Gitea-issues aesthetic)

Top-nav Layout replaces side-nav: brand, primary nav, global search
(debounced /search), notifications bell (Popover + unread badge),
user avatar DropdownMenu. Mobile hamburger collapse.

New pages:
- /dashboard: analytics home (open-by-severity, age buckets, queue
  load, median resolution)
- /tickets: Gitea-style list with status tabs, severity/assignee/CTI
  filters, server pagination (25/page), multi-select bulk bar
  (reassign/close/severity), saved views CRUD
- /notifications: full list with mark-all-read
- /settings: profile, notification prefs grid, API key (SERVICE role)
- /admin/webhooks: CRUD + rotate-secret + active toggle,
  reveal-once secret dialog

TicketDetail: inline Popover editing for Status/Severity/Assignee
(replaces modal chain), AlertDialog delete confirmation, comment
draft autosave to localStorage per ticket.

Admin Users: window.confirm swapped for AlertDialog on delete +
rotate API key with toast feedback.

React Query hooks added for paged tickets, bulk actions,
notifications, webhooks, saved views, analytics, notification prefs.
ThemeProvider wired (v1.0 ships dark-only; toggle deferred).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-18 16:20:28 -04:00
parent edf4c5eb3c
commit 4bade22410
14 changed files with 2213 additions and 506 deletions
+74 -19
View File
@@ -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<string | null>(null);
const [newApiKey, setNewApiKey] = useState<string | null>(null);
const [deleting, setDeleting] = useState<User | null>(null);
const [rotating, setRotating] = useState<User | null>(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() {
<div className="flex items-center justify-end gap-2">
{u.role === 'SERVICE' && (
<button
onClick={() => handleRegenerateKey(u)}
onClick={() => setRotating(u)}
className="text-gray-600 hover:text-gray-300 transition-colors"
title="Regenerate API key"
>
@@ -225,7 +244,7 @@ export default function AdminUsers() {
</button>
{u.id !== authUser?.id && (
<button
onClick={() => handleDelete(u)}
onClick={() => setDeleting(u)}
className="text-gray-600 hover:text-red-400 transition-colors"
>
<Trash2 size={14} />
@@ -365,6 +384,42 @@ export default function AdminUsers() {
)}
</Modal>
)}
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete {deleting?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
This user will be permanently removed. Their tickets and comments are preserved.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Delete
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={!!rotating} onOpenChange={(o) => !o && setRotating(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Regenerate API key for {rotating?.displayName}?</AlertDialogTitle>
<AlertDialogDescription>
The old key will stop working immediately. You&apos;ll see the new key once make
sure whatever uses it can be updated.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRegenerate}>Rotate key</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}