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>
);
}
+304
View File
@@ -0,0 +1,304 @@
import { useState } from 'react';
import { Copy, Plus, RefreshCw, Trash2 } from 'lucide-react';
import { toast } from 'sonner';
import Layout from '../../components/Layout';
import { WEBHOOK_EVENTS } from '../../../../shared/schemas/notification';
import type { Webhook } from '../../../../shared/types';
import {
useWebhooks,
useCreateWebhook,
useDeleteWebhook,
useUpdateWebhook,
useRotateWebhookSecret,
} from '../../api/queries';
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 BLANK = {
name: '',
url: '',
events: [...WEBHOOK_EVENTS] as string[],
};
export default function AdminWebhooks() {
const { data: hooks = [] } = useWebhooks();
const create = useCreateWebhook();
const update = useUpdateWebhook();
const del = useDeleteWebhook();
const rotate = useRotateWebhookSecret();
const [addOpen, setAddOpen] = useState(false);
const [form, setForm] = useState(BLANK);
const [revealedSecret, setRevealedSecret] = useState<{ name: string; secret: string } | null>(
null,
);
const [deleting, setDeleting] = useState<Webhook | null>(null);
const [rotating, setRotating] = useState<Webhook | null>(null);
const copy = async (text: string) => {
await navigator.clipboard.writeText(text);
toast.success('Copied to clipboard');
};
const toggleEvent = (event: string) => {
setForm((f) => ({
...f,
events: f.events.includes(event)
? f.events.filter((e) => e !== event)
: [...f.events, event],
}));
};
const submitCreate = async () => {
try {
const hook = await create.mutateAsync(form);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
setForm(BLANK);
setAddOpen(false);
toast.success('Webhook created');
} catch (e) {
toast.error((e as Error).message || 'Failed to create webhook');
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await del.mutateAsync(deleting.id);
toast.success('Webhook deleted');
} catch (e) {
toast.error((e as Error).message || 'Failed to delete');
}
setDeleting(null);
};
const confirmRotate = async () => {
if (!rotating) return;
try {
const hook = await rotate.mutateAsync(rotating.id);
if (hook.secret) setRevealedSecret({ name: hook.name, secret: hook.secret });
} catch (e) {
toast.error((e as Error).message || 'Failed to rotate');
}
setRotating(null);
};
return (
<Layout
title="Webhooks"
action={
<button
onClick={() => setAddOpen(true)}
className="flex items-center gap-1.5 bg-primary text-primary-foreground px-3 py-1.5 rounded-md text-sm"
>
<Plus size={14} />
Add webhook
</button>
}
>
{hooks.length === 0 ? (
<div className="py-16 text-center text-sm text-muted-foreground border border-border rounded-md">
No webhooks configured. Add one to push events to n8n, Slack, or any HTTP receiver.
</div>
) : (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-sm">
<thead className="border-b border-border bg-card text-xs uppercase text-muted-foreground">
<tr>
<th className="text-left px-4 py-2">Name</th>
<th className="text-left px-4 py-2">URL</th>
<th className="text-left px-4 py-2">Events</th>
<th className="text-left px-4 py-2">Active</th>
<th className="px-4 py-2" />
</tr>
</thead>
<tbody className="divide-y divide-border">
{hooks.map((h) => (
<tr key={h.id}>
<td className="px-4 py-2 font-medium">{h.name}</td>
<td className="px-4 py-2 font-mono text-xs text-muted-foreground truncate max-w-xs">
{h.url}
</td>
<td className="px-4 py-2 text-xs text-muted-foreground">
{h.events.length} event{h.events.length === 1 ? '' : 's'}
</td>
<td className="px-4 py-2">
<button
onClick={() => update.mutate({ id: h.id, data: { active: !h.active } })}
className={`text-xs px-2 py-0.5 rounded-full ${
h.active
? 'bg-green-500/20 text-green-400'
: 'bg-muted text-muted-foreground'
}`}
>
{h.active ? 'Active' : 'Disabled'}
</button>
</td>
<td className="px-4 py-2">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => setRotating(h)}
title="Rotate secret"
className="text-muted-foreground hover:text-foreground"
>
<RefreshCw size={14} />
</button>
<button
onClick={() => setDeleting(h)}
title="Delete"
className="text-muted-foreground hover:text-destructive"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<Dialog open={addOpen} onOpenChange={setAddOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Add webhook</DialogTitle>
</DialogHeader>
<div className="space-y-3 py-2">
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">Name</label>
<input
value={form.name}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
URL
</label>
<input
value={form.url}
onChange={(e) => 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"
/>
</div>
<div>
<label className="block text-xs font-medium text-muted-foreground mb-1">
Events
</label>
<div className="grid grid-cols-2 gap-1">
{WEBHOOK_EVENTS.map((ev) => (
<label key={ev} className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.events.includes(ev)}
onChange={() => toggleEvent(ev)}
/>
<span className="font-mono text-xs">{ev}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<button
onClick={() => setAddOpen(false)}
className="px-3 py-1.5 rounded-md border border-input text-sm hover:bg-accent"
>
Cancel
</button>
<button
onClick={submitCreate}
disabled={!form.name || !form.url || form.events.length === 0 || create.isPending}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm disabled:opacity-50"
>
Create
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<Dialog open={!!revealedSecret} onOpenChange={(o) => !o && setRevealedSecret(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Secret for {revealedSecret?.name}</DialogTitle>
</DialogHeader>
<p className="text-sm text-muted-foreground">
Copy it now it won&apos;t be shown again. Use it to verify the{' '}
<code className="text-xs">X-Ticketing-Signature</code> HMAC-SHA256 header.
</p>
<div className="flex items-center gap-2 bg-muted rounded-md px-3 py-2 font-mono text-xs break-all">
<span className="flex-1">{revealedSecret?.secret}</span>
<button
onClick={() => revealedSecret && copy(revealedSecret.secret)}
className="text-muted-foreground hover:text-foreground"
>
<Copy size={14} />
</button>
</div>
<DialogFooter>
<button
onClick={() => setRevealedSecret(null)}
className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm"
>
Done
</button>
</DialogFooter>
</DialogContent>
</Dialog>
<AlertDialog open={!!deleting} onOpenChange={(o) => !o && setDeleting(null)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete webhook {deleting?.name}?</AlertDialogTitle>
<AlertDialogDescription>
No events will be dispatched to this URL anymore.
</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>Rotate secret for {rotating?.name}?</AlertDialogTitle>
<AlertDialogDescription>
The existing secret stops signing new events immediately. You&apos;ll see the new
secret once.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={confirmRotate}>Rotate</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Layout>
);
}