feat: jwt cache, redis health, help-ticket admin, and write rate limits
Deploy / release (push) Skipped
Deploy / deploy (push) Successful in 1m33s

Cut Auth.js DB load with cached jwtVersion checks, surface Redis in /api/health and deploy warnings, add admin help-center ticket reply UI, rate-limit API tickets/reactions/referral claims, and revoke PATs on sign-out-everywhere.

Co-authored-by: Cursor <[email protected]>
This commit is contained in:
SimoandCursor committed 2026-07-21 21:58:48 +02:00
1 parent 3bb96eb6f3
commit 968ca15c27
23 files changed
+1344 -205

No files matched your search

@@ -0,0 +1,288 @@
"use client";
import { ArrowLeft, Loader2, Mail, Send, Shield, User } from "lucide-react";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { useEffect, useRef, useState } from "react";
import {
closeHelpCenterTicket,
reopenHelpCenterTicket,
replyHelpCenterTicket,
} from "@/actions/admin-help-tickets";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Textarea } from "@/components/ui/textarea";
import { useServerAction } from "@/hooks/use-server-action";
interface ThreadMessage {
key: string;
userId: number;
username: string;
isStaff: boolean;
content: string;
createdAt: string;
}
interface HelpTicketInfo {
id: string;
title: string;
open: boolean;
createdAt: string;
updatedAt: string;
creator: {
id: number;
username: string;
rank: number;
mail: string;
} | null;
}
export function AdminHelpTicketDetail({
ticket,
messages: initialMessages,
canEdit,
}: {
ticket: HelpTicketInfo;
messages: ThreadMessage[];
canEdit: boolean;
}) {
const t = useTranslations("pages.admin.helpTickets");
const [messages, setMessages] = useState(initialMessages);
const [reply, setReply] = useState("");
const { run, isPending } = useServerAction();
const messagesEndRef = useRef<HTMLDivElement>(null);
useEffect(() => {
setMessages(initialMessages);
}, [initialMessages]);
// biome-ignore lint/correctness/useExhaustiveDependencies: scroll when thread updates
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
function handleReply() {
if (!reply.trim() || isPending) return;
run(
() =>
replyHelpCenterTicket({
ticketId: ticket.id,
content: reply.trim(),
}),
{
successMessage: t("success.replied"),
onSuccess: () => setReply(""),
},
);
}
function handleClose() {
run(
() => closeHelpCenterTicket({ ticketId: ticket.id }),
{ successMessage: t("success.closed") },
);
}
function handleReopen() {
run(
() => reopenHelpCenterTicket({ ticketId: ticket.id }),
{ successMessage: t("success.reopened") },
);
}
return (
<div className="space-y-6">
<div className="flex items-start gap-4">
<Link href="/admin/help-tickets">
<Button variant="ghost" size="icon" aria-label={t("backToList")}>
<ArrowLeft className="h-4 w-4" />
</Button>
</Link>
<div className="flex-1">
<div className="flex items-center gap-2 mb-1">
<span className="text-sm text-muted-foreground font-mono">
#{ticket.id}
</span>
<Badge variant={ticket.open ? "default" : "secondary"}>
{ticket.open ? t("statusOpen") : t("statusClosed")}
</Badge>
</div>
<h1 className="text-2xl font-bold">{ticket.title}</h1>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_300px]">
<div className="space-y-4">
<Card className="overflow-hidden">
<CardHeader className="pb-2 border-b">
<CardTitle className="text-sm">{t("threadTitle")}</CardTitle>
</CardHeader>
<div className="max-h-[600px] overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<div key={msg.key} className="flex gap-3">
<div className="shrink-0">
<div className="w-9 h-9 rounded-full bg-accent flex items-center justify-center text-xs font-bold">
{msg.isStaff ? (
<Shield className="h-4 w-4 text-primary" />
) : (
<User className="h-4 w-4" />
)}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span
className={`text-xs font-semibold ${msg.isStaff ? "text-primary" : ""}`}
>
{msg.username}
{msg.isStaff ? ` (${t("staffBadge")})` : ""}
</span>
<span className="text-[10px] text-muted-foreground">
{new Date(msg.createdAt).toLocaleString()}
</span>
</div>
<div
className={`rounded-lg px-4 py-2.5 text-sm leading-relaxed ${
msg.isStaff
? "bg-primary/10 border border-primary/20"
: "bg-muted"
}`}
>
<p className="whitespace-pre-wrap break-words">
{msg.content}
</p>
</div>
</div>
</div>
))}
<div ref={messagesEndRef} />
</div>
{canEdit && ticket.open && (
<div className="border-t p-4">
<div className="flex gap-2">
<Textarea
value={reply}
onChange={(e) => setReply(e.target.value)}
placeholder={t("replyPlaceholder")}
rows={3}
maxLength={5000}
className="resize-none"
onKeyDown={(e) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleReply();
}
}}
/>
<Button
onClick={handleReply}
disabled={isPending || !reply.trim()}
size="icon"
className="shrink-0 h-auto"
aria-label={t("replySubmit")}
>
{isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
{t("replyHint")}
</p>
</div>
)}
{!ticket.open && (
<div className="border-t p-4 text-center text-sm text-muted-foreground">
{t("closedHint")}
</div>
)}
</Card>
</div>
<div className="space-y-4">
{canEdit && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("actionsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{ticket.open ? (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={isPending}
onClick={handleClose}
>
{t("closeSubmit")}
</Button>
) : (
<Button
variant="outline"
size="sm"
className="w-full"
disabled={isPending}
onClick={handleReopen}
>
{t("reopenSubmit")}
</Button>
)}
</CardContent>
</Card>
)}
{ticket.creator && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("requesterTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<Link
href={`/admin/users/show/${ticket.creator.id}`}
className="flex items-center gap-2 text-sm font-medium hover:text-primary"
>
<User className="h-3.5 w-3.5" />
{ticket.creator.username}
<Badge variant="outline" className="text-[10px]">
{t("rankLabel", { rank: ticket.creator.rank })}
</Badge>
</Link>
{ticket.creator.mail ? (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Mail className="h-3 w-3" />
{ticket.creator.mail}
</div>
) : null}
</CardContent>
</Card>
)}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm">{t("detailsTitle")}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-xs text-muted-foreground">
<div className="flex justify-between gap-2">
<span>{t("colCreated")}</span>
<span>{new Date(ticket.createdAt).toLocaleString()}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("colUpdated")}</span>
<span>{new Date(ticket.updatedAt).toLocaleString()}</span>
</div>
<div className="flex justify-between gap-2">
<span>{t("messageCountLabel")}</span>
<span>{messages.length}</span>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}