(READ ONLY) Margin is an open annotation layer for the internet. Powered by the AT Protocol.
margin.at
extension
web
atproto
comments
1import React, { useEffect, useState } from "react";
2import { useStore } from "@nanostores/react";
3import { useTranslation } from "react-i18next";
4import { $user } from "../../store/auth";
5import {
6 checkAdminAccess,
7 getAdminReports,
8 adminTakeAction,
9 adminCreateLabel,
10 adminDeleteLabel,
11 adminGetLabels,
12 adminBanAccount,
13 adminUnbanAccount,
14 adminGetBannedAccounts,
15 type BannedAccount,
16} from "../../api/client";
17import type { ModerationReport, HydratedLabel } from "../../types";
18import {
19 Shield,
20 CheckCircle,
21 XCircle,
22 AlertTriangle,
23 Eye,
24 ChevronDown,
25 ChevronUp,
26 Tag,
27 FileText,
28 Plus,
29 Trash2,
30 EyeOff,
31 UserX,
32 UserCheck,
33} from "lucide-react";
34import { Avatar, EmptyState, Skeleton, Button } from "../../components/ui";
35
36const STATUS_COLORS: Record<string, string> = {
37 pending:
38 "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300",
39 resolved:
40 "bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300",
41 dismissed:
42 "bg-surface-100 text-surface-600 dark:bg-surface-800 dark:text-surface-400",
43 escalated: "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300",
44 acknowledged:
45 "bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-300",
46};
47
48const REASON_LABEL_KEYS: Record<string, string> = {
49 spam: "adminModeration.reasons.spam",
50 violation: "adminModeration.reasons.violation",
51 misleading: "adminModeration.reasons.misleading",
52 sexual: "adminModeration.reasons.sexual",
53 rude: "adminModeration.reasons.rude",
54 other: "adminModeration.reasons.other",
55};
56
57const LABEL_VALS = [
58 "sexual",
59 "nudity",
60 "violence",
61 "gore",
62 "spam",
63 "misleading",
64];
65
66type Tab = "reports" | "labels" | "actions" | "bans";
67
68export default function AdminModeration() {
69 const { t } = useTranslation();
70 const user = useStore($user);
71 const [isAdmin, setIsAdmin] = useState(false);
72 const [loading, setLoading] = useState(true);
73 const [activeTab, setActiveTab] = useState<Tab>("reports");
74
75 const [reports, setReports] = useState<ModerationReport[]>([]);
76 const [pendingCount, setPendingCount] = useState(0);
77 const [totalCount, setTotalCount] = useState(0);
78 const [statusFilter, setStatusFilter] = useState<string>("pending");
79 const [expandedReport, setExpandedReport] = useState<number | null>(null);
80 const [actionLoading, setActionLoading] = useState<number | null>(null);
81
82 const [labels, setLabels] = useState<HydratedLabel[]>([]);
83
84 const [labelSrc, setLabelSrc] = useState("");
85 const [labelUri, setLabelUri] = useState("");
86 const [labelVal, setLabelVal] = useState("");
87 const [labelSubmitting, setLabelSubmitting] = useState(false);
88 const [labelSuccess, setLabelSuccess] = useState(false);
89
90 const [bans, setBans] = useState<BannedAccount[]>([]);
91 const [banDid, setBanDid] = useState("");
92 const [banReason, setBanReason] = useState("");
93 const [banSubmitting, setBanSubmitting] = useState(false);
94 const [banSuccess, setBanSuccess] = useState(false);
95 const [unbanLoading, setUnbanLoading] = useState<string | null>(null);
96
97 const loadReports = async (status: string) => {
98 const data = await getAdminReports(status || undefined);
99 setReports(data.items);
100 setPendingCount(data.pendingCount);
101 setTotalCount(data.totalItems);
102 };
103
104 const loadLabels = async () => {
105 const data = await adminGetLabels();
106 setLabels(data.items || []);
107 };
108
109 const loadBans = async () => {
110 const data = await adminGetBannedAccounts();
111 setBans(data.items || []);
112 };
113
114 useEffect(() => {
115 const init = async () => {
116 const admin = await checkAdminAccess();
117 setIsAdmin(admin);
118 if (admin) await loadReports("pending");
119 setLoading(false);
120 };
121 init();
122 }, []);
123
124 const handleTabChange = async (tab: Tab) => {
125 setActiveTab(tab);
126 if (tab === "labels") await loadLabels();
127 if (tab === "bans") await loadBans();
128 };
129
130 const handleFilterChange = async (status: string) => {
131 setStatusFilter(status);
132 await loadReports(status);
133 };
134
135 const handleAction = async (reportId: number, action: string) => {
136 setActionLoading(reportId);
137 const success = await adminTakeAction({ reportId, action });
138 if (success) {
139 await loadReports(statusFilter);
140 setExpandedReport(null);
141 }
142 setActionLoading(null);
143 };
144
145 const handleBanFromReport = async (did: string, reportId: number) => {
146 setActionLoading(reportId);
147 await adminBanAccount({ did });
148 setActionLoading(null);
149 };
150
151 const handleBanAccount = async () => {
152 if (!banDid.trim()) return;
153 setBanSubmitting(true);
154 const success = await adminBanAccount({
155 did: banDid.trim(),
156 reason: banReason.trim() || undefined,
157 });
158 if (success) {
159 setBanDid("");
160 setBanReason("");
161 setBanSuccess(true);
162 setTimeout(() => setBanSuccess(false), 2000);
163 await loadBans();
164 }
165 setBanSubmitting(false);
166 };
167
168 const handleUnban = async (did: string) => {
169 setUnbanLoading(did);
170 const success = await adminUnbanAccount(did);
171 if (success) setBans((prev) => prev.filter((b) => b.did !== did));
172 setUnbanLoading(null);
173 };
174
175 const handleCreateLabel = async () => {
176 if (!labelVal || (!labelSrc && !labelUri)) return;
177 setLabelSubmitting(true);
178 const success = await adminCreateLabel({
179 src: labelSrc || labelUri,
180 uri: labelUri || undefined,
181 val: labelVal,
182 });
183 if (success) {
184 setLabelSrc("");
185 setLabelUri("");
186 setLabelVal("");
187 setLabelSuccess(true);
188 setTimeout(() => setLabelSuccess(false), 2000);
189 if (activeTab === "labels") await loadLabels();
190 }
191 setLabelSubmitting(false);
192 };
193
194 const handleDeleteLabel = async (id: number) => {
195 if (!window.confirm(t("adminModeration.labels.removeConfirm"))) return;
196 const success = await adminDeleteLabel(id);
197 if (success) setLabels((prev) => prev.filter((l) => l.id !== id));
198 };
199
200 if (loading) {
201 return (
202 <div className="max-w-3xl mx-auto animate-slide-up">
203 <Skeleton className="h-8 w-48 mb-6" />
204 <div className="space-y-3">
205 <Skeleton className="h-24 rounded-xl" />
206 <Skeleton className="h-24 rounded-xl" />
207 <Skeleton className="h-24 rounded-xl" />
208 </div>
209 </div>
210 );
211 }
212
213 if (!user || !isAdmin) {
214 return (
215 <EmptyState
216 icon={<Shield size={40} />}
217 title={t("adminModeration.accessDenied")}
218 message={t("adminModeration.accessDeniedMessage")}
219 />
220 );
221 }
222
223 return (
224 <div className="max-w-3xl mx-auto animate-slide-up">
225 <div className="flex items-center justify-between mb-6">
226 <div>
227 <h1 className="text-2xl font-display font-bold text-surface-900 dark:text-white flex items-center gap-2.5">
228 <Shield
229 size={24}
230 className="text-primary-600 dark:text-primary-400"
231 />
232 {t("adminModeration.title")}
233 </h1>
234 <p className="text-sm text-surface-500 dark:text-surface-400 mt-1">
235 {t("adminModeration.stats", {
236 pending: pendingCount,
237 total: totalCount,
238 })}
239 </p>
240 </div>
241 </div>
242
243 <div className="flex gap-1 mb-5 border-b border-surface-200 dark:border-surface-700">
244 {[
245 {
246 id: "reports" as Tab,
247 label: t("adminModeration.tabs.reports"),
248 icon: <FileText size={15} />,
249 },
250 {
251 id: "actions" as Tab,
252 label: t("adminModeration.tabs.actions"),
253 icon: <EyeOff size={15} />,
254 },
255 {
256 id: "labels" as Tab,
257 label: t("adminModeration.tabs.labels"),
258 icon: <Tag size={15} />,
259 },
260 {
261 id: "bans" as Tab,
262 label: "Bans",
263 icon: <UserX size={15} />,
264 },
265 ].map((tab) => (
266 <button
267 key={tab.id}
268 onClick={() => handleTabChange(tab.id)}
269 className={`flex items-center gap-1.5 px-4 py-2.5 text-sm font-medium border-b-2 -mb-px transition-colors ${
270 activeTab === tab.id
271 ? "border-primary-600 text-primary-600 dark:border-primary-400 dark:text-primary-400"
272 : "border-transparent text-surface-500 dark:text-surface-400 hover:text-surface-700 dark:hover:text-surface-300"
273 }`}
274 >
275 {tab.icon}
276 {tab.label}
277 </button>
278 ))}
279 </div>
280
281 {activeTab === "reports" && (
282 <>
283 <div className="flex gap-2 mb-5">
284 {["pending", "resolved", "dismissed", "escalated", ""].map(
285 (status) => (
286 <button
287 key={status || "all"}
288 onClick={() => handleFilterChange(status)}
289 className={`px-3.5 py-1.5 text-sm font-medium rounded-lg transition-colors ${
290 statusFilter === status
291 ? "bg-primary-100 text-primary-700 dark:bg-primary-900/30 dark:text-primary-300"
292 : "text-surface-500 dark:text-surface-400 hover:bg-surface-100 dark:hover:bg-surface-800"
293 }`}
294 >
295 {status
296 ? t(`adminModeration.filters.${status}`, {
297 defaultValue:
298 status.charAt(0).toUpperCase() + status.slice(1),
299 })
300 : t("adminModeration.filters.all")}
301 </button>
302 ),
303 )}
304 </div>
305
306 {reports.length === 0 ? (
307 <EmptyState
308 icon={<CheckCircle size={40} />}
309 title={t("adminModeration.reports.empty")}
310 message={
311 statusFilter === "pending"
312 ? t("adminModeration.reports.emptyPending")
313 : t("adminModeration.reports.emptyFiltered", {
314 status: statusFilter || "",
315 })
316 }
317 />
318 ) : (
319 <div className="space-y-3">
320 {reports.map((report) => (
321 <div
322 key={report.id}
323 className="card overflow-hidden transition-all"
324 >
325 <button
326 onClick={() =>
327 setExpandedReport(
328 expandedReport === report.id ? null : report.id,
329 )
330 }
331 className="w-full p-4 flex items-center gap-4 text-left hover:bg-surface-50 dark:hover:bg-surface-800/50 transition-colors"
332 >
333 <Avatar
334 did={report.subject.did}
335 avatar={report.subject.avatar}
336 size="sm"
337 />
338 <div className="flex-1 min-w-0">
339 <div className="flex items-center gap-2 mb-0.5">
340 <span className="font-medium text-surface-900 dark:text-white text-sm truncate">
341 {report.subject.displayName ||
342 report.subject.handle ||
343 report.subject.did}
344 </span>
345 <span
346 className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[report.status] || STATUS_COLORS.pending}`}
347 >
348 {report.status}
349 </span>
350 </div>
351 <p className="text-xs text-surface-500 dark:text-surface-400">
352 {REASON_LABEL_KEYS[report.reasonType]
353 ? t(REASON_LABEL_KEYS[report.reasonType])
354 : report.reasonType}{" "}
355 · reported by @
356 {report.reporter.handle || report.reporter.did} ·{" "}
357 {new Date(report.createdAt).toLocaleDateString()}
358 </p>
359 </div>
360 {expandedReport === report.id ? (
361 <ChevronUp size={16} className="text-surface-400" />
362 ) : (
363 <ChevronDown size={16} className="text-surface-400" />
364 )}
365 </button>
366
367 {expandedReport === report.id && (
368 <div className="px-4 pb-4 border-t border-surface-100 dark:border-surface-800 pt-3 space-y-3">
369 <div className="grid grid-cols-2 gap-3 text-sm">
370 <div>
371 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
372 {t("adminModeration.reports.reportedUser")}
373 </span>
374 <a
375 href={`/profile/${report.subject.did}`}
376 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
377 >
378 @{report.subject.handle || report.subject.did}
379 </a>
380 </div>
381 <div>
382 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
383 {t("adminModeration.reports.reporter")}
384 </span>
385 <a
386 href={`/profile/${report.reporter.did}`}
387 className="block mt-1 text-primary-600 dark:text-primary-400 hover:underline font-medium"
388 >
389 @{report.reporter.handle || report.reporter.did}
390 </a>
391 </div>
392 </div>
393
394 {report.reasonText && (
395 <div>
396 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
397 {t("adminModeration.reports.details")}
398 </span>
399 <p className="text-sm text-surface-700 dark:text-surface-300 mt-1">
400 {report.reasonText}
401 </p>
402 </div>
403 )}
404
405 {report.subjectUri && (
406 <div>
407 <span className="text-surface-400 dark:text-surface-500 text-xs uppercase tracking-wider">
408 {t("adminModeration.reports.contentUri")}
409 </span>
410 <p className="text-xs text-surface-500 font-mono mt-1 break-all">
411 {report.subjectUri}
412 </p>
413 </div>
414 )}
415
416 {report.status === "pending" && (
417 <div className="flex flex-wrap items-center gap-2 pt-2">
418 <Button
419 size="sm"
420 variant="secondary"
421 onClick={() =>
422 handleAction(report.id, "acknowledge")
423 }
424 loading={actionLoading === report.id}
425 icon={<Eye size={14} />}
426 >
427 {t("adminModeration.reports.acknowledge")}
428 </Button>
429 <Button
430 size="sm"
431 variant="secondary"
432 onClick={() => handleAction(report.id, "dismiss")}
433 loading={actionLoading === report.id}
434 icon={<XCircle size={14} />}
435 >
436 {t("adminModeration.reports.dismiss")}
437 </Button>
438 <Button
439 size="sm"
440 onClick={() => handleAction(report.id, "takedown")}
441 loading={actionLoading === report.id}
442 icon={<AlertTriangle size={14} />}
443 className="!bg-red-600 hover:!bg-red-700 !text-white"
444 >
445 {t("adminModeration.reports.takedown")}
446 </Button>
447 <Button
448 size="sm"
449 onClick={() =>
450 handleBanFromReport(report.subject.did, report.id)
451 }
452 loading={actionLoading === report.id}
453 icon={<UserX size={14} />}
454 className="!bg-gray-800 hover:!bg-gray-900 !text-white dark:!bg-gray-700 dark:hover:!bg-gray-600"
455 >
456 Ban user
457 </Button>
458 </div>
459 )}
460 </div>
461 )}
462 </div>
463 ))}
464 </div>
465 )}
466 </>
467 )}
468
469 {activeTab === "actions" && (
470 <div className="space-y-6">
471 <div className="card p-5">
472 <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2">
473 <Tag
474 size={16}
475 className="text-primary-600 dark:text-primary-400"
476 />
477 {t("adminModeration.actions.applyWarning")}
478 </h3>
479 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
480 {t("adminModeration.actions.applyWarningDesc")}
481 </p>
482
483 <div className="space-y-3">
484 <div>
485 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
486 {t("adminModeration.actions.accountDid")}
487 </label>
488 <input
489 type="text"
490 value={labelSrc}
491 onChange={(e) => setLabelSrc(e.target.value)}
492 placeholder="did:plc:..."
493 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
494 />
495 </div>
496
497 <div>
498 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
499 {t("adminModeration.actions.contentUri")}{" "}
500 <span className="text-surface-400">
501 ({t("adminModeration.actions.contentUriOptional")})
502 </span>
503 </label>
504 <input
505 type="text"
506 value={labelUri}
507 onChange={(e) => setLabelUri(e.target.value)}
508 placeholder="at://did:plc:.../at.margin.annotation/..."
509 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
510 />
511 </div>
512
513 <div>
514 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
515 {t("adminModeration.actions.labelType")}
516 </label>
517 <div className="grid grid-cols-3 gap-2">
518 {LABEL_VALS.map((val) => (
519 <button
520 key={val}
521 onClick={() => setLabelVal(val)}
522 className={`px-3 py-2 text-sm font-medium rounded-lg border transition-all ${
523 labelVal === val
524 ? "border-primary-500 bg-primary-50 dark:bg-primary-900/20 text-primary-700 dark:text-primary-300 ring-2 ring-primary-500/20"
525 : "border-surface-200 dark:border-surface-700 text-surface-600 dark:text-surface-400 hover:bg-surface-50 dark:hover:bg-surface-800"
526 }`}
527 >
528 {t(`card.labelDescriptions.${val}`)}
529 </button>
530 ))}
531 </div>
532 </div>
533
534 <div className="flex items-center gap-3 pt-1">
535 <Button
536 onClick={handleCreateLabel}
537 loading={labelSubmitting}
538 disabled={!labelVal || (!labelSrc && !labelUri)}
539 icon={<Plus size={14} />}
540 size="sm"
541 >
542 {t("adminModeration.actions.applyLabel")}
543 </Button>
544 {labelSuccess && (
545 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5">
546 <CheckCircle size={14} />{" "}
547 {t("adminModeration.actions.labelApplied")}
548 </span>
549 )}
550 </div>
551 </div>
552 </div>
553 </div>
554 )}
555
556 {activeTab === "labels" && (
557 <div>
558 {labels.length === 0 ? (
559 <EmptyState
560 icon={<Tag size={40} />}
561 title={t("adminModeration.labels.empty")}
562 message={t("adminModeration.labels.emptyMessage")}
563 />
564 ) : (
565 <div className="space-y-2">
566 {labels.map((label) => (
567 <div
568 key={label.id}
569 className="card p-4 flex items-center gap-4"
570 >
571 {label.subject && (
572 <Avatar
573 did={label.subject.did}
574 avatar={label.subject.avatar}
575 size="sm"
576 />
577 )}
578 <div className="flex-1 min-w-0">
579 <div className="flex items-center gap-2 mb-0.5">
580 <span
581 className={`text-xs px-2 py-0.5 rounded-full font-medium ${
582 label.val === "sexual" || label.val === "nudity"
583 ? "bg-pink-100 text-pink-800 dark:bg-pink-900/30 dark:text-pink-300"
584 : label.val === "violence" || label.val === "gore"
585 ? "bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300"
586 : "bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-300"
587 }`}
588 >
589 {label.val}
590 </span>
591 {label.subject && (
592 <a
593 href={`/profile/${label.subject.did}`}
594 className="text-sm font-medium text-surface-900 dark:text-white hover:text-primary-600 dark:hover:text-primary-400 truncate"
595 >
596 @{label.subject.handle || label.subject.did}
597 </a>
598 )}
599 </div>
600 <p className="text-xs text-surface-500 dark:text-surface-400 truncate">
601 {label.uri !== label.src
602 ? label.uri
603 : t("adminModeration.labels.accountLevel")}{" "}
604 · {new Date(label.createdAt).toLocaleDateString()} · by @
605 {label.createdBy.handle || label.createdBy.did}
606 </p>
607 </div>
608 <button
609 onClick={() => handleDeleteLabel(label.id)}
610 className="p-2 rounded-lg text-surface-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
611 title={t("adminModeration.labels.removeTitle")}
612 >
613 <Trash2 size={14} />
614 </button>
615 </div>
616 ))}
617 </div>
618 )}
619 </div>
620 )}
621
622 {activeTab === "bans" && (
623 <div className="space-y-6">
624 <div className="card p-5">
625 <h3 className="text-base font-semibold text-surface-900 dark:text-white mb-1 flex items-center gap-2">
626 <UserX size={16} className="text-red-500" />
627 Ban account
628 </h3>
629 <p className="text-sm text-surface-500 dark:text-surface-400 mb-4">
630 Banned users cannot sign in and their content is hidden everywhere
631 on Margin.
632 </p>
633
634 <div className="space-y-3">
635 <div>
636 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
637 Account DID
638 </label>
639 <input
640 type="text"
641 value={banDid}
642 onChange={(e) => setBanDid(e.target.value)}
643 placeholder="did:plc:..."
644 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
645 />
646 </div>
647
648 <div>
649 <label className="block text-xs font-medium text-surface-600 dark:text-surface-400 mb-1.5">
650 Reason <span className="text-surface-400">(optional)</span>
651 </label>
652 <input
653 type="text"
654 value={banReason}
655 onChange={(e) => setBanReason(e.target.value)}
656 placeholder="Reason for ban..."
657 className="w-full px-3 py-2 text-sm rounded-lg border border-surface-200 dark:border-surface-700 bg-white dark:bg-surface-800 text-surface-900 dark:text-white placeholder:text-surface-400 focus:outline-none focus:ring-2 focus:ring-primary-500/20 focus:border-primary-500"
658 />
659 </div>
660
661 <div className="flex items-center gap-3 pt-1">
662 <Button
663 onClick={handleBanAccount}
664 loading={banSubmitting}
665 disabled={!banDid.trim()}
666 icon={<UserX size={14} />}
667 size="sm"
668 className="!bg-red-600 hover:!bg-red-700 !text-white"
669 >
670 Ban account
671 </Button>
672 {banSuccess && (
673 <span className="text-sm text-green-600 dark:text-green-400 flex items-center gap-1.5">
674 <CheckCircle size={14} /> Account banned
675 </span>
676 )}
677 </div>
678 </div>
679 </div>
680
681 <div>
682 {bans.length === 0 ? (
683 <EmptyState
684 icon={<UserCheck size={40} />}
685 title="No banned accounts"
686 message="Banned accounts will appear here."
687 />
688 ) : (
689 <div className="space-y-2">
690 {bans.map((ban) => (
691 <div
692 key={ban.did}
693 className="card p-4 flex items-center gap-4"
694 >
695 <Avatar
696 did={ban.did}
697 avatar={ban.profile?.avatar}
698 size="sm"
699 />
700 <div className="flex-1 min-w-0">
701 <div className="flex items-center gap-2 mb-0.5">
702 <span className="font-medium text-surface-900 dark:text-white text-sm truncate">
703 {ban.profile?.displayName ||
704 (ban.profile?.handle && `@${ban.profile.handle}`) ||
705 ban.did}
706 </span>
707 <span className="text-xs px-2 py-0.5 rounded-full font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-300">
708 banned
709 </span>
710 </div>
711 <p className="text-xs text-surface-500 dark:text-surface-400 truncate">
712 {ban.reason ? `${ban.reason} · ` : ""}
713 {new Date(ban.bannedAt).toLocaleDateString()}
714 </p>
715 </div>
716 <button
717 onClick={() => handleUnban(ban.did)}
718 disabled={unbanLoading === ban.did}
719 className="p-2 rounded-lg text-surface-400 hover:text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20 transition-colors"
720 title="Unban"
721 >
722 <UserCheck size={14} />
723 </button>
724 </div>
725 ))}
726 </div>
727 )}
728 </div>
729 </div>
730 )}
731 </div>
732 );
733}